use std::fs;
use std::path::PathBuf;
use super::Check;
use crate::extracted::ExtractedEpub;
use crate::profile::Profile;
use crate::validate::ValidationReport;
pub struct FixedLayoutChecks;
impl Check for FixedLayoutChecks {
fn ids(&self) -> &'static [&'static str] {
&[
"R11.1", "R11.2", "R11.3", "R11.4", "R11.5", "R11.6", "R11.7",
"R11.8", "R11.9",
]
}
fn run(&self, epub: &ExtractedEpub, report: &mut ValidationReport) {
if epub.profile != Profile::Comic && epub.profile != Profile::Textbook {
return;
}
let opf = &epub.opf;
let opf_content = match fs::read_to_string(&epub.opf_path) {
Ok(s) => s,
Err(_) => return,
};
let opf_file = Some(opf_file_label(epub));
let uses_rendition_layout = opf_has_rendition_layout_preparginated(&opf_content);
if !opf.is_fixed_layout {
report.emit_at(
"R11.1",
"Viewport meta present but OPF lacks \
<meta property=\"rendition:layout\">pre-paginated</meta>.",
opf_file.clone(),
None,
);
}
check_rendition_values(&opf_content, &opf_file, report);
if opf.is_fixed_layout && opf.original_resolution.is_none() {
report.emit_at(
"R11.9",
"Fixed-layout OPF has no \
<meta name=\"original-resolution\" content=\"WxH\"/>.",
opf_file.clone(),
None,
);
}
for item in &opf.raw_itemrefs {
let props_lower = item.properties.to_ascii_lowercase();
if props_lower.contains("rendition:layout-reflowable") {
continue;
}
let Some((href, _mt)) = opf.manifest.get(&item.idref) else {
continue;
};
let full = opf.base_dir.join(href);
let bytes = match fs::read(&full) {
Ok(b) => b,
Err(_) => continue,
};
let content = match std::str::from_utf8(&bytes) {
Ok(s) => s.to_string(),
Err(_) => String::from_utf8_lossy(&bytes).to_string(),
};
scan_spine_xhtml(href, &content, uses_rendition_layout, report);
}
}
}
fn opf_file_label(epub: &ExtractedEpub) -> PathBuf {
epub.opf_path
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("content.opf"))
}
fn scan_spine_xhtml(
href: &str,
content: &str,
uses_rendition_layout: bool,
report: &mut ValidationReport,
) {
let file = Some(PathBuf::from(href));
let viewport = find_viewport_meta(content);
match viewport {
None => {
if uses_rendition_layout {
report.emit_at(
"R11.2",
"No <meta name=\"viewport\" ...> in this fixed-layout \
content document.",
file.clone(),
None,
);
report.emit_at(
"R11.7",
"OPF declares rendition:layout=pre-paginated but this \
content has no viewport meta.",
file.clone(),
None,
);
}
}
Some(ref vp) => {
if !vp.has_width || !vp.has_height {
report.emit_at(
"R11.3",
format!(
"Viewport meta is \"{}\"; must include both width= \
and height=.",
vp.raw
),
file.clone(),
None,
);
}
}
}
if !has_image_element(content) {
report.emit_at(
"R11.8",
"Fixed-layout page has no <img>, <image>, or <svg image>; \
Kindle comic readers render it blank.",
file.clone(),
None,
);
}
}
fn opf_has_rendition_layout_preparginated(opf_content: &str) -> bool {
for (prop, value) in iter_rendition_meta(opf_content) {
if prop == "rendition:layout" && value == "pre-paginated" {
return true;
}
}
false
}
#[derive(Debug, Clone)]
struct ViewportMeta {
raw: String,
has_width: bool,
has_height: bool,
}
fn find_viewport_meta(content: &str) -> Option<ViewportMeta> {
let lower = content.to_ascii_lowercase();
let mut cursor = 0usize;
while let Some(idx) = lower[cursor..].find("<meta") {
let abs = cursor + idx;
let after = abs + 5;
let end = match lower[after..].find('>') {
Some(e) => after + e,
None => break,
};
let tag_lower = &lower[after..end];
if tag_lower.contains("name=\"viewport\"") || tag_lower.contains("name='viewport'") {
let tag_raw = &content[after..end];
let content_val = extract_attr_value(tag_raw, "content").unwrap_or_default();
let low = content_val.to_ascii_lowercase();
let has_width = low.contains("width=") || low.contains("width :") || low.contains("width=");
let has_height = low.contains("height=") || low.contains("height :") || low.contains("height=");
let has_width = has_width || low.contains("width:");
let has_height = has_height || low.contains("height:");
return Some(ViewportMeta {
raw: content_val,
has_width,
has_height,
});
}
cursor = end + 1;
}
None
}
fn has_image_element(content: &str) -> bool {
let lower = content.to_ascii_lowercase();
lower.contains("<img ")
|| lower.contains("<img/")
|| lower.contains("<img>")
|| lower.contains("<image ")
|| lower.contains("<image/")
|| lower.contains("<image>")
|| lower.contains("<svg ")
|| lower.contains("<svg>")
}
fn extract_attr_value(tag_body: &str, attr: &str) -> Option<String> {
let needle_eq = format!("{}=", attr);
let idx = tag_body.find(&needle_eq)?;
let rest = &tag_body[idx + needle_eq.len()..];
let quote = rest.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let rest = &rest[1..];
let end = rest.find(quote)?;
Some(rest[..end].to_string())
}
fn check_rendition_values(
opf_content: &str,
opf_file: &Option<PathBuf>,
report: &mut ValidationReport,
) {
for (prop, value) in iter_rendition_meta(opf_content) {
match prop.as_str() {
"rendition:spread" => {
if !is_valid_spread(&value) {
report.emit_at(
"R11.4",
format!(
"rendition:spread=\"{}\" is not one of none, \
landscape, portrait, both, auto.",
value
),
opf_file.clone(),
None,
);
}
}
"rendition:orientation" => {
if !is_valid_orientation(&value) {
report.emit_at(
"R11.5",
format!(
"rendition:orientation=\"{}\" is not one of \
auto, landscape, portrait.",
value
),
opf_file.clone(),
None,
);
}
}
"rendition:layout" => {
if !is_valid_layout(&value) {
report.emit_at(
"R11.6",
format!(
"rendition:layout=\"{}\" is not one of \
pre-paginated, reflowable.",
value
),
opf_file.clone(),
None,
);
}
}
_ => {}
}
}
}
fn iter_rendition_meta(content: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut cursor = 0usize;
let bytes = content.as_bytes();
while cursor < bytes.len() {
let idx = match content[cursor..].find("<meta") {
Some(i) => cursor + i,
None => break,
};
let after = idx + 5;
let next = match bytes.get(after) {
Some(b) => *b,
None => break,
};
if !(next == b' ' || next == b'\t' || next == b'\n' || next == b'\r'
|| next == b'/' || next == b'>')
{
cursor = after;
continue;
}
let end = match content[after..].find('>') {
Some(e) => after + e,
None => break,
};
let open_body = &content[after..end];
let self_closing = open_body.trim_end().ends_with('/');
let trimmed_body = open_body.trim_end_matches('/').trim();
let property = extract_attr_value(trimmed_body, "property");
if let Some(prop) = property {
if prop.starts_with("rendition:") {
let value = if self_closing {
String::new()
} else {
let body_start = end + 1;
let close = "</meta>";
let close_idx = content[body_start..]
.to_ascii_lowercase()
.find(close)
.map(|i| body_start + i);
match close_idx {
Some(ci) => content[body_start..ci].trim().to_string(),
None => String::new(),
}
};
out.push((prop, value));
}
}
cursor = end + 1;
}
out
}
fn is_valid_spread(value: &str) -> bool {
matches!(
value,
"none" | "landscape" | "portrait" | "both" | "auto"
)
}
fn is_valid_orientation(value: &str) -> bool {
matches!(value, "auto" | "landscape" | "portrait")
}
fn is_valid_layout(value: &str) -> bool {
matches!(value, "pre-paginated" | "reflowable")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn viewport_with_width_and_height_detected() {
let s = r##"<html><head><meta name="viewport" content="width=1072, height=1448"/></head></html>"##;
let vp = find_viewport_meta(s).unwrap();
assert!(vp.has_width);
assert!(vp.has_height);
assert!(vp.raw.contains("1072"));
}
#[test]
fn viewport_missing_height_flagged() {
let s = r##"<meta name="viewport" content="width=1072"/>"##;
let vp = find_viewport_meta(s).unwrap();
assert!(vp.has_width);
assert!(!vp.has_height);
}
#[test]
fn viewport_css_colon_syntax_accepted() {
let s = r##"<meta name="viewport" content="width: 1072; height: 1448"/>"##;
let vp = find_viewport_meta(s).unwrap();
assert!(vp.has_width);
assert!(vp.has_height);
}
#[test]
fn no_viewport_returns_none() {
let s = r##"<html><head><title>t</title></head></html>"##;
assert!(find_viewport_meta(s).is_none());
}
#[test]
fn non_viewport_meta_is_ignored() {
let s = r##"<meta name="author" content="Jane"/>"##;
assert!(find_viewport_meta(s).is_none());
}
#[test]
fn img_tag_detected() {
assert!(has_image_element(r##"<p><img src="a.jpg"/></p>"##));
}
#[test]
fn svg_image_detected() {
assert!(has_image_element(r##"<svg><image href="a.png"/></svg>"##));
}
#[test]
fn plain_text_page_has_no_image() {
assert!(!has_image_element("<p>All text, no pictures.</p>"));
}
#[test]
fn valid_spread_values_accepted() {
for v in &["none", "landscape", "portrait", "both", "auto"] {
assert!(is_valid_spread(v), "{} should be valid", v);
}
}
#[test]
fn invalid_spread_values_rejected() {
assert!(!is_valid_spread("left"));
assert!(!is_valid_spread(""));
assert!(!is_valid_spread("NONE"));
}
#[test]
fn valid_orientation_values_accepted() {
for v in &["auto", "landscape", "portrait"] {
assert!(is_valid_orientation(v));
}
}
#[test]
fn invalid_orientation_rejected() {
assert!(!is_valid_orientation("both"));
assert!(!is_valid_orientation("sideways"));
}
#[test]
fn valid_layout_values_accepted() {
assert!(is_valid_layout("pre-paginated"));
assert!(is_valid_layout("reflowable"));
}
#[test]
fn invalid_layout_rejected() {
assert!(!is_valid_layout("fixed"));
assert!(!is_valid_layout("prepaginated"));
assert!(!is_valid_layout(""));
}
#[test]
fn iter_rendition_meta_finds_layout() {
let opf = r##"<package><metadata>
<meta property="rendition:layout">pre-paginated</meta>
<meta property="rendition:spread">landscape</meta>
</metadata></package>"##;
let items = iter_rendition_meta(opf);
assert_eq!(items.len(), 2);
assert_eq!(items[0], ("rendition:layout".to_string(), "pre-paginated".to_string()));
assert_eq!(items[1], ("rendition:spread".to_string(), "landscape".to_string()));
}
#[test]
fn iter_rendition_meta_ignores_other_props() {
let opf = r##"<package><metadata>
<meta property="dcterms:modified">2026-04-15T00:00:00Z</meta>
</metadata></package>"##;
assert_eq!(iter_rendition_meta(opf).len(), 0);
}
#[test]
fn iter_rendition_meta_handles_bad_value() {
let opf = r##"<package><metadata>
<meta property="rendition:orientation">sideways</meta>
</metadata></package>"##;
let items = iter_rendition_meta(opf);
assert_eq!(items.len(), 1);
assert_eq!(items[0].1, "sideways");
}
#[test]
fn r11_4_bad_spread_fires() {
let opf = r##"<package><metadata>
<meta property="rendition:spread">diagonal</meta>
</metadata></package>"##;
let mut r = ValidationReport::new();
check_rendition_values(opf, &None, &mut r);
assert!(r.findings.iter().any(|f| f.rule_id == Some("R11.4")));
}
#[test]
fn r11_5_bad_orientation_fires() {
let opf = r##"<package><metadata>
<meta property="rendition:orientation">backwards</meta>
</metadata></package>"##;
let mut r = ValidationReport::new();
check_rendition_values(opf, &None, &mut r);
assert!(r.findings.iter().any(|f| f.rule_id == Some("R11.5")));
}
#[test]
fn r11_6_bad_layout_fires() {
let opf = r##"<package><metadata>
<meta property="rendition:layout">fixed</meta>
</metadata></package>"##;
let mut r = ValidationReport::new();
check_rendition_values(opf, &None, &mut r);
assert!(r.findings.iter().any(|f| f.rule_id == Some("R11.6")));
}
#[test]
fn good_rendition_values_are_clean() {
let opf = r##"<package><metadata>
<meta property="rendition:layout">pre-paginated</meta>
<meta property="rendition:spread">landscape</meta>
<meta property="rendition:orientation">auto</meta>
</metadata></package>"##;
let mut r = ValidationReport::new();
check_rendition_values(opf, &None, &mut r);
assert_eq!(
r.findings.iter().filter(|f| {
matches!(f.rule_id, Some("R11.4") | Some("R11.5") | Some("R11.6"))
}).count(),
0
);
}
#[test]
fn r11_2_missing_viewport_fires_on_rendition_layout_opf() {
let html = r##"<html><head><title>p1</title></head>
<body><img src="p1.jpg"/></body></html>"##;
let mut r = ValidationReport::new();
scan_spine_xhtml("p1.xhtml", html, true, &mut r);
assert!(r.findings.iter().any(|f| f.rule_id == Some("R11.2")));
}
#[test]
fn r11_2_not_fired_on_amazon_legacy_fixed_layout() {
let html = r##"<html><body><img src="p.jpg"/></body></html>"##;
let mut r = ValidationReport::new();
scan_spine_xhtml("p.xhtml", html, false, &mut r);
assert!(!r.findings.iter().any(|f| f.rule_id == Some("R11.2")));
assert!(!r.findings.iter().any(|f| f.rule_id == Some("R11.7")));
}
#[test]
fn r11_3_viewport_missing_height_fires() {
let html = r##"<html><head>
<meta name="viewport" content="width=1072"/>
</head><body><img src="p1.jpg"/></body></html>"##;
let mut r = ValidationReport::new();
scan_spine_xhtml("p1.xhtml", html, true, &mut r);
assert!(r.findings.iter().any(|f| f.rule_id == Some("R11.3")));
assert!(!r.findings.iter().any(|f| f.rule_id == Some("R11.2")));
}
#[test]
fn r11_7_conflict_fires_when_rendition_layout_and_no_viewport() {
let html = r##"<html><body><img src="p.jpg"/></body></html>"##;
let mut r = ValidationReport::new();
scan_spine_xhtml("p.xhtml", html, true, &mut r);
assert!(r.findings.iter().any(|f| f.rule_id == Some("R11.7")));
}
#[test]
fn r11_8_plaintext_page_fires() {
let html = r##"<html><head>
<meta name="viewport" content="width=1072, height=1448"/>
</head><body><p>All text.</p></body></html>"##;
let mut r = ValidationReport::new();
scan_spine_xhtml("p.xhtml", html, true, &mut r);
assert!(r.findings.iter().any(|f| f.rule_id == Some("R11.8")));
}
#[test]
fn r11_8_page_with_img_is_clean() {
let html = r##"<html><head>
<meta name="viewport" content="width=1072, height=1448"/>
</head><body><img src="p.jpg"/></body></html>"##;
let mut r = ValidationReport::new();
scan_spine_xhtml("p.xhtml", html, true, &mut r);
assert!(!r.findings.iter().any(|f| f.rule_id == Some("R11.8")));
}
#[test]
fn extract_attr_value_handles_single_quotes() {
let body = r##"name='viewport' content='width=1072'"##;
assert_eq!(extract_attr_value(body, "content"), Some("width=1072".to_string()));
}
#[test]
fn extract_attr_value_handles_double_quotes() {
let body = r##"name="viewport" content="width=1072""##;
assert_eq!(extract_attr_value(body, "content"), Some("width=1072".to_string()));
}
#[test]
fn iter_rendition_skips_metadata_element() {
let opf = r##"<package><metadata><dc:title>x</dc:title></metadata></package>"##;
assert_eq!(iter_rendition_meta(opf).len(), 0);
}
#[test]
fn rendition_layout_prepaginated_recognised() {
let opf = r##"<package><metadata>
<meta property="rendition:layout">pre-paginated</meta>
</metadata></package>"##;
assert!(opf_has_rendition_layout_preparginated(opf));
}
#[test]
fn amazon_legacy_fixed_layout_not_rendition_layout() {
let opf = r##"<package><metadata>
<meta name="fixed-layout" content="true"/>
<meta name="original-resolution" content="1072x1448"/>
</metadata></package>"##;
assert!(!opf_has_rendition_layout_preparginated(opf));
}
#[test]
fn reflowable_rendition_layout_not_recognised() {
let opf = r##"<package><metadata>
<meta property="rendition:layout">reflowable</meta>
</metadata></package>"##;
assert!(!opf_has_rendition_layout_preparginated(opf));
}
}