use std::collections::HashSet;
use std::sync::{Arc, LazyLock};
use custom_error::custom_error;
use itertools::Itertools;
use log::debug;
use log::warn;
use regex::Regex;
use krpano_metadata::{KrpanoMetadata, TemplateString, TemplateStringPart, XY};
use crate::dezoomer::{
Dezoomer, DezoomerError, DezoomerInput, DezoomerInputWithContents, Images, IntoZoomLevels,
PageContents, ResolvedImage, TileReference, TilesRect, Vec2d, ZoomLevels,
};
use crate::krpano::krpano_metadata::{ImageInfo, LevelDesc};
use crate::network::resolve_relative;
use krpano_decrypt::{decrypt_xml, is_encrypted_xml};
#[cfg(test)]
use crate::dezoomer::test_utils::{expect_only, expect_resolved_images, expect_single_resolved};
mod krpano_metadata;
#[derive(Default)]
pub struct KrpanoDezoomer {
state: ResolveState,
}
#[derive(Default)]
enum ResolveState {
#[default]
None,
NeedXml {
xml_uri: String,
viewer_js: Vec<u8>,
remaining_js_uris: Vec<String>,
},
NeedJsToDecrypt {
xml_uri: String,
xml_contents: Vec<u8>,
remaining_js_uris: Vec<String>,
},
}
impl Dezoomer for KrpanoDezoomer {
fn name(&self) -> &'static str {
"krpano"
}
fn images(&mut self, data: &DezoomerInput) -> Result<Images, DezoomerError> {
self.handle_input(data, |uri, contents| {
Ok(load_images_from_properties(uri, contents)?.into())
})
}
}
impl KrpanoDezoomer {
fn handle_input<T>(
&mut self,
data: &DezoomerInput,
parse: impl FnOnce(&str, &[u8]) -> Result<T, DezoomerError>,
) -> Result<T, DezoomerError> {
if let Some(error) = self.handle_failed_js_download(data) {
return Err(error);
}
let DezoomerInputWithContents { uri, contents } = data.with_contents()?;
debug!(
"krpano handle_input: uri={uri}, content_len={}",
contents.len()
);
match std::mem::take(&mut self.state) {
ResolveState::None => self.handle_initial_contents(uri, contents, parse),
ResolveState::NeedXml {
xml_uri,
viewer_js,
remaining_js_uris,
} => self.handle_requested_xml(contents, xml_uri, &viewer_js, remaining_js_uris, parse),
ResolveState::NeedJsToDecrypt {
xml_uri,
xml_contents,
remaining_js_uris,
} => self.handle_viewer_js(contents, xml_uri, xml_contents, remaining_js_uris, parse),
}
}
fn handle_failed_js_download(&mut self, data: &DezoomerInput) -> Option<DezoomerError> {
if !matches!(self.state, ResolveState::NeedJsToDecrypt { .. })
|| !matches!(
data.contents,
PageContents::Error(_) | PageContents::Unknown
)
{
return None;
}
let ResolveState::NeedJsToDecrypt {
xml_uri,
xml_contents,
mut remaining_js_uris,
} = std::mem::take(&mut self.state)
else {
unreachable!();
};
if let Some(next_js_uri) = next_js_candidate(&mut remaining_js_uris) {
debug!(
"krpano: viewer JS download failed for {}; trying next JS candidate {next_js_uri}",
data.uri
);
self.state = ResolveState::NeedJsToDecrypt {
xml_uri,
xml_contents,
remaining_js_uris,
};
Some(DezoomerError::NeedsData { uri: next_js_uri })
} else {
Some(DezoomerError::DownloadError {
msg: format!("failed to download viewer JS from {}", data.uri),
})
}
}
fn handle_requested_xml<T>(
&mut self,
contents: &[u8],
xml_uri: String,
viewer_js: &[u8],
mut remaining_js_uris: Vec<String>,
parse: impl FnOnce(&str, &[u8]) -> Result<T, DezoomerError>,
) -> Result<T, DezoomerError> {
debug!(
"krpano state=NeedXml → got content ({} bytes), xml_uri={xml_uri}",
contents.len()
);
if !is_encrypted_xml(contents) {
debug!("krpano: XML is plain, parsing directly");
return parse(&xml_uri, contents);
}
let decrypt_result = if viewer_js.is_empty() {
debug!("krpano: XML is encrypted, trying decryption without viewer JS");
decrypt_xml(contents, None)
} else {
debug!(
"krpano: XML is encrypted, decrypting with saved viewer JS ({} bytes)",
viewer_js.len()
);
decrypt_xml(contents, Some(viewer_js))
};
match decrypt_result {
Ok(decrypted) => {
debug!("krpano: decrypted XML = {} bytes", decrypted.len());
parse(&xml_uri, &decrypted)
}
Err(error) => {
let Some(next_js_uri) = next_js_candidate(&mut remaining_js_uris) else {
return Err(error.into());
};
debug!("krpano: decrypt failed: {error}; trying next JS candidate {next_js_uri}");
self.state = ResolveState::NeedJsToDecrypt {
xml_uri,
xml_contents: contents.to_vec(),
remaining_js_uris,
};
Err(DezoomerError::NeedsData { uri: next_js_uri })
}
}
}
fn handle_viewer_js<T>(
&mut self,
contents: &[u8],
xml_uri: String,
xml_contents: Vec<u8>,
mut remaining_js_uris: Vec<String>,
parse: impl FnOnce(&str, &[u8]) -> Result<T, DezoomerError>,
) -> Result<T, DezoomerError> {
debug!(
"krpano state=NeedJsToDecrypt → got potential viewer JS ({} bytes)",
contents.len()
);
let viewer_js = extract_viewer_js(contents).unwrap_or_else(|| contents.to_vec());
match decrypt_xml(&xml_contents, Some(&viewer_js)) {
Ok(decrypted) => {
debug!("krpano: decrypted XML = {} bytes", decrypted.len());
parse(&xml_uri, &decrypted)
}
Err(error) => {
let Some(next_js_uri) = next_js_candidate(&mut remaining_js_uris) else {
return Err(error.into());
};
debug!(
"krpano: decrypt failed with candidate JS: {error}; trying next JS candidate {next_js_uri}"
);
self.state = ResolveState::NeedJsToDecrypt {
xml_uri,
xml_contents,
remaining_js_uris,
};
Err(DezoomerError::NeedsData { uri: next_js_uri })
}
}
}
fn handle_initial_contents<T>(
&mut self,
uri: &str,
contents: &[u8],
parse: impl FnOnce(&str, &[u8]) -> Result<T, DezoomerError>,
) -> Result<T, DezoomerError> {
if !looks_like_krpano_xml(contents) && looks_like_viewer_js(contents) {
let xml_uri = sibling_uri(uri, "tour.xml");
debug!(
"krpano: content looks like viewer JS ({} bytes), requesting XML: {xml_uri}",
contents.len()
);
self.state = ResolveState::NeedXml {
xml_uri: xml_uri.clone(),
viewer_js: contents.to_vec(),
remaining_js_uris: Vec::new(),
};
return Err(DezoomerError::NeedsData { uri: xml_uri });
}
if !looks_like_krpano_xml(contents) && looks_like_krpano_html(contents) {
debug!("krpano: content looks like HTML ({} bytes)", contents.len());
let html = String::from_utf8_lossy(contents);
let js_uris = extract_js_candidates_from_html(&html, uri);
let xml_uri = extract_xml_from_embedpano(&html).map_or_else(
|| sibling_uri(uri, "tour.xml"),
|rel| resolve_relative(uri, &rel),
);
debug!("krpano HTML: js_uris={js_uris:?}, xml_uri={xml_uri}");
self.state = ResolveState::NeedXml {
xml_uri: xml_uri.clone(),
viewer_js: Vec::new(),
remaining_js_uris: js_uris,
};
return Err(DezoomerError::NeedsData { uri: xml_uri });
}
if is_encrypted_xml(contents) {
if let Ok(decrypted) = decrypt_xml(contents, None) {
debug!(
"krpano: encrypted XML decrypted without viewer JS ({} bytes)",
decrypted.len()
);
return parse(uri, &decrypted);
}
let mut js_uris = viewer_js_candidates_for_xml(uri);
let js_uri =
next_js_candidate(&mut js_uris).unwrap_or_else(|| sibling_uri(uri, "tour.js"));
debug!("krpano: encrypted XML needs viewer JS, requesting: {js_uri}");
self.state = ResolveState::NeedJsToDecrypt {
xml_uri: uri.to_string(),
xml_contents: contents.to_vec(),
remaining_js_uris: js_uris,
};
return Err(DezoomerError::NeedsData { uri: js_uri });
}
debug!(
"krpano: trying to parse as plain XML ({} bytes)",
contents.len()
);
parse(uri, contents)
}
}
fn looks_like_krpano_xml(contents: &[u8]) -> bool {
let contents = contents.strip_prefix(b"\xef\xbb\xbf").unwrap_or(contents);
let text = String::from_utf8_lossy(contents);
let trimmed = text.trim_start();
if trimmed.starts_with("<?xml") {
return true;
}
trimmed.to_ascii_lowercase().starts_with("<krpano")
}
fn looks_like_krpano_html(contents: &[u8]) -> bool {
let text = String::from_utf8_lossy(contents);
let lower = text.to_ascii_lowercase();
if lower.contains("embedpano(") || lower.contains("createpanoviewer(") {
return true;
}
lower.contains("<script") && (lower.contains("krpano") || lower.contains("tour.js"))
}
fn looks_like_viewer_js(contents: &[u8]) -> bool {
let contents = if contents.starts_with(b"\xef\xbb\xbf") {
&contents[3..]
} else {
contents
};
if contents.starts_with(b"/*") {
let window = &contents[..contents.len().min(512)];
return window.windows(6).any(|w| w == b"krpano");
}
if contents.starts_with(b"function ") {
let window = &contents[..contents.len().min(8192)];
return window.windows(6).any(|w| w == b"krpano")
|| window.windows(9).any(|w| w == b"embedpano")
|| window.windows(16).any(|w| w == b"createPanoViewer");
}
false
}
fn extract_js_candidates_from_html(html: &str, html_uri: &str) -> Vec<String> {
debug!(
"extract_js_from_html: scanning {} bytes of HTML",
html.len()
);
let mut candidates = Vec::new();
let mut seen = HashSet::new();
for (index, tag) in SCRIPT_TAG_RE.find_iter(html).enumerate() {
let tag = tag.as_str();
let Some(src) = extract_src_attr(tag) else {
continue;
};
if !is_javascript_src(&src) {
debug!("extract_js_from_html: skipping non-JS src: {src}");
continue;
}
let resolved = resolve_relative(html_uri, &src);
if seen.insert(resolved.clone()) {
let score = viewer_script_score(&src);
debug!("extract_js_from_html: candidate {src} → {resolved}, score={score}");
candidates.push(ScriptCandidate {
uri: resolved,
score,
index,
});
}
}
candidates.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.index.cmp(&b.index)));
let uris = candidates
.into_iter()
.map(|candidate| candidate.uri)
.collect::<Vec<_>>();
if !uris.is_empty() {
debug!("extract_js_from_html: ranked candidates={uris:?}");
return uris;
}
debug!("extract_js_from_html: no <script src> found");
Vec::new()
}
fn extract_xml_from_embedpano(html: &str) -> Option<String> {
let lower = html.to_ascii_lowercase();
let start = lower
.find("embedpano(")
.or_else(|| lower.find("createpanoviewer("))?;
debug!("extract_xml_from_embedpano: found embed call at offset {start}");
let body = &html[start..];
let end = EMBEDPANO_END_RE.find(body)?;
let params = &body[..end.end()];
let caps = EMBEDPANO_XML_RE.captures(params)?;
let xml = &caps[1];
debug!("extract_xml_from_embedpano: found xml={xml:?}");
Some(xml.to_string())
}
fn extract_src_attr(line: &str) -> Option<String> {
let captures = SCRIPT_SRC_RE.captures(line)?;
captures
.get(1)
.or_else(|| captures.get(2))
.or_else(|| captures.get(3))
.map(|capture| capture.as_str().to_string())
}
fn extract_viewer_js(contents: &[u8]) -> Option<Vec<u8>> {
if looks_like_viewer_js(contents) {
return Some(contents.to_vec());
}
let text = String::from_utf8_lossy(contents);
if let Some(start) = text.find("<script>") {
let body = &text[start + 8..];
if let Some(end) = body.find("</script>") {
let js = body[..end].trim();
if looks_like_viewer_js(js.as_bytes()) {
return Some(js.as_bytes().to_vec());
}
}
}
None
}
fn viewer_js_candidates_for_xml(xml_uri: &str) -> Vec<String> {
let path = xml_uri.split_once(['?', '#']).map_or(xml_uri, |(p, _)| p);
let xml_stem = path
.rsplit(['/', '\\'])
.next()
.and_then(|name| name.rsplit_once('.').map(|(stem, _)| stem))
.filter(|stem| !stem.is_empty())
.unwrap_or("tour");
let mut candidates = vec![sibling_uri(xml_uri, &format!("{xml_stem}.js"))];
for fallback in ["tour.js", "krpano.js"] {
let uri = sibling_uri(xml_uri, fallback);
if !candidates.contains(&uri) {
candidates.push(uri);
}
}
candidates
}
fn sibling_uri(uri: &str, filename: &str) -> String {
let uri = uri.split_once(['?', '#']).map_or(uri, |(path, _)| path);
let scheme_end = uri.find("://").map(|i| i + 3);
let search_start = scheme_end.unwrap_or(0);
let after_scheme = &uri[search_start..];
match after_scheme.rfind(['/', '\\']) {
Some(rel_idx) => {
let idx = search_start + rel_idx;
format!("{}{}{filename}", &uri[..idx], &uri[idx..=idx])
}
None => {
if scheme_end.is_some() {
format!("{uri}/{filename}")
} else {
filename.to_string()
}
}
}
}
#[derive(Debug)]
struct ScriptCandidate {
uri: String,
score: i32,
index: usize,
}
static SCRIPT_TAG_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?is)<script\b[^>]*>").unwrap());
static SCRIPT_SRC_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?is)(?:^|[\s<])src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))"#).unwrap()
});
static EMBEDPANO_END_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\}\s*\)").unwrap());
static EMBEDPANO_XML_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?i)\bxml["']?\s*:\s*["']([^"']+)["']"#).unwrap());
fn is_javascript_src(src: &str) -> bool {
let without_query = src.split_once(['?', '#']).map_or(src, |(path, _)| path);
let filename = without_query.rsplit(['/', '\\']).next().unwrap_or_default();
filename
.rsplit_once('.')
.is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("js"))
}
fn viewer_script_score(src: &str) -> i32 {
let lower = src.to_ascii_lowercase();
let path = lower
.split_once(['?', '#'])
.map_or(lower.as_str(), |(path, _)| path);
let filename = path.rsplit(['/', '\\']).next().unwrap_or(path);
let mut score = 0;
if filename == "tour.js" {
score += 1_000;
} else if filename == "krpano.js" {
score += 950;
} else {
if filename.contains("krpano") {
score += 850;
}
if filename.contains("pano") {
score += 450;
}
if filename.contains("tour") {
score += 400;
}
if filename.contains("viewer") {
score += 250;
}
}
if is_common_non_viewer_script(filename) || is_common_non_viewer_script(path) {
score -= 1_000;
}
score
}
fn is_common_non_viewer_script(value: &str) -> bool {
[
"jquery",
"analytics",
"gtag",
"googletagmanager",
"matomo",
"piwik",
"bootstrap",
"modernizr",
"polyfill",
"underscore",
"lodash",
"react",
"vue",
"angular",
"runtime",
"vendor",
]
.iter()
.any(|needle| value.contains(needle))
}
fn next_js_candidate(js_uris: &mut Vec<String>) -> Option<String> {
if js_uris.is_empty() {
None
} else {
Some(js_uris.remove(0))
}
}
custom_error! {pub KrpanoError
XmlError{source: serde_xml_rs::Error} = "Unable to parse the krpano xml file: {source}",
}
impl From<krpano_decrypt::KrpanoDecryptError> for DezoomerError {
fn from(err: krpano_decrypt::KrpanoDecryptError) -> Self {
DezoomerError::Other { source: err.into() }
}
}
impl From<KrpanoError> for DezoomerError {
fn from(err: KrpanoError) -> Self {
DezoomerError::Other { source: err.into() }
}
}
fn load_images_from_properties(
url: &str,
contents: &[u8],
) -> Result<Vec<ResolvedImage>, DezoomerError> {
let decrypted;
let contents = if is_encrypted_xml(contents) {
decrypted = decrypt_xml(contents, None)?;
decrypted.as_slice()
} else {
contents
};
let image_properties = KrpanoMetadata::from_reader(contents).map_err(KrpanoError::from)?;
let base_url = Arc::from(url);
let global_title = image_properties.get_title().unwrap_or("").to_string();
let images = image_properties
.into_image_iter()
.map(|ImageInfo { image, name }| {
let root_tile_size = image.tilesize.map(Vec2d::square);
let base_index = image.baseindex;
let base_url = Arc::clone(&base_url);
let global_title_for_levels = Arc::from(global_title.as_str());
let name_for_levels = Arc::clone(&name);
let levels: ZoomLevels = image
.into_levels()
.enumerate()
.flat_map(move |(level_index, level)| {
let name = Arc::clone(&name_for_levels);
let base_url = Arc::clone(&base_url);
let global_title = Arc::clone(&global_title_for_levels);
level
.level_descriptions(None, level_index)
.into_iter()
.flat_map(move |level_desc| {
let name = Arc::clone(&name);
let base_url = Arc::clone(&base_url);
let global_title = Arc::clone(&global_title);
level_desc
.map_err(|err| warn!("bad krpano level: {err}"))
.into_iter()
.flat_map(
move |LevelDesc {
name: shape_name,
size,
tilesize,
url,
level_index,
}| {
let level = level_index + base_index as usize;
let name = Arc::clone(&name);
let base_url = Arc::clone(&base_url);
let global_title = Arc::clone(&global_title);
url.all_sides(level).filter_map(
move |(side_name, template)| {
let base_url = Arc::clone(&base_url);
let name = Arc::clone(&name);
let global_title = Arc::clone(&global_title);
tilesize.or(root_tile_size).map(|tile_size| Level {
base_url,
size,
tile_size,
base_index,
template,
shape_name,
side_name,
name: Arc::clone(&name),
title: Arc::clone(&global_title),
})
},
)
},
)
})
})
.into_zoom_levels();
let image_title = if name.is_empty() && global_title.is_empty() {
None
} else {
let title = [global_title.as_str(), name.as_ref()]
.iter()
.filter(|s| !s.is_empty())
.copied()
.collect::<Vec<_>>()
.join(" ");
Some(title)
};
ResolvedImage::new(levels, image_title)
})
.collect::<Vec<_>>();
Ok(images)
}
#[derive(PartialEq, Eq)]
struct Level {
base_url: Arc<str>,
size: Vec2d,
tile_size: Vec2d,
base_index: u32,
template: TemplateString<XY>,
shape_name: &'static str,
side_name: &'static str,
name: Arc<str>,
title: Arc<str>,
}
impl TilesRect for Level {
fn size(&self) -> Vec2d {
self.size
}
fn tile_size(&self) -> Vec2d {
self.tile_size
}
fn tile_url(&self, Vec2d { x, y }: Vec2d) -> String {
use std::fmt::Write;
let mut result = String::new();
for part in &self.template.0 {
match part {
TemplateStringPart::Literal(s) => result += s,
TemplateStringPart::Variable { padding, variable } => {
write!(
result,
"{value:0padding$}",
value = self.base_index
+ match variable {
XY::X => x,
XY::Y => y,
},
padding = *padding
)
.unwrap();
}
}
}
resolve_relative(&self.base_url, &result)
}
fn title(&self) -> Option<String> {
if self.title.is_empty() && self.name.is_empty() {
None
} else {
let title = [self.title.as_ref(), self.name.as_ref()].join(" ");
Some(title)
}
}
fn tile_ref(&self, pos: Vec2d) -> TileReference {
TileReference {
url: self.tile_url(pos),
position: self.tile_size() * pos,
}
}
}
impl std::fmt::Debug for Level {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let parts = ["Krpano", self.shape_name, self.side_name, &self.name];
write!(f, "{}", parts.iter().filter(|s| !s.is_empty()).join(" "))
}
}
#[test]
fn test_cube() {
let image = expect_only(load_images_from_properties(
"http://test.com",
r#"<krpano showerrors="false" logkey="false">
<image type="cube" multires="true" tilesize="512" progressive="false" multiresthreshold="-0.3">
<level download="view" decode="view" tiledimagewidth="1000" tiledimageheight="100">
<cube url="http://example.com/%s/%r/%c.jpg"/>
</level>
</image>
</krpano>"#.as_bytes(),
).unwrap());
let mut levels = image.into_zoom_levels();
assert_eq!(levels.len(), 6);
assert_eq!(levels[0].size_hint(), Some(Vec2d { x: 1000, y: 100 }));
assert_eq!(format!("{:?}", levels[0]), "Krpano Cube forward");
assert_eq!(
levels[0].next_tiles(None),
vec![
TileReference {
url: "http://example.com/f/1/1.jpg".to_string(),
position: Vec2d { x: 0, y: 0 }
},
TileReference {
url: "http://example.com/f/1/2.jpg".to_string(),
position: Vec2d { x: 512, y: 0 }
}
]
);
}
#[test]
fn test_flat_multires() {
let image = expect_only(
load_images_from_properties(
"http://test.com",
r#"<krpano>
<image>
<flat url="level=%l x=%0x y=%0y" multires="1,2x3,3x4x3"/>
</image>
</krpano>"#
.as_bytes(),
)
.unwrap(),
);
let mut levels = image.into_zoom_levels();
assert_eq!(levels.len(), 2);
assert_eq!(levels[1].size_hint(), Some(Vec2d { x: 3, y: 4 }));
assert_eq!(format!("{:?}", levels[0]), "Krpano Flat");
assert_eq!(
levels[1].next_tiles(None),
vec![
TileReference {
url: "http://test.com/level=2%20x=01%20y=01".to_string(),
position: Vec2d { x: 0, y: 0 }
},
TileReference {
url: "http://test.com/level=2%20x=01%20y=02".to_string(),
position: Vec2d { x: 0, y: 3 }
}
]
);
}
#[cfg(test)]
const BELLEGAMBE_XML_URL: &str =
"https://pba.lille.fr/gigapixels/Gigapixelweb/gigapixels_1515_bellegambe/gigapixels.xml";
#[cfg(test)]
fn assert_bellegambe_levels(levels: &ZoomLevels) {
let expected_sizes = [
Vec2d { x: 512, y: 342 },
Vec2d { x: 768, y: 514 },
Vec2d { x: 1536, y: 1026 },
Vec2d { x: 3072, y: 2052 },
Vec2d { x: 5888, y: 3930 },
Vec2d { x: 11904, y: 7946 },
Vec2d { x: 23808, y: 15892 },
Vec2d { x: 47616, y: 31782 },
Vec2d { x: 94976, y: 63392 },
];
assert_eq!(levels.len(), expected_sizes.len());
assert_eq!(
levels
.iter()
.map(|level| level.size_hint())
.collect::<Vec<_>>(),
expected_sizes.into_iter().map(Some).collect::<Vec<_>>()
);
let selected_level = &levels[7];
assert_eq!(selected_level.tile_count_hint(), Some(5859));
assert_eq!(
selected_level
.http_headers()
.get("Referer")
.map(String::as_str),
Some(
"https://pba.lille.fr/gigapixels/Gigapixelweb/gigapixels_1515_bellegambe/gigapixels.tiles/l8/001/l8_001_001.jpg"
)
);
}
#[test]
fn explicit_levels_expand_level_placeholder() {
let data = std::fs::read("testdata/krpano/pba_lille_gigapixels_1515_bellegambe.xml").unwrap();
let image = expect_only(load_images_from_properties(BELLEGAMBE_XML_URL, &data).unwrap());
let levels = image.into_zoom_levels();
assert_bellegambe_levels(&levels);
}
#[test]
fn explicit_levels_expand_level_placeholder_in_image() {
let data = std::fs::read("testdata/krpano/pba_lille_gigapixels_1515_bellegambe.xml").unwrap();
let input = DezoomerInput {
uri: BELLEGAMBE_XML_URL.to_string(),
contents: PageContents::Success(data),
};
let image = expect_single_resolved(KrpanoDezoomer::default().images(&input).unwrap());
let levels = image.into_zoom_levels();
assert_bellegambe_levels(&levels);
}
#[test]
fn test_single_image() {
let mut dezoomer = KrpanoDezoomer::default();
let data = r#"<krpano>
<image>
<flat url="level=%l x=%0x y=%0y" multires="1,2x3,3x4x3"/>
</image>
</krpano>"#
.as_bytes();
let input = DezoomerInput {
uri: "http://test.com".to_string(),
contents: PageContents::Success(data.to_vec()),
};
let image = expect_single_resolved(dezoomer.images(&input).unwrap());
assert_eq!(image.title(), None);
assert_eq!(image.levels().len(), 2);
}
#[test]
fn test_cube_faces_form_one_image() {
let mut dezoomer = KrpanoDezoomer::default();
let data = r#"<krpano showerrors="false" logkey="false">
<image type="cube" multires="true" tilesize="512" progressive="false" multiresthreshold="-0.3">
<level download="view" decode="view" tiledimagewidth="1000" tiledimageheight="100">
<cube url="http://example.com/%s/%r/%c.jpg"/>
</level>
</image>
</krpano>"#.as_bytes();
let input = DezoomerInput {
uri: "http://test.com".to_string(),
contents: PageContents::Success(data.to_vec()),
};
let image = expect_single_resolved(dezoomer.images(&input).unwrap());
assert_eq!(image.title(), None);
assert_eq!(image.levels().len(), 6);
}
#[test]
fn test_multiple_scenes_remain_separate() {
let mut dezoomer = KrpanoDezoomer::default();
let data = std::fs::read("testdata/krpano/krpano_scenes.xml").unwrap();
let input = DezoomerInput {
uri: "http://test.com/scenes.xml".to_string(),
contents: PageContents::Success(data),
};
let images = expect_resolved_images(dezoomer.images(&input).unwrap());
let titles = images.iter().map(ResolvedImage::title).collect::<Vec<_>>();
assert_eq!(
titles,
[
Some(
" Saint Thomas (1618 - 1620) - Diego Velazquez - Museum of Fine Arts, Orleans ( France) scene_Color"
),
Some(
" Saint Thomas (1618 - 1620) - Diego Velazquez - Museum of Fine Arts, Orleans ( France) scene_3D"
),
Some(
" Saint Thomas (1618 - 1620) - Diego Velazquez - Museum of Fine Arts, Orleans ( France) scene_3Dcolor"
),
]
);
}
#[test]
fn encrypted_xml_decrypted_without_js() {
let xml = std::fs::read("testdata/krpano/encrypted/2013-08-09-B/tour.xml").unwrap();
let expected = std::fs::read_to_string("testdata/krpano/encrypted/2013-08-09-B/plaintext.xml")
.unwrap()
.replace("\r\n", "\n");
let plaintext_bytes = krpano_decrypt::decrypt_xml(&xml, None).unwrap();
let plaintext = std::str::from_utf8(&plaintext_bytes).unwrap();
assert_eq!(
plaintext, expected,
"decrypted plaintext does not match expected plaintext.xml"
);
}
#[test]
fn html_script_candidates_prefer_krpano_viewer() {
let html = r#"
<html>
<head>
<script src="/assets/jquery.min.js"></script>
<script src='https://www.googletagmanager.com/gtag/js?id=G-TEST'></script>
<script data-src="ignored.js" src = "assets/tour.js?cache=1"></script>
</head>
</html>
"#;
let candidates = extract_js_candidates_from_html(html, "http://example.com/pano/index.html");
assert_eq!(
candidates.first().map(String::as_str),
Some("http://example.com/pano/assets/tour.js?cache=1")
);
}
#[test]
fn sibling_uri_handles_url_and_local_paths() {
assert_eq!(
sibling_uri("http://example.com/pano/tour.js", "tour.xml"),
"http://example.com/pano/tour.xml"
);
assert_eq!(
sibling_uri("http://example.com/pano/", "tour.xml"),
"http://example.com/pano/tour.xml"
);
assert_eq!(
sibling_uri("/home/user/tour.js", "tour.xml"),
"/home/user/tour.xml"
);
assert_eq!(
sibling_uri("C:\\foo\\bar\\tour.js", "tour.xml"),
"C:\\foo\\bar\\tour.xml"
);
assert_eq!(
sibling_uri("\\\\server\\share\\tour.js", "tour.xml"),
"\\\\server\\share\\tour.xml"
);
assert_eq!(sibling_uri("tour.js", "tour.xml"), "tour.xml");
assert_eq!(
sibling_uri("https://example.com", "tour.xml"),
"https://example.com/tour.xml"
);
assert_eq!(
sibling_uri("http://example.com", "tour.js"),
"http://example.com/tour.js"
);
assert_eq!(
sibling_uri("https://example.com?scene=1", "tour.xml"),
"https://example.com/tour.xml"
);
assert_eq!(
sibling_uri("https://example.com#section", "tour.xml"),
"https://example.com/tour.xml"
);
assert_eq!(
sibling_uri("https://example.com/pano/tour.js?cache=1", "tour.xml"),
"https://example.com/pano/tour.xml"
);
}
#[test]
fn viewer_js_candidates_derived_from_xml_filename() {
assert_eq!(
viewer_js_candidates_for_xml("https://example.com/panos/map_core.xml"),
vec![
"https://example.com/panos/map_core.js".to_string(),
"https://example.com/panos/tour.js".to_string(),
"https://example.com/panos/krpano.js".to_string(),
]
);
assert_eq!(
viewer_js_candidates_for_xml("https://example.com/tour.xml"),
vec![
"https://example.com/tour.js".to_string(),
"https://example.com/krpano.js".to_string(),
]
);
assert_eq!(
viewer_js_candidates_for_xml("https://example.com/panos/map_core.xml?v=1.2"),
vec![
"https://example.com/panos/map_core.js".to_string(),
"https://example.com/panos/tour.js".to_string(),
"https://example.com/panos/krpano.js".to_string(),
]
);
}
#[test]
fn extract_xml_from_embedpano_tolerates_whitespace() {
let html = r#"<script>embedpano({ xml : "panos/tour.xml", target:"pano" });</script>"#;
assert_eq!(
extract_xml_from_embedpano(html),
Some("panos/tour.xml".to_string())
);
let html = r#"
embedpano({
xml: "panos/tour.xml"
}
);
"#;
assert_eq!(
extract_xml_from_embedpano(html),
Some("panos/tour.xml".to_string())
);
let html = r#"embedpano({ "xml": "panos/tour.xml" });"#;
assert_eq!(
extract_xml_from_embedpano(html),
Some("panos/tour.xml".to_string())
);
let html = r#"<script>createPanoViewer({ xml: "panos/tour.xml" });</script>"#;
assert_eq!(
extract_xml_from_embedpano(html),
Some("panos/tour.xml".to_string())
);
let html = r#"<script>EMBEDPANO({ xml: "panos/tour.xml" });</script>"#;
assert_eq!(
extract_xml_from_embedpano(html),
Some("panos/tour.xml".to_string())
);
let html = r#"<script>CreatePanoViewer({ xml: "panos/tour.xml" });</script>"#;
assert_eq!(
extract_xml_from_embedpano(html),
Some("panos/tour.xml".to_string())
);
}
#[test]
fn looks_like_krpano_xml_detects_xml_roots() {
assert!(looks_like_krpano_xml(
b"<?xml version=\"1.0\"?><krpano></krpano>"
));
assert!(looks_like_krpano_xml(b"<krpano><image></image></krpano>"));
assert!(looks_like_krpano_xml(
b"\xef\xbb\xbf<?xml version=\"1.0\"?><krpano/>"
));
assert!(looks_like_krpano_xml(
b"<?xml version=\"1.0\"?><krpano><action><![CDATA[embedpano();]]></action></krpano>"
));
assert!(!looks_like_krpano_xml(b"<html><body></body></html>"));
assert!(!looks_like_krpano_xml(b"/* krpano */ function() {}"));
}
#[test]
fn viewer_js_is_detected_before_html_embed_markers() {
let mut dezoomer = KrpanoDezoomer::default();
let viewer_js = b"function embedpano(opts) { /* krpano viewer */ }";
let data = DezoomerInput {
uri: "https://example.com/krpano.js".to_string(),
contents: PageContents::Success(viewer_js.to_vec()),
};
let err = dezoomer.images(&data).unwrap_err();
assert!(matches!(
err,
DezoomerError::NeedsData { ref uri } if uri == "https://example.com/tour.xml"
));
assert!(matches!(
dezoomer.state,
ResolveState::NeedXml { ref viewer_js, ref remaining_js_uris, .. }
if viewer_js == b"function embedpano(opts) { /* krpano viewer */ }"
&& remaining_js_uris.is_empty()
));
}
#[test]
fn old_create_pano_viewer_js_is_detected_as_viewer_js() {
let mut dezoomer = KrpanoDezoomer::default();
let viewer_js = b"function createPanoViewer(opts) { return buildViewer(opts); }";
let data = DezoomerInput {
uri: "https://example.com/viewer.js".to_string(),
contents: PageContents::Success(viewer_js.to_vec()),
};
let err = dezoomer.images(&data).unwrap_err();
assert!(matches!(
err,
DezoomerError::NeedsData { ref uri } if uri == "https://example.com/tour.xml"
));
assert!(matches!(
dezoomer.state,
ResolveState::NeedXml { ref viewer_js, ref remaining_js_uris, .. }
if viewer_js == b"function createPanoViewer(opts) { return buildViewer(opts); }"
&& remaining_js_uris.is_empty()
));
}
#[test]
fn looks_like_krpano_html_requires_krpano_evidence() {
assert!(looks_like_krpano_html(
b"<html><script>embedpano({xml:'tour.xml'})</script></html>"
));
assert!(looks_like_krpano_html(
b"<HTML><BODY><SCRIPT>EMBEDPANO({xml:'tour.xml'})</SCRIPT></BODY></HTML>"
));
assert!(looks_like_krpano_html(
b"<script>createPanoViewer({xml:'tour.xml'});</script>"
));
assert!(looks_like_krpano_html(
b"<html><script src='krpano.js'></script></html>"
));
assert!(looks_like_krpano_html(
b"<html><script src='tour.js'></script></html>"
));
assert!(looks_like_krpano_html(
b"<HTML><SCRIPT SRC='tour.js'></SCRIPT></HTML>"
));
assert!(!looks_like_krpano_html(
b"<html><script src='jquery.min.js'></script></html>"
));
assert!(!looks_like_krpano_html(
b"<HTML><SCRIPT src='analytics.js'></SCRIPT></HTML>"
));
assert!(!looks_like_krpano_html(b"<html><body>Hello</body></html>"));
}