mod assets;
mod base64;
mod canvas;
mod css_native;
mod css_queries;
mod debug;
mod doc_context;
mod doc_template;
mod error;
mod finalize;
mod flate_native;
mod flowable;
mod font;
mod font_subset;
mod frame;
mod glyph_report;
mod html;
mod html_dom;
mod html_entities;
mod image_native;
mod jit;
mod jpeg_native;
mod math;
mod metrics;
mod native_shape;
mod page_data;
mod page_template;
mod parallel;
mod pdf;
mod pdf_encodings;
mod pdf_native;
mod pdf_raster;
mod pdfinspect;
mod perf;
mod plan;
#[cfg(feature = "python")]
mod python;
#[cfg(feature = "python")]
mod python_abi;
mod raster;
mod raster_native;
mod sfnt;
mod sfnt_cff;
mod sfnt_outline;
mod spill;
mod style;
mod svg;
mod text_shape;
mod types;
mod unicode_data;
mod xml;
pub use assets::{Asset, AssetBundle, AssetKind};
pub use canvas::{Canvas, Command, Document, Page};
use debug::DebugLogger;
pub use doc_context::DocContext;
pub use doc_template::DocTemplate;
pub use error::FullBleedError;
pub use finalize::{
BindingSource, ComposeAnnotationMode, ComposePagePlan, FinalizeComposeSummary,
FinalizeStampSummary, META_PAGE_TEMPLATE_KEY, PageBindingDecision, TemplateAsset,
TemplateBindingSpec, TemplateCatalog, collect_page_feature_flags, collect_page_template_names,
compose_overlay_with_template_catalog,
compose_overlay_with_template_catalog_with_annotation_mode, default_page_map,
resolve_template_bindings, resolve_template_bindings_for_document,
stamp_overlay_on_template_pdf, validate_bindings_against_catalog, validate_page_map,
};
pub use flowable::{
AbsolutePositionedFlowable, BreakAfter, BreakBefore, BreakInside, ContainerFlowable, EdgeSizes,
Flowable, ImageFlowable, LengthSpec, Pagination, Paragraph, Spacer, SvgFlowable, TableFlowable,
TextStyle,
};
use font::FontRegistry;
#[cfg(feature = "python")]
use font::RegisteredFontTrace;
pub use frame::{AddResult, Frame};
use fullbleed_audit_contract as audit_contract;
pub use glyph_report::{GlyphCoverageReport, MissingGlyph};
use html_dom::NodeData;
pub use jit::JitMode;
pub use metrics::{DocumentMetrics, PageMetrics};
pub use page_data::{PageDataContext, PageDataOp, PageDataValue, PaginatedContextSpec};
use page_template::PageSelector;
pub use page_template::{FrameSpec, PageTemplate};
use pdf::PdfOptions;
pub use pdf::{OutputIntent, PdfProfile, PdfVersion};
pub use pdfinspect::{
PdfInspectError, PdfInspectErrorCode, PdfInspectReport, PdfInspectWarning,
composition_compatibility_issues, inspect_pdf_bytes, inspect_pdf_path,
require_pdf_composition_compatibility,
};
use perf::PerfLogger;
use std::collections::{HashMap, VecDeque};
use std::f32::consts::PI;
use std::sync::{Arc, Mutex};
pub use types::{Color, ColorSpace, I32F32, Margins, Pt, Rect, Size};
pub struct FullBleed {
default_page_size: Size,
default_margins: Margins,
page_margins: std::collections::BTreeMap<usize, Margins>,
page_size_explicit: bool,
margins_explicit: bool,
font_registry: Arc<FontRegistry>,
pdf_options: PdfOptions,
svg_form_xobjects: bool,
svg_raster_fallback: bool,
debug: Option<Arc<DebugLogger>>,
perf: Option<Arc<PerfLogger>>,
jit_mode: JitMode,
layout_strategy: LayoutStrategy,
lazy_max_passes: usize,
lazy_budget_ms: f64,
page_header: Option<PageHeaderSpec>,
page_header_html: Option<PageHeaderHtmlSpec>,
page_footer: Option<PageFooterSpec>,
paginated_context: Option<PaginatedContextSpec>,
template_binding_spec: Option<TemplateBindingSpec>,
watermark: Option<WatermarkSpec>,
asset_css: String,
asset_bundle: Arc<AssetBundle>,
render_context_cache: Mutex<RenderContextCache>,
}
pub struct CompiledDocument {
document: Arc<Document>,
font_registry: Arc<FontRegistry>,
pdf_options: PdfOptions,
debug: Option<Arc<DebugLogger>>,
perf: Option<Arc<PerfLogger>>,
compile_nanos: u64,
command_count: usize,
}
impl CompiledDocument {
pub fn page_count(&self) -> usize {
self.document.pages.len()
}
pub fn command_count(&self) -> usize {
self.command_count
}
pub fn compile_time_ms(&self) -> f64 {
self.compile_nanos as f64 / 1_000_000.0
}
pub fn render_to_buffer(&self) -> Result<Vec<u8>, FullBleedError> {
Ok(pdf::document_to_pdf_with_metrics_and_registry_with_logs(
&self.document,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
self.debug.clone(),
self.perf.clone(),
)?)
}
pub fn render_to_writer<W: std::io::Write>(
&self,
writer: &mut W,
) -> Result<usize, FullBleedError> {
Ok(
pdf::document_to_pdf_with_metrics_and_registry_to_writer_with_logs(
&self.document,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
writer,
self.debug.clone(),
self.perf.clone(),
)?,
)
}
pub fn render_to_file(
&self,
path: impl AsRef<std::path::Path>,
) -> Result<usize, FullBleedError> {
let mut file = std::fs::File::create(path)?;
self.render_to_writer(&mut file)
}
pub fn render_many_to_buffer(&self, copies: usize) -> Result<Vec<u8>, FullBleedError> {
let mut out = Vec::new();
self.render_many_to_writer(copies, &mut out)?;
Ok(out)
}
pub fn render_many_to_writer<W: std::io::Write>(
&self,
copies: usize,
writer: &mut W,
) -> Result<usize, FullBleedError> {
if copies == 0 {
return Err(FullBleedError::EmptyDocumentSet);
}
let mut pdf_stream = pdf::PdfStreamWriter::new(
writer,
self.document.page_size,
Some(self.font_registry.as_ref()),
self.pdf_options.clone(),
self.debug.clone(),
self.perf.clone(),
)?;
pdf_stream.add_compiled_document_copies(0, &self.document, copies)?;
Ok(pdf_stream.finish()?)
}
}
#[derive(Clone)]
pub struct FullBleedBuilder {
page_size: Size,
margins: Margins,
page_size_explicit: bool,
margins_explicit: bool,
font_dirs: Vec<std::path::PathBuf>,
font_files: Vec<std::path::PathBuf>,
pdf_options: PdfOptions,
svg_form_xobjects: bool,
svg_raster_fallback: bool,
unicode_metrics: bool,
debug_path: Option<std::path::PathBuf>,
perf_enabled: bool,
perf_path: Option<std::path::PathBuf>,
jit_mode: JitMode,
layout_strategy: LayoutStrategy,
accept_lazy_layout_cost: bool,
lazy_max_passes: usize,
lazy_budget_ms: f64,
page_header: Option<PageHeaderSpec>,
page_header_html: Option<PageHeaderHtmlSpec>,
page_footer: Option<PageFooterSpec>,
paginated_context: Option<PaginatedContextSpec>,
template_binding_spec: Option<TemplateBindingSpec>,
page_margins: std::collections::BTreeMap<usize, Margins>,
watermark: Option<WatermarkSpec>,
asset_bundle: AssetBundle,
}
#[derive(Clone)]
struct RenderContext {
resolver: Arc<style::StyleResolver>,
page_templates: Arc<[PageTemplate]>,
}
const RENDER_CONTEXT_CACHE_MAX_ENTRIES: usize = 32;
struct RenderContextCache {
map: HashMap<String, RenderContext>,
order: VecDeque<String>,
}
impl RenderContextCache {
fn new() -> Self {
Self {
map: HashMap::new(),
order: VecDeque::new(),
}
}
fn get(&self, css: &str) -> Option<RenderContext> {
self.map.get(css).cloned()
}
fn insert(&mut self, css: String, context: RenderContext) {
if self.map.contains_key(&css) {
return;
}
self.order.push_back(css.clone());
self.map.insert(css, context);
while self.map.len() > RENDER_CONTEXT_CACHE_MAX_ENTRIES {
let Some(oldest) = self.order.pop_front() else {
break;
};
self.map.remove(&oldest);
}
}
}
struct LayoutBuildResult {
document: Document,
story_ms: f64,
layout_ms: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayoutStrategy {
Eager,
Lazy,
}
#[derive(Debug, Clone)]
pub struct A11yVerifierEvidence {
pub selector: Option<String>,
pub values: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
pub struct HtmlTableFacts {
pub has_caption: bool,
pub th_count: usize,
pub th_scope_count: usize,
}
#[derive(Debug, Clone)]
pub struct A11yVerifierFinding {
pub rule_id: String,
pub applicability: String,
pub verification_mode: String,
pub verdict: String,
pub severity: String,
pub confidence: String,
pub stage: String,
pub source: String,
pub message: String,
pub evidence: Vec<A11yVerifierEvidence>,
}
#[derive(Debug, Clone)]
pub struct A11yVerifierFacts {
pub html_lang: Option<String>,
pub title: String,
pub part_lang_attr_count: usize,
pub invalid_part_lang_attr_count: usize,
pub main_count: usize,
pub duplicate_ids: Vec<String>,
pub missing_idrefs: Vec<(String, String)>,
pub has_html_wrapper: bool,
pub has_css_link: bool,
pub css_link_hrefs: Vec<String>,
pub signature_semantic_count: usize,
pub empty_heading_count: usize,
pub empty_label_count: usize,
pub empty_aria_label_count: usize,
pub unlabeled_region_count: usize,
pub image_count: usize,
pub image_missing_alt_count: usize,
pub image_title_only_count: usize,
pub image_semantic_conflict_count: usize,
pub figure_informative_count: usize,
pub figure_alt_length_budget: usize,
pub figure_alt_over_budget_count: usize,
pub figure_max_alt_len: usize,
pub figure_caption_redundancy_threshold: f64,
pub figure_caption_redundancy_count: usize,
pub figure_max_caption_similarity: f64,
pub figure_missing_effective_text_count: usize,
pub dl_block_count: usize,
pub dl_fragmentation_count: usize,
pub dl_group_consistency_count: usize,
pub redundant_role_native_count: usize,
pub redundant_state_native_count: usize,
pub form_control_count: usize,
pub unlabeled_form_control_count: usize,
pub invalid_form_control_count: usize,
pub unidentified_error_form_control_count: usize,
pub tabindex_attr_count: usize,
pub positive_tabindex_count: usize,
pub invalid_tabindex_count: usize,
pub link_count: usize,
pub unnamed_link_count: usize,
pub generic_link_text_count: usize,
pub custom_click_handler_count: usize,
pub pointer_only_click_handler_count: usize,
pub script_element_count: usize,
pub embedded_active_content_count: usize,
pub autoplay_media_count: usize,
pub blink_marquee_count: usize,
pub inline_event_handler_attr_count: usize,
pub meta_refresh_count: usize,
pub tables: Vec<HtmlTableFacts>,
pub body_text: String,
}
#[derive(Debug, Clone)]
pub struct A11yVerifierCoreReport {
pub profile: String,
pub findings: Vec<A11yVerifierFinding>,
pub facts: A11yVerifierFacts,
}
#[derive(Debug, Clone, Default)]
pub struct PaginationTraceSummary {
pub page_count: Option<i64>,
pub event_count: Option<i64>,
pub transition_count: Option<i64>,
pub page_transition_count: Option<i64>,
pub frame_transition_count: Option<i64>,
pub placement_count: Option<i64>,
pub split_count: Option<i64>,
pub overflow_event_count: Option<i64>,
pub recoverable_overflow_count: Option<i64>,
pub fatal_overflow_count: Option<i64>,
pub low_coverage_page_count: Option<i64>,
pub flowable_overlap_count: Option<i64>,
pub text_overlap_count: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct PmrCoreEvidence {
pub selector: Option<String>,
pub diagnostic_ref: Option<String>,
pub values: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
pub struct PmrCoreAudit {
pub audit_id: String,
pub category: String,
pub weight: f64,
pub class_name: String,
pub verification_mode: String,
pub severity: String,
pub stage: String,
pub source: String,
pub verdict: String,
pub scored: bool,
pub score: Option<f64>,
pub message: String,
pub fix_hint: Option<String>,
pub evidence: Vec<PmrCoreEvidence>,
}
#[derive(Debug, Clone)]
pub struct PmrCoreCategory {
pub id: String,
pub name: String,
pub weight: f64,
pub score: f64,
pub confidence: f64,
pub audit_count: usize,
pub fail_count: usize,
pub warn_count: usize,
}
#[derive(Debug, Clone)]
pub struct PmrCoreManualDebtItem {
pub id: String,
pub reason: String,
pub severity: String,
pub category: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PmrCoreGate {
pub ok: bool,
pub mode: String,
pub error_count: usize,
pub warn_count: usize,
pub failed_audit_ids: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct PmrCoreRank {
pub score: f64,
pub confidence: f64,
pub band: String,
pub raw_score: f64,
}
#[derive(Debug, Clone)]
pub struct PmrCoreCoverage {
pub evaluated_audit_count: usize,
pub applicable_audit_count: usize,
pub scored_audit_count: usize,
pub manual_needed_count: usize,
pub not_evaluated_audit_count: usize,
}
#[derive(Debug, Clone, Default)]
pub struct PmrCoreContext {
pub overflow_count: Option<i64>,
pub known_loss_count: Option<i64>,
pub pagination_summary: Option<PaginationTraceSummary>,
pub source_page_count: Option<i64>,
pub render_page_count: Option<i64>,
pub review_queue_items: Option<i64>,
pub html_artifact_bytes: Option<u64>,
pub css_artifact_bytes: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct PmrCoreReport {
pub profile: String,
pub mode: String,
pub audits: Vec<PmrCoreAudit>,
pub categories: Vec<PmrCoreCategory>,
pub manual_debt_item_count: usize,
pub manual_debt_high_risk_count: usize,
pub manual_debt_items: Vec<PmrCoreManualDebtItem>,
pub coverage: PmrCoreCoverage,
pub rank: PmrCoreRank,
pub gate: PmrCoreGate,
pub facts: A11yVerifierFacts,
}
#[derive(Debug, Clone)]
pub struct PageHeaderSpec {
pub first: Option<String>,
pub each: Option<String>,
pub last: Option<String>,
pub font_name: String,
pub font_size: Pt,
pub color: Color,
pub x: Pt,
pub y_from_top: Pt,
}
#[derive(Debug, Clone)]
pub struct PageHeaderHtmlSpec {
pub first: Option<String>,
pub each: Option<String>,
pub last: Option<String>,
pub x: Pt,
pub y_from_top: Pt,
pub width: Pt,
pub height: Pt,
}
#[derive(Debug, Clone)]
pub struct PageFooterSpec {
pub first: Option<String>,
pub each: Option<String>,
pub last: Option<String>,
pub font_name: String,
pub font_size: Pt,
pub color: Color,
pub x: Pt,
pub y_from_bottom: Pt,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WatermarkLayer {
Background,
Overlay,
}
#[derive(Debug, Clone)]
pub enum WatermarkKind {
Text(String),
Html(String),
Image(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WatermarkSemantics {
Visual,
Artifact,
Ocg,
}
#[derive(Debug, Clone)]
pub struct WatermarkSpec {
pub kind: WatermarkKind,
pub layer: WatermarkLayer,
pub semantics: WatermarkSemantics,
pub opacity: f32,
pub rotation_deg: f32,
pub font_name: String,
pub font_size: Pt,
pub color: Color,
}
impl WatermarkSpec {
pub fn text(text: impl Into<String>) -> Self {
Self {
kind: WatermarkKind::Text(text.into()),
layer: WatermarkLayer::Overlay,
semantics: WatermarkSemantics::Artifact,
opacity: 0.15,
rotation_deg: 0.0,
font_name: "Helvetica".to_string(),
font_size: Pt::from_f32(48.0),
color: Color::rgb(0.6, 0.6, 0.6),
}
}
pub fn html(html: impl Into<String>) -> Self {
Self {
kind: WatermarkKind::Html(html.into()),
layer: WatermarkLayer::Overlay,
semantics: WatermarkSemantics::Artifact,
opacity: 0.15,
rotation_deg: 0.0,
font_name: "Helvetica".to_string(),
font_size: Pt::from_f32(48.0),
color: Color::rgb(0.6, 0.6, 0.6),
}
}
pub fn image(path: impl Into<String>) -> Self {
Self {
kind: WatermarkKind::Image(path.into()),
layer: WatermarkLayer::Overlay,
semantics: WatermarkSemantics::Artifact,
opacity: 0.15,
rotation_deg: 0.0,
font_name: "Helvetica".to_string(),
font_size: Pt::from_f32(48.0),
color: Color::rgb(0.6, 0.6, 0.6),
}
}
}
fn apply_page_header(
doc: &mut Document,
spec: &PageHeaderSpec,
page_data: Option<&PageDataContext>,
report: Option<&mut GlyphCoverageReport>,
font_registry: Option<&FontRegistry>,
) {
let mut report = report;
let total_pages = doc.pages.len();
if total_pages == 0 {
return;
}
let font_name: Arc<str> = Arc::<str>::from(spec.font_name.as_str());
for (idx0, page) in doc.pages.iter_mut().enumerate() {
let page_number = idx0 + 1;
let template = if total_pages == 1 {
spec.first.as_deref().or(spec.last.as_deref())
} else if page_number == 1 {
spec.first.as_deref()
} else if page_number == total_pages {
spec.last.as_deref().or(spec.each.as_deref())
} else {
spec.each.as_deref()
};
let Some(tpl) = template else { continue };
let text = page_data::substitute_placeholders(tpl, page_number, total_pages, page_data);
if let (Some(report), Some(registry)) = (report.as_deref_mut(), font_registry) {
registry.report_missing_glyphs(&font_name, &[], &text, report);
}
page.commands.push(Command::SetFillColor(spec.color));
page.commands
.push(Command::SetFontName(spec.font_name.clone()));
page.commands.push(Command::SetFontSize(spec.font_size));
page.commands.push(Command::DrawString {
x: spec.x,
y: spec.y_from_top,
text,
});
}
}
fn hash_bytes_local(data: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
data.hash(&mut hasher);
hasher.finish()
}
fn document_layout_signature(doc: &Document) -> u64 {
let debug_repr = format!("{:?}", doc);
hash_bytes_local(debug_repr.as_bytes())
}
fn apply_html_page_shrink_to_fit(doc: &mut Document) {
fn max_scrollable_right_milli(commands: &[Command], max_right: &mut i64) {
for command in commands {
match command {
Command::Meta { key, value } if key == canvas::META_HTML_SCROLLABLE_RIGHT_KEY => {
if let Ok(right) = value.parse::<i64>() {
*max_right = (*max_right).max(right);
}
}
Command::DefineForm { commands, .. }
| Command::DefineIsolatedForm { commands, .. } => {
max_scrollable_right_milli(commands, max_right);
}
_ => {}
}
}
}
let page_width_milli = doc.page_size.width.to_milli_i64();
if page_width_milli <= 0 {
return;
}
for page in &mut doc.pages {
let mut max_right_milli = page_width_milli;
max_scrollable_right_milli(&page.commands, &mut max_right_milli);
if max_right_milli <= page_width_milli {
continue;
}
let scale = page_width_milli as f64 / max_right_milli as f64;
let scale = scale as f32;
if !scale.is_finite() || !(0.0..1.0).contains(&scale) {
continue;
}
let commands = std::mem::take(&mut page.commands);
let mut scaled = Vec::with_capacity(commands.len() + 5);
scaled.push(Command::SaveState);
scaled.push(Command::CssTransformOrigin {
x: Pt::ZERO,
y: Pt::ZERO,
inverse: false,
});
scaled.push(Command::Scale(scale, scale));
scaled.push(Command::CssTransformOrigin {
x: Pt::ZERO,
y: Pt::ZERO,
inverse: true,
});
scaled.extend(commands);
scaled.push(Command::RestoreState);
page.commands = scaled;
}
}
fn jit_mode_str(mode: JitMode) -> &'static str {
match mode {
JitMode::Off => "off",
JitMode::PlanOnly => "plan",
JitMode::PlanAndReplay => "replay",
}
}
fn layout_strategy_str(strategy: LayoutStrategy) -> &'static str {
match strategy {
LayoutStrategy::Eager => "eager",
LayoutStrategy::Lazy => "lazy",
}
}
fn pdf_version_str(version: PdfVersion) -> &'static str {
match version {
PdfVersion::Pdf17 => "1.7",
PdfVersion::Pdf20 => "2.0",
}
}
fn pdf_profile_str(profile: PdfProfile) -> &'static str {
profile.as_str()
}
fn validate_pdf_options(options: &PdfOptions) -> Result<(), FullBleedError> {
if !options.pdf_profile.requires_output_intent() {
return Ok(());
}
let Some(intent) = options.output_intent.as_ref() else {
return Err(FullBleedError::InvalidConfiguration(format!(
"pdf_profile={} requires output_intent",
options.pdf_profile.as_str()
)));
};
if intent.icc_profile.is_empty() {
return Err(FullBleedError::InvalidConfiguration(
"output_intent ICC profile cannot be empty".to_string(),
));
}
if !matches!(intent.n_components, 1 | 3 | 4) {
return Err(FullBleedError::InvalidConfiguration(format!(
"output_intent n_components must be one of 1, 3, or 4 (got {})",
intent.n_components
)));
}
if intent.identifier.trim().is_empty() {
return Err(FullBleedError::InvalidConfiguration(
"output_intent identifier cannot be empty".to_string(),
));
}
Ok(())
}
fn count_commands(doc: &Document) -> usize {
doc.pages.iter().map(|p| p.commands.len()).sum()
}
fn count_form_commands(doc: &Document) -> (usize, usize, usize, usize) {
let mut defs = 0usize;
let mut draws = 0usize;
let mut svg_defs = 0usize;
let mut svg_draws = 0usize;
for page in &doc.pages {
for cmd in &page.commands {
match cmd {
Command::DefineForm { resource_id, .. }
| Command::DefineIsolatedForm { resource_id, .. } => {
defs += 1;
if resource_id.starts_with("svg:") {
svg_defs += 1;
}
}
Command::DrawForm { resource_id, .. }
| Command::DrawFilteredForm { resource_id, .. } => {
draws += 1;
if resource_id.starts_with("svg:") {
svg_draws += 1;
}
}
_ => {}
}
}
}
(defs, draws, svg_defs, svg_draws)
}
fn count_page_data_entries(ctx: &PageDataContext) -> usize {
let mut count = 0usize;
for page in &ctx.pages {
count += page.len();
}
count + ctx.totals.len()
}
fn log_jit_metrics(
logger: &DebugLogger,
doc_id: usize,
mode: JitMode,
options: &PdfOptions,
story_ms: f64,
layout_ms: f64,
plan_ms: f64,
finalize_ms: Option<f64>,
doc: &Document,
overlay: Option<&Document>,
plan: Option<&jit::DocPlan>,
page_data: Option<&PageDataContext>,
) {
let pages = doc.pages.len();
let commands = count_commands(doc);
let overlay_commands = overlay.map(count_commands).unwrap_or(0);
let (doc_form_defs, doc_form_draws, doc_svg_defs, doc_svg_draws) = count_form_commands(doc);
let (ov_form_defs, ov_form_draws, ov_svg_defs, ov_svg_draws) =
overlay.map(count_form_commands).unwrap_or((0, 0, 0, 0));
let paintables = plan.map(|p| p.paintables.len()).unwrap_or(0);
let placements = plan
.map(|p| {
p.pages
.iter()
.map(|page| page.placements.len())
.sum::<usize>()
})
.unwrap_or(0);
let page_data_entries = page_data.map(count_page_data_entries).unwrap_or(0);
let finalize_json = finalize_ms
.map(|v| format!("{v:.3}"))
.unwrap_or_else(|| "null".to_string());
let json = format!(
"{{\"type\":\"jit.metrics\",\"doc_id\":{},\"mode\":\"{}\",\"pdf_version\":\"{}\",\"pdf_profile\":\"{}\",\"timing_ms\":{{\"story\":{:.3},\"layout\":{:.3},\"plan\":{:.3},\"finalize\":{}}},\"counts\":{{\"pages\":{},\"commands\":{},\"overlay_commands\":{},\"form_defs\":{},\"form_draws\":{},\"svg_form_defs\":{},\"svg_form_draws\":{},\"paintables\":{},\"placements\":{},\"page_data_entries\":{}}}}}",
doc_id,
jit_mode_str(mode),
pdf_version_str(options.pdf_version),
pdf_profile_str(options.pdf_profile),
story_ms,
layout_ms,
plan_ms,
finalize_json,
pages,
commands,
overlay_commands,
doc_form_defs + ov_form_defs,
doc_form_draws + ov_form_draws,
doc_svg_defs + ov_svg_defs,
doc_svg_draws + ov_svg_draws,
paintables,
placements,
page_data_entries
);
logger.log_json(&json);
}
fn render_html_snippet_to_commands(
html_snippet: &str,
resolver: &style::StyleResolver,
page_size: Size,
width: Pt,
height: Pt,
font_registry: Option<Arc<FontRegistry>>,
asset_bundle: Option<Arc<AssetBundle>>,
report: Option<&mut GlyphCoverageReport>,
svg_form: bool,
svg_raster_fallback: bool,
transparent_body: bool,
perf: Option<&PerfLogger>,
) -> Vec<Command> {
let body_style = if transparent_body {
" style=\"background: transparent;\""
} else {
""
};
let html = format!(
"<!doctype html><html><body{body_style}>{}</body></html>",
html_snippet
);
let story = html::html_to_story_with_resolver_and_fonts_and_report(
&html,
resolver,
font_registry,
asset_bundle,
report,
svg_form,
svg_raster_fallback,
perf,
None,
);
let mut canvas = Canvas::new(page_size);
let mut frame = Frame::new(Rect {
x: Pt::ZERO,
y: Pt::ZERO,
width,
height,
});
for flowable in story {
match frame.add(flowable, &mut canvas) {
AddResult::Placed(_) => {}
AddResult::Split(_remaining, _) => break,
AddResult::Overflow(_remaining, _) => break,
}
}
let doc = canvas.finish();
doc.pages
.get(0)
.map(|p| p.commands.clone())
.unwrap_or_default()
}
fn substitute_placeholders_in_commands(
commands: &[Command],
page_number: usize,
total_pages: usize,
page_data: Option<&PageDataContext>,
) -> Vec<Command> {
commands
.iter()
.map(|cmd| match cmd {
Command::DrawString { x, y, text } => Command::DrawString {
x: *x,
y: *y,
text: page_data::substitute_placeholders(text, page_number, total_pages, page_data),
},
Command::DefineForm {
resource_id,
width,
height,
commands,
} => Command::DefineForm {
resource_id: resource_id.clone(),
width: *width,
height: *height,
commands: substitute_placeholders_in_commands(
commands,
page_number,
total_pages,
page_data,
),
},
Command::DefineIsolatedForm {
resource_id,
width,
height,
commands,
} => Command::DefineIsolatedForm {
resource_id: resource_id.clone(),
width: *width,
height: *height,
commands: substitute_placeholders_in_commands(
commands,
page_number,
total_pages,
page_data,
),
},
_ => cmd.clone(),
})
.collect()
}
fn apply_page_header_html(
doc: &mut Document,
spec: &PageHeaderHtmlSpec,
resolver: &style::StyleResolver,
page_data: Option<&PageDataContext>,
font_registry: Option<Arc<FontRegistry>>,
asset_bundle: Option<Arc<AssetBundle>>,
report: Option<&mut GlyphCoverageReport>,
svg_form: bool,
svg_raster_fallback: bool,
perf: Option<&PerfLogger>,
) {
let total_pages = doc.pages.len();
if total_pages == 0 {
return;
}
struct HeaderTemplateEntry {
base_commands: Vec<Command>,
slot_eligible: bool,
rendered_cache: std::collections::HashMap<u64, Vec<Command>>,
}
let mut template_cache: std::collections::HashMap<String, HeaderTemplateEntry> =
std::collections::HashMap::new();
let mut report = report;
let use_cache = report.is_none();
for (idx0, page) in doc.pages.iter_mut().enumerate() {
let page_number = idx0 + 1;
let template = if total_pages == 1 {
spec.first.as_deref().or(spec.last.as_deref())
} else if page_number == 1 {
spec.first.as_deref()
} else if page_number == total_pages {
spec.last.as_deref().or(spec.each.as_deref())
} else {
spec.each.as_deref()
};
let Some(tpl) = template else { continue };
let rendered = page_data::substitute_placeholders(tpl, page_number, total_pages, page_data);
let entry = template_cache.entry(tpl.to_string()).or_insert_with(|| {
let slot_eligible = !html::template_uses_attribute_placeholders(tpl);
let base_commands = if slot_eligible && use_cache {
render_html_snippet_to_commands(
tpl,
resolver,
doc.page_size,
spec.width,
spec.height,
font_registry.clone(),
asset_bundle.clone(),
None,
svg_form,
svg_raster_fallback,
false,
perf,
)
} else {
Vec::new()
};
HeaderTemplateEntry {
base_commands,
slot_eligible,
rendered_cache: std::collections::HashMap::new(),
}
});
let cmds = if use_cache && entry.slot_eligible {
let key = hash_bytes_local(rendered.as_bytes());
entry
.rendered_cache
.entry(key)
.or_insert_with(|| {
substitute_placeholders_in_commands(
&entry.base_commands,
page_number,
total_pages,
page_data,
)
})
.clone()
} else if use_cache {
let key = hash_bytes_local(rendered.as_bytes());
entry
.rendered_cache
.entry(key)
.or_insert_with(|| {
render_html_snippet_to_commands(
&rendered,
resolver,
doc.page_size,
spec.width,
spec.height,
font_registry.clone(),
asset_bundle.clone(),
None,
svg_form,
svg_raster_fallback,
false,
perf,
)
})
.clone()
} else {
render_html_snippet_to_commands(
&rendered,
resolver,
doc.page_size,
spec.width,
spec.height,
font_registry.clone(),
asset_bundle.clone(),
report.as_deref_mut(),
svg_form,
svg_raster_fallback,
false,
perf,
)
};
let form_id = format!("hdr-{:016x}", hash_bytes_local(rendered.as_bytes()));
page.commands.push(Command::DefineForm {
resource_id: form_id.clone(),
width: spec.width,
height: spec.height,
commands: cmds,
});
page.commands.push(Command::SaveState);
page.commands
.push(Command::Translate(spec.x, spec.y_from_top));
page.commands.push(Command::ClipRect {
x: Pt::ZERO,
y: Pt::ZERO,
width: spec.width,
height: spec.height,
});
page.commands.push(Command::DrawForm {
x: Pt::ZERO,
y: Pt::ZERO,
width: spec.width,
height: spec.height,
resource_id: form_id,
});
page.commands.push(Command::RestoreState);
}
}
fn apply_page_footer(
doc: &mut Document,
spec: &PageFooterSpec,
page_data: Option<&PageDataContext>,
report: Option<&mut GlyphCoverageReport>,
font_registry: Option<&FontRegistry>,
) {
let mut report = report;
let total_pages = doc.pages.len();
if total_pages == 0 {
return;
}
let font_name: Arc<str> = Arc::<str>::from(spec.font_name.as_str());
for (idx0, page) in doc.pages.iter_mut().enumerate() {
let page_number = idx0 + 1;
let template = if total_pages == 1 {
spec.last
.as_deref()
.or(spec.first.as_deref())
.or(spec.each.as_deref())
} else if page_number == 1 {
spec.first.as_deref().or(spec.each.as_deref())
} else if page_number == total_pages {
spec.last.as_deref().or(spec.each.as_deref())
} else {
spec.each.as_deref()
};
let Some(tpl) = template else { continue };
let text = page_data::substitute_placeholders(tpl, page_number, total_pages, page_data);
if let (Some(report), Some(registry)) = (report.as_deref_mut(), font_registry) {
registry.report_missing_glyphs(&font_name, &[], &text, report);
}
let y = (doc.page_size.height - spec.y_from_bottom - spec.font_size).max(Pt::ZERO);
page.commands.push(Command::SetFillColor(spec.color));
page.commands
.push(Command::SetFontName(spec.font_name.clone()));
page.commands.push(Command::SetFontSize(spec.font_size));
page.commands
.push(Command::DrawString { x: spec.x, y, text });
}
}
fn watermark_image_bytes(bundle: Option<&AssetBundle>, source: &str) -> Option<Vec<u8>> {
let resolved = assets::resolve_image_asset(bundle, source);
resolved.trace.success.then_some(resolved.bytes)
}
fn watermark_image_size(
bundle: Option<&AssetBundle>,
source: &str,
page_size: Size,
) -> Option<Size> {
let bytes = watermark_image_bytes(bundle, source)?;
let (w, h) = image_native::dimensions(&bytes).ok()?;
if w == 0 || h == 0 {
return None;
}
let max_w = page_size.width.to_f32() * 0.35;
let max_h = page_size.height.to_f32() * 0.35;
let mut scale = max_w / (w as f32);
let height = (h as f32) * scale;
if height > max_h {
scale = max_h / (h as f32);
}
let width = (w as f32) * scale;
let height = (h as f32) * scale;
Some(Size {
width: Pt::from_f32(width),
height: Pt::from_f32(height),
})
}
const WATERMARK_OCG_RESOURCE_NAME: &str = "FBWM";
fn build_watermark_commands(
spec: &WatermarkSpec,
page_size: Size,
page_number: usize,
total_pages: usize,
page_data: Option<&PageDataContext>,
resolver: &style::StyleResolver,
font_registry: Option<Arc<FontRegistry>>,
asset_bundle: Option<Arc<AssetBundle>>,
report: Option<&mut GlyphCoverageReport>,
svg_form: bool,
svg_raster_fallback: bool,
) -> Vec<Command> {
let mut commands = Vec::new();
let angle = spec.rotation_deg * (PI / 180.0);
let cx = page_size.width.mul_ratio(1, 2);
let cy = page_size.height.mul_ratio(1, 2);
let mut report = report;
match spec.semantics {
WatermarkSemantics::Visual => {}
WatermarkSemantics::Artifact => {
commands.push(Command::BeginArtifact {
subtype: Some("Watermark".to_string()),
});
}
WatermarkSemantics::Ocg => {
commands.push(Command::BeginOptionalContent {
name: WATERMARK_OCG_RESOURCE_NAME.to_string(),
});
commands.push(Command::BeginArtifact {
subtype: Some("Watermark".to_string()),
});
}
}
match &spec.kind {
WatermarkKind::Text(text) => {
let rendered =
page_data::substitute_placeholders(text, page_number, total_pages, page_data);
let width = if let Some(registry) = font_registry.as_deref() {
registry.measure_text_width(&spec.font_name, spec.font_size, &rendered)
} else {
let approx = spec.font_size.to_f32() * 0.6;
Pt::from_f32(approx * rendered.chars().count() as f32)
};
let local_y = Pt::ZERO - spec.font_size.mul_ratio(1, 2);
let compensated_y = page_size.height - local_y - spec.font_size;
if let (Some(report), Some(registry)) =
(report.as_deref_mut(), font_registry.as_deref())
{
let font_name: Arc<str> = Arc::<str>::from(spec.font_name.as_str());
registry.report_missing_glyphs(&font_name, &[], &rendered, report);
}
commands.push(Command::SaveState);
commands.push(Command::SetOpacity {
fill: spec.opacity,
stroke: spec.opacity,
});
commands.push(Command::SetFillColor(spec.color));
commands.push(Command::Translate(cx, cy));
if angle.abs() > f32::EPSILON {
commands.push(Command::Rotate(angle));
}
commands.push(Command::SetFontName(spec.font_name.clone()));
commands.push(Command::SetFontSize(spec.font_size));
commands.push(Command::DrawString {
x: Pt::ZERO - width.mul_ratio(1, 2),
y: compensated_y,
text: rendered,
});
commands.push(Command::RestoreState);
}
WatermarkKind::Html(html) => {
let rendered =
page_data::substitute_placeholders(html, page_number, total_pages, page_data);
let width = page_size.width;
let height = page_size.height;
let cmds = render_html_snippet_to_commands(
&rendered,
resolver,
page_size,
width,
height,
font_registry,
asset_bundle.clone(),
report.as_deref_mut(),
svg_form,
svg_raster_fallback,
true,
None,
);
let form_id = format!("wm-{:016x}", hash_bytes_local(rendered.as_bytes()));
commands.push(Command::DefineForm {
resource_id: form_id.clone(),
width,
height,
commands: cmds,
});
commands.push(Command::SaveState);
commands.push(Command::SetOpacity {
fill: spec.opacity,
stroke: spec.opacity,
});
commands.push(Command::Translate(cx, cy));
if angle.abs() > f32::EPSILON {
commands.push(Command::Rotate(angle));
}
let local_x = Pt::ZERO - width.mul_ratio(1, 2);
let local_y = Pt::ZERO - height.mul_ratio(1, 2);
let compensated_y = page_size.height - local_y - height;
commands.push(Command::DrawForm {
x: local_x,
y: compensated_y,
width,
height,
resource_id: form_id,
});
commands.push(Command::RestoreState);
}
WatermarkKind::Image(path) => {
let resolved_path = assets::renderable_image_source(asset_bundle.as_deref(), path)
.unwrap_or_else(|| path.clone());
let size =
watermark_image_size(asset_bundle.as_deref(), path, page_size).unwrap_or(Size {
width: page_size.width.mul_ratio(1, 3),
height: page_size.height.mul_ratio(1, 3),
});
commands.push(Command::SaveState);
commands.push(Command::SetOpacity {
fill: spec.opacity,
stroke: spec.opacity,
});
commands.push(Command::Translate(cx, cy));
if angle.abs() > f32::EPSILON {
commands.push(Command::Rotate(angle));
}
commands.push(Command::Translate(
Pt::ZERO - size.width.mul_ratio(1, 2),
Pt::ZERO - size.height.mul_ratio(1, 2),
));
let compensated_y = page_size.height - size.height;
commands.push(Command::DrawImage {
x: Pt::ZERO,
y: compensated_y,
width: size.width,
height: size.height,
resource_id: resolved_path,
interpolate: true,
});
commands.push(Command::RestoreState);
}
}
match spec.semantics {
WatermarkSemantics::Visual => {}
WatermarkSemantics::Artifact => {
commands.push(Command::EndMarkedContent);
}
WatermarkSemantics::Ocg => {
commands.push(Command::EndMarkedContent);
commands.push(Command::EndMarkedContent);
}
}
commands
}
fn build_watermark_document(
base: &Document,
spec: &WatermarkSpec,
resolver: &style::StyleResolver,
page_data: Option<&PageDataContext>,
mut report: Option<&mut GlyphCoverageReport>,
font_registry: Option<Arc<FontRegistry>>,
asset_bundle: Option<Arc<AssetBundle>>,
svg_form: bool,
svg_raster_fallback: bool,
) -> Document {
let total_pages = base.pages.len();
let mut doc = Document {
page_size: base.page_size,
pages: base
.pages
.iter()
.map(|_| Page {
commands: Vec::new(),
})
.collect(),
};
for (idx, page) in doc.pages.iter_mut().enumerate() {
let page_number = idx + 1;
let cmds = build_watermark_commands(
spec,
base.page_size,
page_number,
total_pages,
page_data,
resolver,
font_registry.clone(),
asset_bundle.clone(),
report.as_deref_mut(),
svg_form,
svg_raster_fallback,
);
page.commands.extend(cmds);
}
doc
}
fn merge_background_commands(base: &mut Document, background: &Document) {
if base.pages.len() != background.pages.len() {
return;
}
for (base_page, bg_page) in base.pages.iter_mut().zip(background.pages.iter()) {
if !bg_page.commands.is_empty() {
base_page.commands.splice(0..0, bg_page.commands.clone());
}
}
}
fn escape_html_text(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
impl FullBleed {
pub fn builder() -> FullBleedBuilder {
FullBleedBuilder::new()
}
#[cfg(feature = "python")]
pub(crate) fn measure_text_width_for_trace(
&self,
font_name: &str,
font_size: Pt,
text: &str,
) -> Pt {
self.font_registry
.measure_text_width(font_name, font_size, text)
}
#[cfg(feature = "python")]
pub(crate) fn resolve_registered_font_trace(
&self,
font_name: &str,
) -> Option<RegisteredFontTrace> {
self.font_registry.resolve_trace(font_name)
}
#[cfg(feature = "python")]
pub(crate) fn asset_bundle_ref(&self) -> &AssetBundle {
self.asset_bundle.as_ref()
}
#[cfg(feature = "python")]
pub(crate) fn svg_raster_fallback_enabled(&self) -> bool {
self.svg_raster_fallback
}
fn emit_debug_summary(&self, context: &str) {
if let Some(logger) = self.debug.as_deref() {
logger.emit_summary(context);
logger.flush();
}
if let Some(perf) = self.perf.as_deref() {
perf.flush();
}
}
fn emit_html_asset_warnings(&self, doc_id: usize, html: &str) {
let warnings = html::scan_html_asset_warnings(html);
if warnings.is_empty() {
return;
}
for warning in warnings {
let detail_preview = if warning.details.is_empty() {
String::new()
} else {
let mut preview = warning.details.clone();
if preview.len() > 3 {
preview.truncate(3);
preview.push("...".to_string());
}
format!(" ({})", preview.join(", "))
};
eprintln!(
"[fullbleed][assets] doc {}: {}{}",
doc_id, warning.message, detail_preview
);
if let Some(logger) = self.debug.as_deref() {
let details = warning
.details
.iter()
.map(|d| format!("\"{}\"", d.replace('"', "\\\"")))
.collect::<Vec<_>>()
.join(",");
let json = format!(
"{{\"type\":\"jit.html_asset_warning\",\"doc_id\":{},\"kind\":\"{}\",\"message\":\"{}\",\"details\":[{}]}}",
doc_id,
warning.kind.replace('"', "\\\""),
warning.message.replace('"', "\\\""),
details
);
logger.log_json(&json);
}
}
}
fn has_full_page_background(doc: &Document) -> bool {
let page_w = doc.page_size.width.to_f32();
let page_h = doc.page_size.height.to_f32();
let min_w = page_w * 0.85;
let min_h = page_h * 0.85;
for page in &doc.pages {
for cmd in &page.commands {
if let Command::DrawRect {
x,
y,
width,
height,
} = cmd
{
let w = width.to_f32();
let h = height.to_f32();
let x = x.to_f32();
let y = y.to_f32();
if w >= min_w && h >= min_h && x <= 20.0 && y <= 20.0 {
return true;
}
}
}
}
false
}
fn merge_css(&self, css: &str) -> String {
if self.asset_css.is_empty() {
css.to_string()
} else if css.trim().is_empty() {
self.asset_css.clone()
} else {
let mut merged = String::with_capacity(self.asset_css.len() + css.len() + 2);
merged.push_str(&self.asset_css);
merged.push('\n');
merged.push('\n');
merged.push_str(css);
merged
}
}
pub fn document_lang(&self) -> Option<&str> {
self.pdf_options.document_lang.as_deref()
}
pub fn document_title(&self) -> Option<&str> {
self.pdf_options.document_title.as_deref()
}
pub fn compose_document_html(&self, body_html: &str) -> String {
let lang = self.document_lang().unwrap_or("en");
let title = self.document_title().unwrap_or("fullbleed document");
let mut out = String::with_capacity(body_html.len() + title.len() + lang.len() + 160);
out.push_str("<!doctype html><html lang=\"");
out.push_str(&escape_html_text(lang));
out.push_str("\"><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /><title>");
out.push_str(&escape_html_text(title));
out.push_str("</title></head><body>");
out.push_str(body_html);
out.push_str("</body></html>");
out
}
pub fn compose_artifact_css(&self, css: &str) -> String {
self.merge_css(css)
}
pub fn emit_html_artifact(
&self,
html: &str,
path: impl AsRef<std::path::Path>,
wrap_document: bool,
) -> Result<String, FullBleedError> {
let text = if wrap_document {
self.compose_document_html(html)
} else {
html.to_string()
};
let path = path.as_ref();
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(path, &text)?;
Ok(text)
}
pub fn emit_css_artifact(
&self,
css: &str,
path: impl AsRef<std::path::Path>,
) -> Result<String, FullBleedError> {
let text = self.compose_artifact_css(css);
let path = path.as_ref();
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(path, &text)?;
Ok(text)
}
pub fn emit_html_css_artifacts(
&self,
html: &str,
css: &str,
html_path: impl AsRef<std::path::Path>,
css_path: impl AsRef<std::path::Path>,
wrap_document: bool,
) -> Result<(String, String), FullBleedError> {
let html_text = self.emit_html_artifact(html, html_path, wrap_document)?;
let css_text = self.emit_css_artifact(css, css_path)?;
Ok((html_text, css_text))
}
fn verify_accessibility_html_facts(&self, html: &str) -> A11yVerifierFacts {
let document = html_dom::parse_html(html);
let mut html_lang: Option<String> = None;
if let Ok(mut html_nodes) = document.select("html") {
if let Some(node) = html_nodes.next() {
let attrs = node.attributes.borrow();
html_lang = attrs.get("lang").map(|v| v.trim().to_string());
if matches!(html_lang.as_deref(), Some("")) {
html_lang = None;
}
}
}
let mut title = String::new();
if let Ok(mut titles) = document.select("head title, title") {
if let Some(node) = titles.next() {
title = node.text_contents().trim().to_string();
}
}
let mut main_count = 0usize;
if let Ok(nodes) = document.select("main") {
main_count = nodes.count();
}
let mut ids_seen: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
let mut duplicate_ids: Vec<String> = Vec::new();
let mut idrefs: Vec<(String, String)> = Vec::new();
let mut css_link_hrefs: Vec<String> = Vec::new();
let mut signature_semantic_count = 0usize;
let mut empty_heading_count = 0usize;
let mut empty_label_count = 0usize;
let mut empty_aria_label_count = 0usize;
let mut unlabeled_region_count = 0usize;
let mut image_count = 0usize;
let mut image_missing_alt_count = 0usize;
let mut image_title_only_count = 0usize;
let mut image_semantic_conflict_count = 0usize;
let figure_alt_length_budget = 150usize;
let figure_caption_redundancy_threshold = 0.8f64;
let mut figure_informative_count = 0usize;
let mut figure_alt_over_budget_count = 0usize;
let mut figure_max_alt_len = 0usize;
let mut figure_caption_redundancy_count = 0usize;
let mut figure_max_caption_similarity = 0.0f64;
let mut figure_missing_effective_text_count = 0usize;
let mut dl_block_count = 0usize;
let mut dl_fragmentation_count = 0usize;
let mut dl_group_consistency_count = 0usize;
let mut redundant_role_native_count = 0usize;
let mut redundant_state_native_count = 0usize;
let mut label_for_targets: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
let mut form_controls: Vec<(String, bool, bool, bool, String, String, bool)> = Vec::new(); let mut tabindex_attr_count = 0usize;
let mut positive_tabindex_count = 0usize;
let mut invalid_tabindex_count = 0usize;
let mut link_count = 0usize;
let mut unnamed_link_count = 0usize;
let mut generic_link_text_count = 0usize;
let mut custom_click_handler_count = 0usize;
let mut pointer_only_click_handler_count = 0usize;
let mut script_element_count = 0usize;
let mut embedded_active_content_count = 0usize;
let mut autoplay_media_count = 0usize;
let mut blink_marquee_count = 0usize;
let mut inline_event_handler_attr_count = 0usize;
let mut meta_refresh_count = 0usize;
let mut has_html = false;
let mut has_head = false;
let mut has_body = false;
let mut part_lang_attr_count = 0usize;
let mut invalid_part_lang_attr_count = 0usize;
if let Ok(nodes) = document.select("*") {
for node in nodes {
let mut tag_name: Option<String> = None;
if let NodeData::Element(el) = node.as_node().data() {
let tag = el.name.local.as_ref().to_ascii_lowercase();
tag_name = Some(tag.clone());
match tag.as_str() {
"html" => has_html = true,
"head" => has_head = true,
"body" => has_body = true,
"script" => script_element_count += 1,
"iframe" | "embed" | "object" | "frame" => {
embedded_active_content_count += 1
}
"audio" | "video" => {}
"blink" | "marquee" => blink_marquee_count += 1,
_ => {}
}
}
let attrs = node.attributes.borrow();
inline_event_handler_attr_count += attrs
.map
.iter()
.filter(|(name, _)| {
let attr_name = name.local.as_ref().to_ascii_lowercase();
attr_name.len() > 2 && attr_name.starts_with("on")
})
.count();
if let Some(id) = attrs.get("id") {
let id = id.trim();
if !id.is_empty() {
let count = ids_seen.entry(id.to_string()).or_insert(0);
*count += 1;
if *count == 2 {
duplicate_ids.push(id.to_string());
}
}
}
for attr_name in ["aria-labelledby", "aria-describedby"] {
if let Some(val) = attrs.get(attr_name) {
for tok in val.split_whitespace() {
let tok = tok.trim();
if !tok.is_empty() {
idrefs.push((attr_name.to_string(), tok.to_string()));
}
}
}
}
if attrs.get("data-fb-a11y-signature-status").is_some() {
signature_semantic_count += 1;
}
if let Some(aria_label) = attrs.get("aria-label") {
if aria_label.trim().is_empty() {
empty_aria_label_count += 1;
}
}
if let Some(tabindex) = attrs.get("tabindex") {
let tabindex = tabindex.trim();
if !tabindex.is_empty() {
tabindex_attr_count += 1;
match tabindex.parse::<i32>() {
Ok(v) if v > 0 => positive_tabindex_count += 1,
Ok(_) => {}
Err(_) => invalid_tabindex_count += 1,
}
} else {
tabindex_attr_count += 1;
invalid_tabindex_count += 1;
}
}
if !matches!(tag_name.as_deref(), Some("html")) {
if let Some(lang_attr) = attrs.get("lang") {
let lang_attr = lang_attr.trim();
if !lang_attr.is_empty() {
part_lang_attr_count += 1;
if !Self::a11y_lang_value_is_valid(lang_attr) {
invalid_part_lang_attr_count += 1;
}
} else {
part_lang_attr_count += 1;
invalid_part_lang_attr_count += 1;
}
}
}
if matches!(tag_name.as_deref(), Some("audio" | "video"))
&& attrs.get("autoplay").is_some()
{
autoplay_media_count += 1;
}
if tag_name.as_deref() == Some("meta") {
let http_equiv_refresh = attrs
.get("http-equiv")
.map(|v| v.trim().eq_ignore_ascii_case("refresh"))
.unwrap_or(false);
if http_equiv_refresh {
meta_refresh_count += 1;
}
}
if tag_name.as_deref() == Some("label") {
}
let role = attrs
.get("role")
.map(|v| v.trim().to_ascii_lowercase())
.unwrap_or_default();
let role_tokens: std::collections::BTreeSet<&str> =
role.split_whitespace().collect();
let expected_native_role = match tag_name.as_deref() {
Some("nav") => Some("navigation"),
Some("main") => Some("main"),
Some("aside") => Some("complementary"),
Some("form") => Some("form"),
Some("table") => Some("table"),
Some("ul" | "ol") => Some("list"),
Some("li") => Some("listitem"),
Some("button") => Some("button"),
Some("img") => Some("img"),
Some("a") => {
if attrs
.get("href")
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
Some("link")
} else {
None
}
}
_ => None,
};
if let Some(native_role) = expected_native_role {
if role_tokens.contains(native_role) {
redundant_role_native_count += 1;
}
}
if attrs.get("disabled").is_some()
&& attrs
.get("aria-disabled")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
{
redundant_state_native_count += 1;
}
if attrs.get("required").is_some()
&& attrs
.get("aria-required")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
{
redundant_state_native_count += 1;
}
if attrs.get("readonly").is_some()
&& attrs
.get("aria-readonly")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
{
redundant_state_native_count += 1;
}
if tag_name.as_deref() == Some("input") {
let is_checkbox_or_radio = attrs
.get("type")
.map(|v| {
matches!(v.trim().to_ascii_lowercase().as_str(), "checkbox" | "radio")
})
.unwrap_or(false);
if is_checkbox_or_radio
&& attrs.get("checked").is_some()
&& attrs
.get("aria-checked")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
{
redundant_state_native_count += 1;
}
}
if tag_name.as_deref() == Some("option")
&& attrs.get("selected").is_some()
&& attrs
.get("aria-selected")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
{
redundant_state_native_count += 1;
}
let has_onclick = attrs.get("onclick").is_some();
let has_keyboard_handler = attrs.get("onkeydown").is_some()
|| attrs.get("onkeyup").is_some()
|| attrs.get("onkeypress").is_some();
let has_tabindex_attr = attrs.get("tabindex").is_some();
let is_native_keyboard_interactive = match tag_name.as_deref() {
Some("a") => attrs
.get("href")
.map(|v| !v.trim().is_empty())
.unwrap_or(false),
Some("button" | "select" | "textarea" | "summary") => true,
Some("input") => !attrs
.get("type")
.map(|v| v.trim().eq_ignore_ascii_case("hidden"))
.unwrap_or(false),
_ => false,
};
if has_onclick && !is_native_keyboard_interactive {
custom_click_handler_count += 1;
if !has_keyboard_handler && !has_tabindex_attr {
pointer_only_click_handler_count += 1;
}
}
if role == "region" {
let aria_label_nonempty = attrs
.get("aria-label")
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
let aria_labelledby_nonempty = attrs
.get("aria-labelledby")
.map(|v| v.split_whitespace().any(|tok| !tok.trim().is_empty()))
.unwrap_or(false);
if !aria_label_nonempty && !aria_labelledby_nonempty {
unlabeled_region_count += 1;
}
}
if matches!(tag_name.as_deref(), Some("img" | "svg")) {
image_count += 1;
let role_decorative = attrs
.get("role")
.map(|v| {
let v = v.trim().to_ascii_lowercase();
v == "presentation" || v == "none"
})
.unwrap_or(false);
let aria_hidden = attrs
.get("aria-hidden")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false);
let explicit_decorative = attrs
.get("data-fb-a11y-decorative")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false);
let aria_label = attrs.get("aria-label");
let aria_labelledby = attrs.get("aria-labelledby");
let alt_value = attrs.get("alt");
let title_value = attrs.get("title");
let has_informative_name =
aria_label.map(|v| !v.trim().is_empty()).unwrap_or(false)
|| aria_labelledby
.map(|v| v.split_whitespace().any(|tok| !tok.trim().is_empty()))
.unwrap_or(false)
|| alt_value.map(|v| !v.trim().is_empty()).unwrap_or(false);
let alt_empty = alt_value.map(|v| v.is_empty()).unwrap_or(false);
let decorative =
explicit_decorative || aria_hidden || role_decorative || alt_empty;
if decorative && has_informative_name {
image_semantic_conflict_count += 1;
} else if !decorative && !has_informative_name {
if title_value.map(|v| !v.trim().is_empty()).unwrap_or(false) {
image_title_only_count += 1;
} else {
image_missing_alt_count += 1;
}
}
}
if tag_name.as_deref() == Some("label") {
if let Some(for_id) = attrs.get("for") {
let for_id = for_id.trim();
if !for_id.is_empty() {
label_for_targets.insert(for_id.to_string());
}
}
}
if matches!(tag_name.as_deref(), Some("input" | "select" | "textarea")) {
let is_hidden_input = tag_name.as_deref() == Some("input")
&& attrs
.get("type")
.map(|v| v.trim().eq_ignore_ascii_case("hidden"))
.unwrap_or(false);
if !is_hidden_input {
let ctl_id = attrs.get("id").map(|v| v.trim()).unwrap_or("").to_string();
let aria_label_nonempty = attrs
.get("aria-label")
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
let aria_labelledby_nonempty = attrs
.get("aria-labelledby")
.map(|v| v.split_whitespace().any(|tok| !tok.trim().is_empty()))
.unwrap_or(false);
let aria_describedby = attrs
.get("aria-describedby")
.map(|v| v.to_string())
.unwrap_or_default();
let aria_errormessage = attrs
.get("aria-errormessage")
.map(|v| v.to_string())
.unwrap_or_default();
let invalid_state = attrs
.get("aria-invalid")
.map(|v| {
let t = v.trim().to_ascii_lowercase();
!t.is_empty() && !matches!(t.as_str(), "0" | "false" | "no" | "off")
})
.unwrap_or(false);
let in_label = node.as_node().ancestors().any(|anc| {
if let NodeData::Element(el) = anc.data() {
el.name.local.as_ref().eq_ignore_ascii_case("label")
} else {
false
}
});
form_controls.push((
ctl_id,
in_label,
aria_label_nonempty,
aria_labelledby_nonempty,
aria_describedby,
aria_errormessage,
invalid_state,
));
}
}
if tag_name.as_deref() == Some("a") {
let has_href = attrs
.get("href")
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
if has_href {
link_count += 1;
let aria_label_nonempty = attrs
.get("aria-label")
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
let aria_labelledby_nonempty = attrs
.get("aria-labelledby")
.map(|v| v.split_whitespace().any(|tok| !tok.trim().is_empty()))
.unwrap_or(false);
let link_text = node.as_node().text_contents();
let link_text_norm = link_text
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
let text_nonempty = !link_text_norm.trim().is_empty();
if !(aria_label_nonempty || aria_labelledby_nonempty || text_nonempty) {
unnamed_link_count += 1;
} else if text_nonempty
&& matches!(
link_text_norm.as_str(),
"click here"
| "here"
| "read more"
| "learn more"
| "more"
| "more..."
)
{
generic_link_text_count += 1;
}
}
}
if let Some(rel) = attrs.get("rel") {
let rel_l = rel.to_ascii_lowercase();
if rel_l.split_whitespace().any(|tok| tok == "stylesheet") {
if let Some(href) = attrs.get("href") {
let href = href.trim();
if !href.is_empty() {
css_link_hrefs.push(href.to_string());
}
}
}
}
drop(attrs);
if let Some(tag) = tag_name.as_deref() {
let is_empty_text = node.as_node().text_contents().trim().is_empty();
if matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6") && is_empty_text {
empty_heading_count += 1;
}
if tag == "label" && is_empty_text {
empty_label_count += 1;
}
}
}
}
let id_set: std::collections::BTreeSet<String> = ids_seen.keys().cloned().collect();
let mut missing_idrefs: Vec<(String, String)> = Vec::new();
for (attr_name, tok) in idrefs {
if !id_set.contains(&tok) {
missing_idrefs.push((attr_name, tok));
}
}
let mut unlabeled_form_control_count = 0usize;
for (ctl_id, in_label, aria_label_nonempty, aria_labelledby_nonempty, _, _, _) in
&form_controls
{
if *aria_label_nonempty || *aria_labelledby_nonempty || *in_label {
continue;
}
if !ctl_id.is_empty() && label_for_targets.contains(ctl_id) {
continue;
}
unlabeled_form_control_count += 1;
}
let mut invalid_form_control_count = 0usize;
let mut unidentified_error_form_control_count = 0usize;
for (_, _, _, _, aria_describedby, aria_errormessage, invalid_state) in &form_controls {
if !*invalid_state {
continue;
}
invalid_form_control_count += 1;
let describedby_ok = aria_describedby
.split_whitespace()
.any(|tok| !tok.trim().is_empty() && id_set.contains(tok));
let errormessage_ok = aria_errormessage
.split_whitespace()
.any(|tok| !tok.trim().is_empty() && id_set.contains(tok));
if !(describedby_ok || errormessage_ok) {
unidentified_error_form_control_count += 1;
}
}
let body_text = if let Ok(mut bodies) = document.select("body") {
if let Some(body) = bodies.next() {
body.as_node()
.text_contents()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
} else {
String::new()
}
} else {
String::new()
};
let mut tables: Vec<HtmlTableFacts> = Vec::new();
if let Ok(table_nodes) = document.select("table") {
for table in table_nodes {
let node = table.as_node();
let has_caption = node
.select("caption")
.ok()
.map(|mut it| it.next().is_some())
.unwrap_or(false);
let th_count = node.select("th").ok().map(|it| it.count()).unwrap_or(0);
let th_scope_count = node
.select("th[scope]")
.ok()
.map(|it| it.count())
.unwrap_or(0);
tables.push(HtmlTableFacts {
has_caption,
th_count,
th_scope_count,
});
}
}
if let Ok(figure_nodes) = document.select("figure") {
for figure in figure_nodes {
let figure_node = figure.as_node();
let caption_text = if let Ok(captions) = figure_node.select("figcaption") {
let caption_joined = captions
.map(|cap| cap.as_node().text_contents())
.collect::<Vec<_>>()
.join(" ");
Self::a11y_normalize_text(&caption_joined)
} else {
String::new()
};
let mut informative_image_count = 0usize;
let mut effective_name_present = false;
let mut alt_texts: Vec<String> = Vec::new();
let mut local_max_alt_len = 0usize;
if let Ok(images) = figure_node.select("img, svg") {
for image in images {
let attrs = image.attributes.borrow();
let role_decorative = attrs
.get("role")
.map(|v| {
let v = v.trim().to_ascii_lowercase();
v == "presentation" || v == "none"
})
.unwrap_or(false);
let aria_hidden = attrs
.get("aria-hidden")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false);
let explicit_decorative = attrs
.get("data-fb-a11y-decorative")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false);
let aria_label = attrs.get("aria-label");
let aria_labelledby = attrs.get("aria-labelledby");
let alt_value = attrs.get("alt");
let has_informative_name =
aria_label.map(|v| !v.trim().is_empty()).unwrap_or(false)
|| aria_labelledby
.map(|v| v.split_whitespace().any(|tok| !tok.trim().is_empty()))
.unwrap_or(false)
|| alt_value.map(|v| !v.trim().is_empty()).unwrap_or(false);
let alt_empty = alt_value.map(|v| v.is_empty()).unwrap_or(false);
let decorative =
explicit_decorative || aria_hidden || role_decorative || alt_empty;
if decorative {
continue;
}
informative_image_count += 1;
if has_informative_name {
effective_name_present = true;
}
if let Some(alt) = alt_value {
let alt_norm = Self::a11y_normalize_text(alt);
if !alt_norm.is_empty() {
local_max_alt_len = local_max_alt_len.max(alt_norm.chars().count());
alt_texts.push(alt_norm);
}
}
}
}
if informative_image_count == 0 {
continue;
}
figure_informative_count += 1;
figure_max_alt_len = figure_max_alt_len.max(local_max_alt_len);
if local_max_alt_len > figure_alt_length_budget {
figure_alt_over_budget_count += 1;
}
if !effective_name_present && caption_text.is_empty() {
figure_missing_effective_text_count += 1;
}
if !alt_texts.is_empty() && !caption_text.is_empty() {
let longest_alt = alt_texts
.iter()
.max_by_key(|txt| txt.chars().count())
.cloned()
.unwrap_or_default();
let similarity = Self::a11y_text_similarity(&longest_alt, &caption_text);
figure_max_caption_similarity = figure_max_caption_similarity.max(similarity);
if similarity >= figure_caption_redundancy_threshold {
figure_caption_redundancy_count += 1;
}
}
}
}
if let Ok(parent_nodes) = document.select("*") {
for parent in parent_nodes {
let mut run_total = 0usize;
let mut run_tiny = 0usize;
for child in parent.as_node().children() {
let is_dl = if let NodeData::Element(el) = child.data() {
el.name.local.as_ref().eq_ignore_ascii_case("dl")
} else {
false
};
if is_dl {
dl_block_count += 1;
run_total += 1;
let dt_count = child.select("dt").ok().map(|it| it.count()).unwrap_or(0);
let dd_count = child.select("dd").ok().map(|it| it.count()).unwrap_or(0);
if dt_count.min(dd_count) <= 2 {
run_tiny += 1;
}
} else if run_total >= 2 {
dl_fragmentation_count += run_total - 1;
if run_tiny == run_total {
dl_group_consistency_count += run_total - 1;
}
run_total = 0;
run_tiny = 0;
} else {
run_total = 0;
run_tiny = 0;
}
}
if run_total >= 2 {
dl_fragmentation_count += run_total - 1;
if run_tiny == run_total {
dl_group_consistency_count += run_total - 1;
}
}
}
}
A11yVerifierFacts {
html_lang,
title,
part_lang_attr_count,
invalid_part_lang_attr_count,
main_count,
duplicate_ids,
missing_idrefs,
has_html_wrapper: has_html && has_head && has_body,
has_css_link: !css_link_hrefs.is_empty(),
css_link_hrefs,
signature_semantic_count,
empty_heading_count,
empty_label_count,
empty_aria_label_count,
unlabeled_region_count,
image_count,
image_missing_alt_count,
image_title_only_count,
image_semantic_conflict_count,
figure_informative_count,
figure_alt_length_budget,
figure_alt_over_budget_count,
figure_max_alt_len,
figure_caption_redundancy_threshold,
figure_caption_redundancy_count,
figure_max_caption_similarity,
figure_missing_effective_text_count,
dl_block_count,
dl_fragmentation_count,
dl_group_consistency_count,
redundant_role_native_count,
redundant_state_native_count,
form_control_count: form_controls.len(),
unlabeled_form_control_count,
invalid_form_control_count,
unidentified_error_form_control_count,
tabindex_attr_count,
positive_tabindex_count,
invalid_tabindex_count,
link_count,
unnamed_link_count,
generic_link_text_count,
custom_click_handler_count,
pointer_only_click_handler_count,
script_element_count,
embedded_active_content_count,
autoplay_media_count,
blink_marquee_count,
inline_event_handler_attr_count,
meta_refresh_count,
tables,
body_text,
}
}
fn push_a11y_finding(
findings: &mut Vec<A11yVerifierFinding>,
rule_id: &str,
verdict: &str,
severity: &str,
stage: &str,
source: &str,
message: String,
evidence: Vec<A11yVerifierEvidence>,
) {
findings.push(A11yVerifierFinding {
rule_id: rule_id.to_string(),
applicability: "applicable".to_string(),
verification_mode: "machine".to_string(),
verdict: verdict.to_string(),
severity: severity.to_string(),
confidence: "certain".to_string(),
stage: stage.to_string(),
source: source.to_string(),
message,
evidence,
});
}
pub fn verify_accessibility_html_core(
&self,
html: &str,
profile: &str,
) -> A11yVerifierCoreReport {
let facts = self.verify_accessibility_html_facts(html);
let mut findings: Vec<A11yVerifierFinding> = Vec::new();
let observed_lang = facts.html_lang.clone().unwrap_or_default();
let expected_lang = self.document_lang().map(|value| value.to_string());
let lang_value_valid = facts
.html_lang
.as_deref()
.map(Self::a11y_lang_value_is_valid)
.unwrap_or(false);
let lang_ok = lang_value_valid
&& expected_lang
.as_deref()
.map(|expected| facts.html_lang.as_deref() == Some(expected))
.unwrap_or(true);
let lang_failure_kind = if facts.html_lang.is_none() {
"missing"
} else if !lang_value_valid {
"invalid"
} else if expected_lang
.as_deref()
.map(|expected| facts.html_lang.as_deref() != Some(expected))
.unwrap_or(false)
{
"metadata_mismatch"
} else {
"none"
};
Self::push_a11y_finding(
&mut findings,
"fb.a11y.html.lang_present_valid",
if lang_ok { "pass" } else { "fail" },
"high",
"post-emit",
"fullbleed",
if lang_ok {
"HTML lang attribute is present and valid.".to_string()
} else if lang_failure_kind == "metadata_mismatch" {
format!(
"HTML lang attribute is present and valid in the emitted DOM, but engine metadata persistence mismatched (observed DOM={}, expected metadata={}).",
observed_lang,
expected_lang.clone().unwrap_or_default()
)
} else if lang_failure_kind == "invalid" {
"HTML lang attribute is present but invalid.".to_string()
} else {
"HTML lang attribute is missing.".to_string()
},
vec![A11yVerifierEvidence {
selector: Some("html".to_string()),
values: vec![
("lang".to_string(), observed_lang),
(
"observed_lang".to_string(),
facts.html_lang.clone().unwrap_or_default(),
),
(
"expected_document_lang".to_string(),
expected_lang.unwrap_or_default(),
),
("failure_kind".to_string(), lang_failure_kind.to_string()),
],
}],
);
if facts.part_lang_attr_count == 0 {
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.language.parts_declared_valid_seed".to_string(),
applicability: "not_applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: "not_applicable".to_string(),
severity: "low".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: "No inline lang declarations on descendant elements detected; language-of-parts rule not applicable for this document."
.to_string(),
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"part_lang_attr_count".to_string(),
facts.part_lang_attr_count.to_string(),
),
(
"invalid_part_lang_attr_count".to_string(),
facts.invalid_part_lang_attr_count.to_string(),
),
],
}],
});
} else {
let part_lang_fail = facts.invalid_part_lang_attr_count > 0;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.language.parts_declared_valid_seed".to_string(),
applicability: "applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: if part_lang_fail { "fail" } else { "pass" }.to_string(),
severity: if part_lang_fail { "medium" } else { "low" }.to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if part_lang_fail {
"Invalid or empty inline language-of-parts declarations detected.".to_string()
} else {
"Inline language-of-parts declarations are syntactically valid.".to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"part_lang_attr_count".to_string(),
facts.part_lang_attr_count.to_string(),
),
(
"invalid_part_lang_attr_count".to_string(),
facts.invalid_part_lang_attr_count.to_string(),
),
],
}],
});
}
let expected_title = self.document_title().map(|value| value.to_string());
let title_present = !facts.title.trim().is_empty();
let title_ok = title_present
&& expected_title
.as_deref()
.map(|expected| facts.title == expected)
.unwrap_or(true);
let title_failure_kind = if !title_present {
"missing"
} else if expected_title
.as_deref()
.map(|expected| facts.title != expected)
.unwrap_or(false)
{
"metadata_mismatch"
} else {
"none"
};
Self::push_a11y_finding(
&mut findings,
"fb.a11y.html.title_present_nonempty",
if title_ok { "pass" } else { "fail" },
"high",
"post-emit",
"fullbleed",
if title_ok {
"Document title is present and non-empty.".to_string()
} else if title_failure_kind == "metadata_mismatch" {
format!(
"Document title is present in the emitted DOM, but engine metadata persistence mismatched (observed DOM={}, expected metadata={}).",
facts.title,
expected_title.clone().unwrap_or_default()
)
} else {
"Document title is missing or empty.".to_string()
},
vec![A11yVerifierEvidence {
selector: Some("head > title".to_string()),
values: vec![
("title".to_string(), facts.title.clone()),
("observed_title".to_string(), facts.title.clone()),
(
"expected_document_title".to_string(),
expected_title.unwrap_or_default(),
),
("failure_kind".to_string(), title_failure_kind.to_string()),
],
}],
);
let main_ok = facts.main_count == 1;
Self::push_a11y_finding(
&mut findings,
"fb.a11y.structure.single_main",
if main_ok { "pass" } else { "fail" },
"medium",
"post-emit",
"fullbleed",
if main_ok {
"Single primary content root detected.".to_string()
} else {
format!("Expected exactly one <main>; found {}.", facts.main_count)
},
vec![A11yVerifierEvidence {
selector: Some("main".to_string()),
values: vec![("count".to_string(), facts.main_count.to_string())],
}],
);
let hl_fail =
(facts.empty_heading_count + facts.empty_label_count + facts.empty_aria_label_count)
> 0;
let hl_warn = facts.unlabeled_region_count > 0;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.headings_labels.present_nonempty".to_string(),
applicability: "applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: if hl_fail {
"fail"
} else if hl_warn {
"warn"
} else {
"pass"
}
.to_string(),
severity: if hl_fail { "high" } else { "medium" }.to_string(),
confidence: if hl_fail || hl_warn { "high" } else { "medium" }.to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if hl_fail {
"Empty heading/label naming signals detected.".to_string()
} else if hl_warn {
"Headings/labels are non-empty, but some region landmarks are unlabeled."
.to_string()
} else {
"No empty headings/labels or unlabeled regions detected.".to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"empty_heading_count".to_string(),
facts.empty_heading_count.to_string(),
),
(
"empty_label_count".to_string(),
facts.empty_label_count.to_string(),
),
(
"empty_aria_label_count".to_string(),
facts.empty_aria_label_count.to_string(),
),
(
"unlabeled_region_count".to_string(),
facts.unlabeled_region_count.to_string(),
),
],
}],
});
if facts.image_count == 0 {
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.images.alt_or_decorative".to_string(),
applicability: "not_applicable".to_string(),
verification_mode: "machine".to_string(),
verdict: "not_applicable".to_string(),
severity: "low".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message:
"No img/svg elements detected; non-text-content image rule not applicable."
.to_string(),
evidence: Vec::new(),
});
} else {
let img_fail =
facts.image_missing_alt_count > 0 || facts.image_semantic_conflict_count > 0;
let img_warn = facts.image_title_only_count > 0;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.images.alt_or_decorative".to_string(),
applicability: "applicable".to_string(),
verification_mode: "machine".to_string(),
verdict: if img_fail {
"fail"
} else if img_warn {
"warn"
} else {
"pass"
}
.to_string(),
severity: if img_fail { "high" } else { "medium" }.to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if img_fail {
"Image text alternative errors detected.".to_string()
} else if img_warn {
"Some images rely on title without alt/ARIA text alternatives.".to_string()
} else {
"Image text alternatives/decorative semantics look consistent.".to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
("image_count".to_string(), facts.image_count.to_string()),
(
"image_missing_alt_count".to_string(),
facts.image_missing_alt_count.to_string(),
),
(
"image_title_only_count".to_string(),
facts.image_title_only_count.to_string(),
),
(
"image_semantic_conflict_count".to_string(),
facts.image_semantic_conflict_count.to_string(),
),
],
}],
});
}
if facts.figure_informative_count == 0 {
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.figure.alt_length_budget_seed".to_string(),
applicability: "not_applicable".to_string(),
verification_mode: "machine".to_string(),
verdict: "not_applicable".to_string(),
severity: "low".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message:
"No informative figures detected; figure alt-length budget rule not applicable."
.to_string(),
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"figure_informative_count".to_string(),
facts.figure_informative_count.to_string(),
),
(
"figure_alt_length_budget".to_string(),
facts.figure_alt_length_budget.to_string(),
),
(
"figure_alt_over_budget_count".to_string(),
facts.figure_alt_over_budget_count.to_string(),
),
(
"figure_max_alt_len".to_string(),
facts.figure_max_alt_len.to_string(),
),
],
}],
});
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.figure.caption_redundancy_seed".to_string(),
applicability: "not_applicable".to_string(),
verification_mode: "machine".to_string(),
verdict: "not_applicable".to_string(),
severity: "low".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: "No informative figures detected; figure caption-redundancy rule not applicable."
.to_string(),
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"figure_informative_count".to_string(),
facts.figure_informative_count.to_string(),
),
(
"figure_caption_redundancy_threshold".to_string(),
facts.figure_caption_redundancy_threshold.to_string(),
),
(
"figure_caption_redundancy_count".to_string(),
facts.figure_caption_redundancy_count.to_string(),
),
(
"figure_max_caption_similarity".to_string(),
format!("{:.3}", facts.figure_max_caption_similarity),
),
],
}],
});
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.figure.missing_effective_text_seed".to_string(),
applicability: "not_applicable".to_string(),
verification_mode: "machine".to_string(),
verdict: "not_applicable".to_string(),
severity: "low".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: "No informative figures detected; missing-effective-figure-text rule not applicable."
.to_string(),
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"figure_informative_count".to_string(),
facts.figure_informative_count.to_string(),
),
(
"figure_missing_effective_text_count".to_string(),
facts.figure_missing_effective_text_count.to_string(),
),
],
}],
});
} else {
let over_budget = facts.figure_alt_over_budget_count > 0;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.figure.alt_length_budget_seed".to_string(),
applicability: "applicable".to_string(),
verification_mode: "machine".to_string(),
verdict: if over_budget { "warn" } else { "pass" }.to_string(),
severity: "medium".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if over_budget {
"Figure alternative text exceeds recommended length budget.".to_string()
} else {
"Informative figure alternative text lengths are within the recommended budget."
.to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"figure_informative_count".to_string(),
facts.figure_informative_count.to_string(),
),
(
"figure_alt_length_budget".to_string(),
facts.figure_alt_length_budget.to_string(),
),
(
"figure_alt_over_budget_count".to_string(),
facts.figure_alt_over_budget_count.to_string(),
),
(
"figure_max_alt_len".to_string(),
facts.figure_max_alt_len.to_string(),
),
],
}],
});
let caption_redundant = facts.figure_caption_redundancy_count > 0;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.figure.caption_redundancy_seed".to_string(),
applicability: "applicable".to_string(),
verification_mode: "machine".to_string(),
verdict: if caption_redundant { "warn" } else { "pass" }.to_string(),
severity: "medium".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if caption_redundant {
"Figure alt and figcaption content appear near-duplicate; announce-once optimization recommended."
.to_string()
} else {
"Figure alt and figcaption content are sufficiently distinct.".to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"figure_informative_count".to_string(),
facts.figure_informative_count.to_string(),
),
(
"figure_caption_redundancy_threshold".to_string(),
facts.figure_caption_redundancy_threshold.to_string(),
),
(
"figure_caption_redundancy_count".to_string(),
facts.figure_caption_redundancy_count.to_string(),
),
(
"figure_max_caption_similarity".to_string(),
format!("{:.3}", facts.figure_max_caption_similarity),
),
],
}],
});
let missing_effective = facts.figure_missing_effective_text_count > 0;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.figure.missing_effective_text_seed".to_string(),
applicability: "applicable".to_string(),
verification_mode: "machine".to_string(),
verdict: if missing_effective { "fail" } else { "pass" }.to_string(),
severity: "high".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if missing_effective {
"Informative figure(s) missing effective text alternatives (alt/ARIA/caption)."
.to_string()
} else {
"Informative figures expose effective text alternatives (alt/ARIA/caption)."
.to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"figure_informative_count".to_string(),
facts.figure_informative_count.to_string(),
),
(
"figure_missing_effective_text_count".to_string(),
facts.figure_missing_effective_text_count.to_string(),
),
],
}],
});
}
let dl_fragmented = facts.dl_fragmentation_count > 0;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.dl.fragmentation_seed".to_string(),
applicability: "applicable".to_string(),
verification_mode: "machine".to_string(),
verdict: if dl_fragmented { "warn" } else { "pass" }.to_string(),
severity: "medium".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if dl_fragmented {
"Adjacent description-list siblings detected; consolidate into a single logical list where possible."
.to_string()
} else {
"No adjacent description-list fragmentation detected.".to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
("dl_block_count".to_string(), facts.dl_block_count.to_string()),
(
"dl_fragmentation_count".to_string(),
facts.dl_fragmentation_count.to_string(),
),
],
}],
});
let dl_inconsistent = facts.dl_group_consistency_count > 0;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.dl.group_consistency_seed".to_string(),
applicability: "applicable".to_string(),
verification_mode: "machine".to_string(),
verdict: if dl_inconsistent { "warn" } else { "pass" }.to_string(),
severity: "medium".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if dl_inconsistent {
"Repeated tiny description-list groups detected with similar structure; unify group semantics."
.to_string()
} else {
"Description-list grouping consistency looks stable.".to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
("dl_block_count".to_string(), facts.dl_block_count.to_string()),
(
"dl_group_consistency_count".to_string(),
facts.dl_group_consistency_count.to_string(),
),
],
}],
});
if facts.form_control_count == 0 {
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.forms.labels_or_instructions_present".to_string(),
applicability: "not_applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: "not_applicable".to_string(),
severity: "low".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: "No form controls detected; labels/instructions rule not applicable."
.to_string(),
evidence: Vec::new(),
});
} else {
let ctrl_fail = facts.unlabeled_form_control_count > 0;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.forms.labels_or_instructions_present".to_string(),
applicability: "applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: if ctrl_fail { "fail" } else { "pass" }.to_string(),
severity: if ctrl_fail { "high" } else { "medium" }.to_string(),
confidence: "medium".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if ctrl_fail {
"Unlabeled form controls detected.".to_string()
} else {
"Detected form controls have label/ARIA naming signals.".to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"form_control_count".to_string(),
facts.form_control_count.to_string(),
),
(
"unlabeled_form_control_count".to_string(),
facts.unlabeled_form_control_count.to_string(),
),
],
}],
});
}
if facts.invalid_form_control_count == 0 {
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.forms.error_identification_present".to_string(),
applicability: "not_applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: "not_applicable".to_string(),
severity: "low".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message:
"No invalid form controls detected; error-identification rule not applicable."
.to_string(),
evidence: Vec::new(),
});
} else {
let err_fail = facts.unidentified_error_form_control_count > 0;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.forms.error_identification_present".to_string(),
applicability: "applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: if err_fail { "fail" } else { "pass" }.to_string(),
severity: if err_fail { "high" } else { "medium" }.to_string(),
confidence: "medium".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if err_fail {
"Invalid form controls without associated error-identification text detected."
.to_string()
} else {
"Invalid form controls expose associated error-identification text signals."
.to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"invalid_form_control_count".to_string(),
facts.invalid_form_control_count.to_string(),
),
(
"unidentified_error_form_control_count".to_string(),
facts.unidentified_error_form_control_count.to_string(),
),
],
}],
});
}
let focus_order_target_count = facts.link_count + facts.form_control_count;
if focus_order_target_count == 0 {
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.focus.order_seed".to_string(),
applicability: "not_applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: "not_applicable".to_string(),
severity: "medium".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message:
"No interactive links or form controls detected; focus-order seed not applicable."
.to_string(),
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"interactive_focus_target_count".to_string(),
focus_order_target_count.to_string(),
),
("link_count".to_string(), facts.link_count.to_string()),
(
"form_control_count".to_string(),
facts.form_control_count.to_string(),
),
(
"tabindex_attr_count".to_string(),
facts.tabindex_attr_count.to_string(),
),
(
"positive_tabindex_count".to_string(),
facts.positive_tabindex_count.to_string(),
),
(
"invalid_tabindex_count".to_string(),
facts.invalid_tabindex_count.to_string(),
),
],
}],
});
} else {
let focus_order_warn =
facts.positive_tabindex_count > 0 || facts.invalid_tabindex_count > 0;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.focus.order_seed".to_string(),
applicability: "applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: if focus_order_warn { "warn" } else { "pass" }.to_string(),
severity: "medium".to_string(),
confidence: if focus_order_warn { "medium" } else { "medium" }.to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if facts.positive_tabindex_count > 0 {
"Positive tabindex values detected; focus order may diverge from DOM order and requires manual review."
.to_string()
} else if facts.invalid_tabindex_count > 0 {
"Invalid tabindex values detected; focus order behavior may be inconsistent and requires manual review."
.to_string()
} else {
"No positive/invalid tabindex focus-order override signals detected for interactive content."
.to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"interactive_focus_target_count".to_string(),
focus_order_target_count.to_string(),
),
("link_count".to_string(), facts.link_count.to_string()),
(
"form_control_count".to_string(),
facts.form_control_count.to_string(),
),
(
"tabindex_attr_count".to_string(),
facts.tabindex_attr_count.to_string(),
),
(
"positive_tabindex_count".to_string(),
facts.positive_tabindex_count.to_string(),
),
(
"invalid_tabindex_count".to_string(),
facts.invalid_tabindex_count.to_string(),
),
],
}],
});
}
if facts.link_count == 0 {
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.links.purpose_in_context".to_string(),
applicability: "not_applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: "not_applicable".to_string(),
severity: "low".to_string(),
confidence: "high".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: "No links detected; link-purpose rule not applicable.".to_string(),
evidence: Vec::new(),
});
} else {
let link_fail = facts.unnamed_link_count > 0;
let link_warn = facts.generic_link_text_count > 0;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.links.purpose_in_context".to_string(),
applicability: "applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: if link_fail {
"fail"
} else if link_warn {
"warn"
} else {
"pass"
}
.to_string(),
severity: if link_fail { "high" } else { "medium" }.to_string(),
confidence: "medium".to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if link_fail {
"Links without discernible text purpose signals detected.".to_string()
} else if link_warn {
"Generic link text detected; contextual purpose may require manual review."
.to_string()
} else {
"Detected links have discernible text purpose signals.".to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
("link_count".to_string(), facts.link_count.to_string()),
(
"unnamed_link_count".to_string(),
facts.unnamed_link_count.to_string(),
),
(
"generic_link_text_count".to_string(),
facts.generic_link_text_count.to_string(),
),
],
}],
});
}
let sensory_hits = Self::a11y_sensory_instruction_hits(&facts.body_text);
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.instructions.sensory_characteristics_seed".to_string(),
applicability: "applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: if sensory_hits.is_empty() { "pass" } else { "warn" }.to_string(),
severity: "medium".to_string(),
confidence: if sensory_hits.is_empty() { "high" } else { "medium" }.to_string(),
stage: "post-emit".to_string(),
source: "fullbleed".to_string(),
message: if sensory_hits.is_empty() {
"No obvious sensory-characteristics instruction phrases detected."
.to_string()
} else {
"Potential sensory-characteristics instruction phrases detected; manual review required."
.to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"sensory_phrase_hit_count".to_string(),
sensory_hits.len().to_string(),
),
("sensory_phrase_hits".to_string(), sensory_hits.join("|")),
],
}],
});
if facts.duplicate_ids.is_empty() {
Self::push_a11y_finding(
&mut findings,
"fb.a11y.ids.duplicate_id",
"pass",
"critical",
"post-emit",
"fullbleed",
"No duplicate IDs detected.".to_string(),
Vec::new(),
);
} else {
for dup in &facts.duplicate_ids {
Self::push_a11y_finding(
&mut findings,
"fb.a11y.ids.duplicate_id",
"fail",
"critical",
"post-emit",
"fullbleed",
format!("Duplicate id {dup:?} detected."),
vec![A11yVerifierEvidence {
selector: None,
values: vec![("id".to_string(), dup.clone())],
}],
);
}
}
if facts.missing_idrefs.is_empty() {
Self::push_a11y_finding(
&mut findings,
"fb.a11y.aria.reference_target_exists",
"pass",
"critical",
"post-emit",
"fullbleed",
"No broken ARIA ID references detected.".to_string(),
Vec::new(),
);
} else {
for (attr, target) in &facts.missing_idrefs {
Self::push_a11y_finding(
&mut findings,
"fb.a11y.aria.reference_target_exists",
"fail",
"critical",
"post-emit",
"fullbleed",
format!("{attr} references missing id {target:?}."),
vec![A11yVerifierEvidence {
selector: None,
values: vec![
("attr".to_string(), attr.clone()),
("target_id".to_string(), target.clone()),
],
}],
);
}
}
Self::push_a11y_finding(
&mut findings,
"fb.a11y.aria.redundant_role_native_seed",
if facts.redundant_role_native_count > 0 {
"warn"
} else {
"pass"
},
"medium",
"post-emit",
"fullbleed",
if facts.redundant_role_native_count > 0 {
"Explicit roles duplicate native semantics on one or more elements.".to_string()
} else {
"No obvious redundant explicit-role/native-semantic duplication detected."
.to_string()
},
vec![A11yVerifierEvidence {
selector: None,
values: vec![(
"redundant_role_native_count".to_string(),
facts.redundant_role_native_count.to_string(),
)],
}],
);
Self::push_a11y_finding(
&mut findings,
"fb.a11y.aria.redundant_state_native_seed",
if facts.redundant_state_native_count > 0 {
"warn"
} else {
"pass"
},
"medium",
"post-emit",
"fullbleed",
if facts.redundant_state_native_count > 0 {
"ARIA state/property duplicates equivalent native HTML state on one or more elements."
.to_string()
} else {
"No obvious redundant ARIA state/native-state duplication detected.".to_string()
},
vec![A11yVerifierEvidence {
selector: None,
values: vec![(
"redundant_state_native_count".to_string(),
facts.redundant_state_native_count.to_string(),
)],
}],
);
if profile.eq_ignore_ascii_case("cav") || profile.eq_ignore_ascii_case("transactional") {
let body_text_l = facts.body_text.to_ascii_lowercase();
let sig_cue_present =
body_text_l.contains("signature") || body_text_l.contains("signed");
let sig_ok = facts.signature_semantic_count > 0;
let sig_na = !sig_ok && !sig_cue_present;
Self::push_a11y_finding(
&mut findings,
"fb.a11y.signatures.text_semantics_present",
if sig_ok {
"pass"
} else if sig_na {
"not_applicable"
} else {
"fail"
},
"medium",
"post-emit",
"fullbleed",
if sig_ok {
"Signature fields include text-first semantics.".to_string()
} else if sig_na {
"No signature-bearing content cues detected; signature semantics check not applicable."
.to_string()
} else {
"No text-first signature semantics detected.".to_string()
},
vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"signature_semantic_count".to_string(),
facts.signature_semantic_count.to_string(),
),
(
"signature_cue_text_present".to_string(),
sig_cue_present.to_string(),
),
],
}],
);
}
let non_interference_signal_count = facts.script_element_count
+ facts.embedded_active_content_count
+ facts.autoplay_media_count
+ facts.blink_marquee_count
+ facts.inline_event_handler_attr_count
+ facts.meta_refresh_count;
findings.push(A11yVerifierFinding {
rule_id: "fb.a11y.claim.non_interference_seed".to_string(),
applicability: "applicable".to_string(),
verification_mode: "hybrid".to_string(),
verdict: if non_interference_signal_count == 0 {
"pass"
} else {
"warn"
}
.to_string(),
severity: "medium".to_string(),
confidence: if non_interference_signal_count == 0 {
"high"
} else {
"medium"
}
.to_string(),
stage: "adapter".to_string(),
source: "adapter".to_string(),
message: if non_interference_signal_count == 0 {
"No obvious active-content non-interference risk signals detected in emitted HTML."
.to_string()
} else {
"Potential non-interference risk signals detected; manual review required."
.to_string()
},
evidence: vec![A11yVerifierEvidence {
selector: None,
values: vec![
(
"script_element_count".to_string(),
facts.script_element_count.to_string(),
),
(
"embedded_active_content_count".to_string(),
facts.embedded_active_content_count.to_string(),
),
(
"autoplay_media_count".to_string(),
facts.autoplay_media_count.to_string(),
),
(
"blink_marquee_count".to_string(),
facts.blink_marquee_count.to_string(),
),
(
"inline_event_handler_attr_count".to_string(),
facts.inline_event_handler_attr_count.to_string(),
),
(
"meta_refresh_count".to_string(),
facts.meta_refresh_count.to_string(),
),
],
}],
});
A11yVerifierCoreReport {
profile: profile.to_string(),
findings,
facts,
}
}
fn pmr_push_audit(
audits: &mut Vec<PmrCoreAudit>,
audit_id: &str,
category: &str,
weight: f64,
class_name: &str,
verification_mode: &str,
severity: &str,
stage: &str,
source: &str,
verdict: &str,
scored: bool,
message: String,
evidence: Vec<PmrCoreEvidence>,
fix_hint: Option<String>,
) {
let score = if scored {
match verdict {
"pass" => Some(1.0),
"warn" => Some(0.5),
"fail" => Some(0.0),
_ => None,
}
} else {
None
};
audits.push(PmrCoreAudit {
audit_id: audit_id.to_string(),
category: category.to_string(),
weight,
class_name: class_name.to_string(),
verification_mode: verification_mode.to_string(),
severity: severity.to_string(),
stage: stage.to_string(),
source: source.to_string(),
verdict: verdict.to_string(),
scored,
score,
message,
fix_hint,
evidence,
});
}
fn pmr_push_contract_audit(
audits: &mut Vec<PmrCoreAudit>,
audit_id: &str,
verification_mode: &str,
verdict: &str,
scored: bool,
message: String,
evidence: Vec<PmrCoreEvidence>,
fix_hint: Option<String>,
) {
let spec = audit_contract::pmr_audit_def(audit_id)
.unwrap_or_else(|| panic!("missing PMR audit contract definition for {audit_id}"));
Self::pmr_push_audit(
audits,
spec.id,
spec.category,
spec.weight,
spec.class_name,
verification_mode,
spec.severity,
spec.stage,
"fullbleed",
verdict,
scored,
message,
evidence,
fix_hint,
);
}
fn pmr_clamp(v: f64, lo: f64, hi: f64) -> f64 {
if v < lo {
lo
} else if v > hi {
hi
} else {
v
}
}
fn pmr_band(score: f64) -> &'static str {
if score >= 95.0 {
"excellent"
} else if score >= 85.0 {
"good"
} else if score >= 70.0 {
"watch"
} else {
"poor"
}
}
fn pmr_note_hits(body_text: &str) -> Vec<String> {
let lowered = body_text.to_ascii_lowercase();
[
"review queue",
"parity report",
"source analysis",
"component validation",
"a11y validation",
"transcription sidecar",
"debug log",
"remediation note",
]
.iter()
.filter_map(|needle| {
if lowered.contains(needle) {
Some((*needle).to_string())
} else {
None
}
})
.collect()
}
fn a11y_sensory_instruction_hits(body_text: &str) -> Vec<String> {
let lowered = body_text.to_ascii_lowercase();
[
"see above",
"see below",
"shown above",
"shown below",
"on the left",
"on the right",
"left side",
"right side",
"top of the page",
"bottom of the page",
"red button",
"green button",
"blue button",
]
.iter()
.filter_map(|needle| {
if lowered.contains(needle) {
Some((*needle).to_string())
} else {
None
}
})
.collect()
}
fn a11y_normalize_text(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn a11y_text_tokens(value: &str) -> std::collections::BTreeSet<String> {
let mut tokens: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut current = String::new();
for ch in value.chars().flat_map(|c| c.to_lowercase()) {
if ch.is_ascii_alphanumeric() {
current.push(ch);
} else if !current.is_empty() {
tokens.insert(std::mem::take(&mut current));
}
}
if !current.is_empty() {
tokens.insert(current);
}
tokens
}
fn a11y_text_similarity(a: &str, b: &str) -> f64 {
let ta = Self::a11y_text_tokens(a);
let tb = Self::a11y_text_tokens(b);
if ta.is_empty() || tb.is_empty() {
return 0.0;
}
let intersection = ta.intersection(&tb).count() as f64;
let union = ta.union(&tb).count() as f64;
if union <= 0.0 {
0.0
} else {
intersection / union
}
}
fn a11y_lang_value_is_valid(lang: &str) -> bool {
let lang = lang.trim();
!lang.is_empty()
&& !lang.starts_with('-')
&& !lang.ends_with('-')
&& lang
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
}
fn pmr_gate(audits: &[PmrCoreAudit], profile: &str, mode: &str) -> PmrCoreGate {
let mode_norm = {
let m = mode.trim().to_ascii_lowercase();
if m.is_empty() { "error".to_string() } else { m }
};
let mut ec = 0usize;
let mut wc = 0usize;
let mut failed: Vec<String> = Vec::new();
for audit in audits {
let verdict = audit.verdict.as_str();
if verdict != "fail" && verdict != "warn" {
continue;
}
let level = audit_contract::pmr_effective_gate_level(profile, &audit.audit_id);
if mode_norm == "off" || level == "off" {
continue;
}
if mode_norm == "warn" {
wc += 1;
continue;
}
if verdict == "warn" {
wc += 1;
} else if level == "error" {
ec += 1;
failed.push(audit.audit_id.clone());
} else {
wc += 1;
}
}
PmrCoreGate {
ok: ec == 0,
mode: mode_norm,
error_count: ec,
warn_count: wc,
failed_audit_ids: failed,
}
}
fn lang_is_valid(lang: &str) -> bool {
!lang.trim().is_empty()
&& lang
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
&& !lang.starts_with('-')
&& !lang.ends_with('-')
}
pub fn verify_paged_media_rank_html_core(
&self,
html: &str,
profile: &str,
mode: &str,
ctx: &PmrCoreContext,
) -> PmrCoreReport {
let facts = self.verify_accessibility_html_facts(html);
let mut audits: Vec<PmrCoreAudit> = Vec::new();
let observed_lang = facts.html_lang.clone().unwrap_or_default();
let expected_lang = self.document_lang().map(|value| value.to_string());
let lang_value_valid = facts
.html_lang
.as_deref()
.map(Self::lang_is_valid)
.unwrap_or(false);
let lang_pass = lang_value_valid
&& expected_lang
.as_deref()
.map(|expected| facts.html_lang.as_deref() == Some(expected))
.unwrap_or(true);
let lang_failure_kind = if facts.html_lang.is_none() {
"missing"
} else if !lang_value_valid {
"invalid"
} else if expected_lang
.as_deref()
.map(|expected| facts.html_lang.as_deref() != Some(expected))
.unwrap_or(false)
{
"metadata_mismatch"
} else {
"none"
};
Self::pmr_push_contract_audit(
&mut audits,
"pmr.doc.lang_present_valid",
"machine",
if lang_pass { "pass" } else { "fail" },
true,
if lang_pass {
"HTML lang is present and valid.".to_string()
} else if lang_failure_kind == "metadata_mismatch" {
format!(
"HTML lang is present and valid in the emitted DOM, but engine metadata persistence mismatched (observed DOM={}, expected metadata={}).",
observed_lang,
expected_lang.clone().unwrap_or_default()
)
} else if lang_failure_kind == "invalid" {
"HTML lang attribute is present but invalid.".to_string()
} else {
"HTML lang attribute is missing.".to_string()
},
vec![PmrCoreEvidence {
selector: Some("html".to_string()),
diagnostic_ref: None,
values: vec![
("lang".to_string(), observed_lang),
(
"observed_lang".to_string(),
facts.html_lang.clone().unwrap_or_default(),
),
(
"expected_document_lang".to_string(),
expected_lang.unwrap_or_default(),
),
("failure_kind".to_string(), lang_failure_kind.to_string()),
],
}],
None,
);
let expected_title = self.document_title().map(|value| value.to_string());
let title_present = !facts.title.trim().is_empty();
let title_pass = title_present
&& expected_title
.as_deref()
.map(|expected| facts.title == expected)
.unwrap_or(true);
let title_failure_kind = if !title_present {
"missing"
} else if expected_title
.as_deref()
.map(|expected| facts.title != expected)
.unwrap_or(false)
{
"metadata_mismatch"
} else {
"none"
};
Self::pmr_push_contract_audit(
&mut audits,
"pmr.doc.title_present_nonempty",
"machine",
if title_pass { "pass" } else { "fail" },
true,
if title_pass {
"Document title is present and non-empty.".to_string()
} else if title_failure_kind == "metadata_mismatch" {
format!(
"Document title is present in the emitted DOM, but engine metadata persistence mismatched (observed DOM={}, expected metadata={}).",
facts.title,
expected_title.clone().unwrap_or_default()
)
} else {
"Document title is missing or empty.".to_string()
},
vec![PmrCoreEvidence {
selector: Some("head > title".to_string()),
diagnostic_ref: None,
values: vec![
("title".to_string(), facts.title.clone()),
("observed_title".to_string(), facts.title.clone()),
(
"expected_document_title".to_string(),
expected_title.unwrap_or_default(),
),
("failure_kind".to_string(), title_failure_kind.to_string()),
],
}],
None,
);
if self.document_lang().is_none() && self.document_title().is_none() {
Self::pmr_push_contract_audit(
&mut audits,
"pmr.doc.metadata_engine_persistence",
"manual",
"manual_needed",
false,
"Expected metadata not supplied; cannot verify engine persistence.".to_string(),
Vec::new(),
None,
);
} else {
let ok = lang_pass && title_pass;
Self::pmr_push_contract_audit(
&mut audits,
"pmr.doc.metadata_engine_persistence",
"machine",
if ok { "pass" } else { "fail" },
true,
if ok {
"Engine metadata persisted into emitted HTML.".to_string()
} else {
"Engine metadata persistence check failed.".to_string()
},
Vec::new(),
None,
);
}
let pagination_summary = ctx.pagination_summary.as_ref();
let pagination_overflow =
pagination_summary.and_then(|summary| summary.overflow_event_count);
let overflow = pagination_overflow.or(ctx.overflow_count).unwrap_or(0);
Self::pmr_push_contract_audit(
&mut audits,
"pmr.layout.overflow_none",
"machine",
if overflow == 0 { "pass" } else { "fail" },
true,
if overflow == 0 {
"No overflow placements detected.".to_string()
} else {
format!("Overflow placements detected ({overflow}).")
},
vec![PmrCoreEvidence {
selector: None,
diagnostic_ref: Some(
if pagination_overflow.is_some() {
"pagination_trace_summary.overflow_event_count"
} else {
"component_validation.overflow_count"
}
.to_string(),
),
values: {
let mut values = vec![("overflow_count".to_string(), overflow.to_string())];
if let Some(count) = pagination_overflow {
values.push((
"pagination_overflow_event_count".to_string(),
count.to_string(),
));
}
if let Some(count) = ctx.overflow_count {
values.push((
"component_validation_overflow_count".to_string(),
count.to_string(),
));
}
values
},
}],
None,
);
let known_loss = ctx.known_loss_count.unwrap_or(0);
Self::pmr_push_contract_audit(
&mut audits,
"pmr.layout.known_loss_none_critical",
"machine",
if known_loss == 0 { "pass" } else { "fail" },
true,
if known_loss == 0 {
"No critical known-loss events detected.".to_string()
} else {
format!("Known-loss events detected ({known_loss}).")
},
vec![PmrCoreEvidence {
selector: None,
diagnostic_ref: Some("component_validation.known_loss_count".to_string()),
values: vec![("known_loss_count".to_string(), known_loss.to_string())],
}],
None,
);
let render_page_count = pagination_summary
.and_then(|summary| summary.page_count)
.or(ctx.render_page_count);
match (ctx.source_page_count, render_page_count) {
(Some(src), Some(rnd)) => {
let ok = src == rnd;
Self::pmr_push_contract_audit(
&mut audits,
"pmr.layout.page_count_target",
"machine",
if ok { "pass" } else { "fail" },
true,
if ok {
"Page-count target satisfied.".to_string()
} else {
format!("Page-count parity mismatch (source={src}, render={rnd}).")
},
vec![PmrCoreEvidence {
selector: None,
diagnostic_ref: pagination_summary
.and_then(|summary| summary.page_count)
.map(|_| "pagination_trace_summary.page_count".to_string()),
values: {
let mut values = vec![
("source_page_count".to_string(), src.to_string()),
("render_page_count".to_string(), rnd.to_string()),
];
if let Some(count) =
pagination_summary.and_then(|summary| summary.page_count)
{
values.push((
"pagination_trace_page_count".to_string(),
count.to_string(),
));
}
if let Some(count) = ctx.render_page_count {
values.push((
"runtime_render_page_count".to_string(),
count.to_string(),
));
}
values
},
}],
None,
);
}
_ => {
Self::pmr_push_contract_audit(
&mut audits,
"pmr.layout.page_count_target",
"manual",
"manual_needed",
false,
"Page-count target could not be evaluated.".to_string(),
Vec::new(),
None,
);
}
}
let ids_ok = facts.duplicate_ids.is_empty() && facts.missing_idrefs.is_empty();
Self::pmr_push_contract_audit(
&mut audits,
"pmr.forms.id_ref_integrity",
"machine",
if ids_ok { "pass" } else { "fail" },
true,
if ids_ok {
"ID and IDREF integrity checks passed.".to_string()
} else {
"Duplicate IDs or missing IDREF targets detected.".to_string()
},
vec![PmrCoreEvidence {
selector: None,
diagnostic_ref: None,
values: vec![
(
"duplicate_id_count".to_string(),
facts.duplicate_ids.len().to_string(),
),
(
"missing_idref_count".to_string(),
facts.missing_idrefs.len().to_string(),
),
],
}],
None,
);
if facts.tables.is_empty() {
Self::pmr_push_contract_audit(
&mut audits,
"pmr.tables.semantic_table_headers",
"machine",
"not_applicable",
false,
"No table elements detected.".to_string(),
Vec::new(),
None,
);
} else {
let mut ok = true;
let mut evidence = Vec::new();
for (idx, tbl) in facts.tables.iter().enumerate() {
if tbl.th_count > 0 {
let this_ok = tbl.has_caption || tbl.th_scope_count > 0;
ok = ok && this_ok;
evidence.push(PmrCoreEvidence {
selector: None,
diagnostic_ref: None,
values: vec![
("table_index".to_string(), idx.to_string()),
("has_caption".to_string(), tbl.has_caption.to_string()),
("th_count".to_string(), tbl.th_count.to_string()),
("th_scope_count".to_string(), tbl.th_scope_count.to_string()),
],
});
}
}
if evidence.is_empty() {
evidence.push(PmrCoreEvidence {
selector: None,
diagnostic_ref: None,
values: vec![("table_count".to_string(), facts.tables.len().to_string())],
});
}
Self::pmr_push_contract_audit(
&mut audits,
"pmr.tables.semantic_table_headers",
"machine",
if ok { "pass" } else { "fail" },
true,
if ok {
"Semantic table header checks passed.".to_string()
} else {
"Semantic table header checks failed.".to_string()
},
evidence,
None,
);
}
let profile_l = profile.to_ascii_lowercase();
if profile_l == "cav" || profile_l == "transactional" {
let body_text_l = facts.body_text.to_ascii_lowercase();
let sig_cue_present =
body_text_l.contains("signature") || body_text_l.contains("signed");
let sig_ok = facts.signature_semantic_count > 0;
let sig_na = !sig_ok && !sig_cue_present;
Self::pmr_push_contract_audit(
&mut audits,
"pmr.signatures.text_semantics_present",
"machine",
if sig_ok {
"pass"
} else if sig_na {
"not_applicable"
} else {
"fail"
},
!sig_na,
if sig_ok {
"Text signature semantics detected.".to_string()
} else if sig_na {
"No signature-bearing content cues detected; signature semantics check not applicable."
.to_string()
} else {
"No text signature semantics detected.".to_string()
},
vec![PmrCoreEvidence {
selector: None,
diagnostic_ref: None,
values: vec![
(
"signature_semantic_count".to_string(),
facts.signature_semantic_count.to_string(),
),
(
"signature_cue_text_present".to_string(),
sig_cue_present.to_string(),
),
],
}],
None,
);
} else {
Self::pmr_push_contract_audit(
&mut audits,
"pmr.signatures.text_semantics_present",
"machine",
"not_applicable",
false,
"Not applicable for this profile.".to_string(),
Vec::new(),
None,
);
}
if profile_l == "cav" {
let hits = Self::pmr_note_hits(&facts.body_text);
let mut ev = Vec::new();
if !hits.is_empty() {
ev.push(PmrCoreEvidence {
selector: None,
diagnostic_ref: None,
values: vec![("hits".to_string(), hits.join(", "))],
});
}
Self::pmr_push_contract_audit(
&mut audits,
"pmr.cav.document_only_content",
"machine",
if hits.is_empty() { "pass" } else { "fail" },
true,
if hits.is_empty() {
"CAV deliverable body contains document-only content.".to_string()
} else {
"Potential remediation/provenance note leakage detected in CAV deliverable body."
.to_string()
},
ev,
None,
);
} else {
Self::pmr_push_contract_audit(
&mut audits,
"pmr.cav.document_only_content",
"machine",
"not_applicable",
false,
"Not a CAV profile.".to_string(),
Vec::new(),
None,
);
}
let html_ok = ctx
.html_artifact_bytes
.map(|n| n > 0)
.unwrap_or(!html.is_empty());
Self::pmr_push_contract_audit(
&mut audits,
"pmr.artifacts.html_emitted",
"machine",
if html_ok { "pass" } else { "fail" },
true,
if html_ok {
"HTML artifact emitted.".to_string()
} else {
"HTML artifact missing or empty.".to_string()
},
Vec::new(),
None,
);
let css_ok = ctx.css_artifact_bytes.map(|n| n > 0).unwrap_or(true);
Self::pmr_push_contract_audit(
&mut audits,
"pmr.artifacts.css_emitted",
"machine",
if css_ok { "pass" } else { "fail" },
true,
if css_ok {
"CSS artifact emitted.".to_string()
} else {
"CSS artifact missing or empty.".to_string()
},
Vec::new(),
None,
);
Self::pmr_push_contract_audit(
&mut audits,
"pmr.artifacts.linked_css_reference",
"machine",
if facts.has_css_link { "pass" } else { "warn" },
false,
if facts.has_css_link {
"HTML artifact includes linked CSS reference.".to_string()
} else {
"HTML artifact does not include linked CSS reference (separate artifact mode)."
.to_string()
},
vec![PmrCoreEvidence {
selector: Some("link[rel~=stylesheet]".to_string()),
diagnostic_ref: None,
values: vec![("hrefs".to_string(), facts.css_link_hrefs.join(", "))],
}],
if facts.has_css_link {
None
} else {
Some(
"Enable CSS link injection packaging mode for standalone HTML artifacts."
.to_string(),
)
},
);
let review_queue_items = ctx.review_queue_items.unwrap_or(0).max(0) as usize;
let mut manual_debt_items = Vec::new();
if review_queue_items > 0 {
manual_debt_items.push(PmrCoreManualDebtItem {
id: "manual.transcription_quality.review_queue".to_string(),
reason: format!(
"{review_queue_items} review-queue item(s) require human verification."
),
severity: "medium".to_string(),
category: None,
});
}
let mut categories = Vec::new();
for cat_def in audit_contract::pmr_category_defs_v1() {
let cid = cat_def.id;
let name = cat_def.name;
let weight = cat_def.weight;
let subset: Vec<&PmrCoreAudit> = audits.iter().filter(|a| a.category == cid).collect();
let scored: Vec<(f64, f64)> = subset
.iter()
.filter_map(|a| {
if a.scored {
a.score.map(|s| (s, a.weight))
} else {
None
}
})
.collect();
let denom = scored.iter().map(|(_, w)| *w).sum::<f64>();
let cat_score = if scored.is_empty() {
100.0
} else {
let d = if denom == 0.0 { 1.0 } else { denom };
100.0 * (scored.iter().map(|(s, w)| s * w).sum::<f64>() / d)
};
let warn_n = subset.iter().filter(|a| a.verdict == "warn").count();
let fail_n = subset.iter().filter(|a| a.verdict == "fail").count();
let manual_n = subset
.iter()
.filter(|a| a.verdict == "manual_needed")
.count();
let conf = Self::pmr_clamp(
100.0 - (10.0 * manual_n as f64) - (3.0 * warn_n as f64) - (5.0 * fail_n as f64),
0.0,
100.0,
);
categories.push(PmrCoreCategory {
id: cid.to_string(),
name: name.to_string(),
weight,
score: ((cat_score * 100.0).round()) / 100.0,
confidence: ((conf * 100.0).round()) / 100.0,
audit_count: subset.len(),
fail_count: fail_n,
warn_count: warn_n,
});
}
let cat_weight_sum = categories.iter().map(|c| c.weight).sum::<f64>();
let cat_weight_denom = if cat_weight_sum == 0.0 {
1.0
} else {
cat_weight_sum
};
let score = categories.iter().map(|c| c.score * c.weight).sum::<f64>() / cat_weight_denom;
let mut confidence = categories
.iter()
.map(|c| c.confidence * c.weight)
.sum::<f64>()
/ cat_weight_denom;
if review_queue_items > 0 {
confidence = Self::pmr_clamp(
confidence - (3.0 * review_queue_items as f64).min(25.0),
0.0,
100.0,
);
}
let gate = Self::pmr_gate(&audits, profile, mode);
let coverage = PmrCoreCoverage {
evaluated_audit_count: audits.len(),
applicable_audit_count: audits
.iter()
.filter(|a| a.verdict != "not_applicable")
.count(),
scored_audit_count: audits.iter().filter(|a| a.scored).count(),
manual_needed_count: audits
.iter()
.filter(|a| a.verdict == "manual_needed")
.count(),
not_evaluated_audit_count: 0,
};
PmrCoreReport {
profile: profile.to_string(),
mode: gate.mode.clone(),
audits,
categories,
manual_debt_item_count: review_queue_items,
manual_debt_high_risk_count: 0,
manual_debt_items,
coverage,
rank: PmrCoreRank {
score: ((score * 100.0).round()) / 100.0,
confidence: ((confidence * 100.0).round()) / 100.0,
band: Self::pmr_band(score).to_string(),
raw_score: ((score * 100.0).round()) / 100.0,
},
gate,
facts,
}
}
fn layout_pass_limit(&self) -> usize {
match self.layout_strategy {
LayoutStrategy::Eager => 1,
LayoutStrategy::Lazy => self.lazy_max_passes.max(1),
}
}
fn emit_layout_strategy_diagnostics(
&self,
doc_id: usize,
pass_limit: usize,
passes: usize,
converged: bool,
budget_hit: bool,
elapsed_ms: f64,
) {
if let Some(logger) = self.debug.as_deref() {
let json = format!(
"{{\"type\":\"jit.layout_strategy\",\"doc_id\":{},\"strategy\":\"{}\",\"pass_limit\":{},\"passes\":{},\"converged\":{},\"budget_ms\":{:.3},\"elapsed_ms\":{:.3},\"budget_hit\":{}}}",
doc_id,
layout_strategy_str(self.layout_strategy),
pass_limit,
passes,
if converged { "true" } else { "false" },
self.lazy_budget_ms,
elapsed_ms,
if budget_hit { "true" } else { "false" }
);
logger.log_json(&json);
logger.increment("jit.layout.strategy", 1);
if self.layout_strategy == LayoutStrategy::Lazy && !converged {
logger.increment("jit.known_loss.lazy_layout_no_convergence", 1);
}
}
if let Some(perf) = self.perf.as_deref() {
perf.log_span_ms("layout.strategy", Some(doc_id), elapsed_ms);
perf.log_counts(
"layout.strategy",
Some(doc_id),
&[
("passes", passes as u64),
("pass_limit", pass_limit as u64),
("converged", if converged { 1 } else { 0 }),
("budget_hit", if budget_hit { 1 } else { 0 }),
],
);
}
}
fn build_document_with_layout_strategy(
&self,
doc_id: usize,
html: &str,
page_templates: &[PageTemplate],
resolver: &style::StyleResolver,
report: Option<&mut GlyphCoverageReport>,
) -> Result<LayoutBuildResult, FullBleedError> {
let lazy = self.layout_strategy == LayoutStrategy::Lazy;
let pass_limit = self.layout_pass_limit();
let started = std::time::Instant::now();
let mut story_ms = 0.0;
let mut layout_ms = 0.0;
let mut passes = 0usize;
let mut converged = false;
let mut budget_hit = false;
let mut previous_signature: Option<u64> = None;
let mut built: Option<Document> = None;
let mut report = report;
let collect_report = report.is_some();
let mut final_report: Option<GlyphCoverageReport> = None;
let page_templates =
if let Some((color, alpha)) = html::document_canvas_background(html, resolver) {
page_templates
.iter()
.cloned()
.map(|template| {
let Some(frame) = template.primary_frame_rect() else {
return template;
};
template.append_on_page(move |canvas, _| {
canvas.save_state();
canvas.set_fill_color(color);
if alpha < 1.0 {
canvas.set_opacity(alpha, alpha);
}
canvas.draw_rect(frame.x, frame.y, frame.width, frame.height);
canvas.restore_state();
})
})
.collect::<Vec<_>>()
} else {
page_templates.to_vec()
};
for pass in 0..pass_limit {
if lazy && pass > 0 && started.elapsed().as_secs_f64() * 1000.0 >= self.lazy_budget_ms {
budget_hit = true;
break;
}
let mut pass_report = GlyphCoverageReport::default();
let mut pass_report_ref = if collect_report {
Some(&mut pass_report)
} else {
None
};
passes += 1;
let t_story = std::time::Instant::now();
let story = html::html_to_story_with_resolver_and_fonts_and_report(
html,
resolver,
Some(self.font_registry.clone()),
Some(self.asset_bundle.clone()),
pass_report_ref.as_deref_mut(),
self.svg_form_xobjects,
self.svg_raster_fallback,
self.perf.as_deref(),
Some(doc_id),
);
story_ms += t_story.elapsed().as_secs_f64() * 1000.0;
let mut doc = DocTemplate::new(page_templates.clone());
if let Some(logger) = self.debug.clone() {
doc = doc.with_debug(logger, Some(doc_id));
}
for flowable in story {
doc.add_flowable(flowable);
}
let t_layout = std::time::Instant::now();
let _perf_guard = flowable::set_perf_context(self.perf.clone(), Some(doc_id));
let mut next_built = doc.build()?;
apply_html_page_shrink_to_fit(&mut next_built);
layout_ms += t_layout.elapsed().as_secs_f64() * 1000.0;
let signature = document_layout_signature(&next_built);
converged = !lazy || previous_signature.is_some_and(|last| last == signature);
previous_signature = Some(signature);
built = Some(next_built);
if collect_report {
final_report = Some(pass_report);
}
if converged {
break;
}
}
self.emit_layout_strategy_diagnostics(
doc_id,
pass_limit,
passes,
converged,
budget_hit,
started.elapsed().as_secs_f64() * 1000.0,
);
if let Some(report) = report.as_deref_mut() {
if let Some(pass_report) = final_report {
*report = pass_report;
}
}
let Some(document) = built else {
return Err(FullBleedError::InvalidConfiguration(
"layout pass budget prevented any layout pass".to_string(),
));
};
Ok(LayoutBuildResult {
document,
story_ms,
layout_ms,
})
}
fn resolve_page_templates_for_css(
&self,
merged_css: &str,
doc_id: Option<usize>,
) -> Vec<PageTemplate> {
let mut page_size = self.default_page_size;
let mut base_margins = self.default_margins;
let mut page_margins = self.page_margins.clone();
let mut page_styles = style::extract_css_page_styles(
merged_css,
self.debug.as_deref(),
Some(self.default_page_size),
);
let page_setup = page_styles.base;
if let Some(css_size) = page_setup.size {
if self.page_size_explicit {
if let Some(logger) = self.debug.as_deref() {
let doc_id = doc_id
.map(|id| id.to_string())
.unwrap_or_else(|| "null".to_string());
let json = format!(
"{{\"type\":\"jit.known_loss\",\"doc_id\":{},\"code\":\"PAGE_SIZE_OVERRIDDEN\",\"runtime\":{{\"w\":{:.3},\"h\":{:.3}}},\"css\":{{\"w\":{:.3},\"h\":{:.3}}}}}",
doc_id,
self.default_page_size.width.to_f32(),
self.default_page_size.height.to_f32(),
css_size.width.to_f32(),
css_size.height.to_f32()
);
logger.log_json(&json);
logger.increment("jit.known_loss.page_size_overridden", 1);
}
} else {
page_size = css_size;
}
}
if let Some(css_margins) = page_setup.resolve_margins(base_margins) {
if self.margins_explicit {
if let Some(logger) = self.debug.as_deref() {
logger.increment("jit.page_margin.css_overridden", 1);
}
} else {
base_margins = css_margins;
page_margins.clear();
}
}
if self.margins_explicit {
for setup in [
&mut page_styles.base,
&mut page_styles.first,
&mut page_styles.left,
&mut page_styles.right,
] {
setup.margin_top = None;
setup.margin_right = None;
setup.margin_bottom = None;
setup.margin_left = None;
}
}
if let Some(logger) = self.debug.as_deref() {
let doc_id = doc_id
.map(|id| id.to_string())
.unwrap_or_else(|| "null".to_string());
let json = format!(
"{{\"type\":\"jit.page_setup\",\"doc_id\":{},\"page_size\":{{\"w\":{:.3},\"h\":{:.3}}},\"margins\":{{\"top\":{:.3},\"right\":{:.3},\"bottom\":{:.3},\"left\":{:.3}}},\"css\":{{\"size_present\":{},\"margin_present\":{}}}}}",
doc_id,
page_size.width.to_f32(),
page_size.height.to_f32(),
base_margins.top.to_f32(),
base_margins.right.to_f32(),
base_margins.bottom.to_f32(),
base_margins.left.to_f32(),
if page_setup.size.is_some() {
"true"
} else {
"false"
},
if page_setup.has_margin_override() {
"true"
} else {
"false"
}
);
logger.log_json(&json);
}
build_page_templates(page_size, base_margins, &page_margins, page_styles)
}
fn build_render_context(&self, css: &str, doc_id: Option<usize>) -> RenderContext {
let t_css = std::time::Instant::now();
let cached_context = self
.render_context_cache
.lock()
.ok()
.and_then(|cache| cache.get(css));
if let Some(context) = cached_context {
let lookup_ms = t_css.elapsed().as_secs_f64() * 1000.0;
if let Some(logger) = self.debug.as_deref() {
let doc_id = doc_id
.map(|id| id.to_string())
.unwrap_or_else(|| "null".to_string());
logger.log_json(&format!(
"{{\"type\":\"jit.css\",\"doc_id\":{},\"css_ms\":{:.3},\"bytes\":{},\"cache_hit\":true}}",
doc_id,
lookup_ms,
css.len()
));
}
if let Some(logger) = self.perf.as_deref() {
logger.log_span_ms("css.cache_lookup", doc_id, lookup_ms);
logger.log_counts("css.cache", doc_id, &[("hits", 1), ("misses", 0)]);
}
return context;
}
let merged_css = self.merge_css(css);
let page_templates = self.resolve_page_templates_for_css(&merged_css, doc_id);
let page_size = page_templates.get(0).map(|t| t.page_size).unwrap_or(Size {
width: Pt::ZERO,
height: Pt::ZERO,
});
let resolver = Arc::new(style::StyleResolver::new_with_debug_and_viewport(
&merged_css,
self.debug.clone(),
Some(page_size),
));
if let Some(logger) = self.debug.as_deref() {
let css_ms = t_css.elapsed().as_secs_f64() * 1000.0;
let doc_id = doc_id
.map(|id| id.to_string())
.unwrap_or_else(|| "null".to_string());
let json = format!(
"{{\"type\":\"jit.css\",\"doc_id\":{},\"css_ms\":{:.3},\"bytes\":{}}}",
doc_id,
css_ms,
merged_css.len()
);
logger.log_json(&json);
}
if let Some(logger) = self.perf.as_deref() {
let css_ms = t_css.elapsed().as_secs_f64() * 1000.0;
logger.log_span_ms("css.parse", doc_id, css_ms);
logger.log_counts("css.parse", doc_id, &[("bytes", merged_css.len() as u64)]);
logger.log_counts("css.cache", doc_id, &[("hits", 0), ("misses", 1)]);
}
let context = RenderContext {
resolver,
page_templates: page_templates.into(),
};
if let Ok(mut cache) = self.render_context_cache.lock() {
cache.insert(css.to_string(), context.clone());
}
context
}
fn merge_overlay_commands(base: &mut Document, overlay: &Document) {
if base.pages.len() != overlay.pages.len() {
return;
}
for (base_page, overlay_page) in base.pages.iter_mut().zip(overlay.pages.iter()) {
if !overlay_page.commands.is_empty() {
base_page.commands.extend(overlay_page.commands.clone());
}
}
}
fn build_overlay_documents(
&self,
base: &Document,
resolver: &style::StyleResolver,
page_data: Option<&PageDataContext>,
report: Option<&mut GlyphCoverageReport>,
) -> (Option<Document>, Option<Document>) {
let mut report = report;
let mut overlay = Document {
page_size: base.page_size,
pages: base
.pages
.iter()
.map(|_| Page {
commands: Vec::new(),
})
.collect(),
};
let mut has_overlay = false;
if let Some(spec) = &self.watermark {
let force_overlay =
spec.layer == WatermarkLayer::Background && Self::has_full_page_background(base);
let as_overlay = spec.layer == WatermarkLayer::Overlay || force_overlay;
if as_overlay {
let wm = build_watermark_document(
base,
spec,
resolver,
page_data,
report.as_deref_mut(),
Some(self.font_registry.clone()),
Some(self.asset_bundle.clone()),
self.svg_form_xobjects,
self.svg_raster_fallback,
);
Self::merge_overlay_commands(&mut overlay, &wm);
has_overlay = true;
if force_overlay {
if let Some(logger) = self.debug.as_deref() {
let json = format!(
"{{\"type\":\"jit.watermark\",\"layer\":\"background\",\"fallback\":\"overlay\",\"reason\":\"body_background\"}}"
);
logger.log_json(&json);
}
}
}
}
if let Some(spec) = &self.page_header_html {
apply_page_header_html(
&mut overlay,
spec,
resolver,
page_data,
Some(self.font_registry.clone()),
Some(self.asset_bundle.clone()),
report.as_deref_mut(),
self.svg_form_xobjects,
self.svg_raster_fallback,
self.perf.as_deref(),
);
has_overlay = true;
} else if let Some(spec) = &self.page_header {
apply_page_header(
&mut overlay,
spec,
page_data,
report.as_deref_mut(),
Some(self.font_registry.as_ref()),
);
has_overlay = true;
}
if let Some(spec) = &self.page_footer {
apply_page_footer(
&mut overlay,
spec,
page_data,
report.as_deref_mut(),
Some(self.font_registry.as_ref()),
);
has_overlay = true;
}
let overlay = if has_overlay { Some(overlay) } else { None };
let background = self.watermark.as_ref().and_then(|spec| {
if spec.layer == WatermarkLayer::Background && !Self::has_full_page_background(base) {
Some(build_watermark_document(
base,
spec,
resolver,
page_data,
report.as_deref_mut(),
Some(self.font_registry.clone()),
Some(self.asset_bundle.clone()),
self.svg_form_xobjects,
self.svg_raster_fallback,
))
} else {
None
}
});
(overlay, background)
}
fn finalize_with_jit(
&self,
doc_id: usize,
mut base: Document,
overlay: Option<Document>,
background: Option<Document>,
page_data: Option<PageDataContext>,
plan: Option<jit::DocPlan>,
) -> Document {
match self.jit_mode {
JitMode::Off => {
if let Some(ref bg_doc) = background {
merge_background_commands(&mut base, bg_doc);
}
if let Some(ref overlay_doc) = overlay {
Self::merge_overlay_commands(&mut base, overlay_doc);
}
base
}
JitMode::PlanOnly => {
let _plan = plan.or_else(|| {
Some(jit::plan_document_with_overlay(
doc_id,
&base,
background.as_ref(),
overlay.as_ref(),
page_data,
self.debug.clone(),
Some(self.font_registry.as_ref()),
))
});
if let Some(ref bg_doc) = background {
merge_background_commands(&mut base, bg_doc);
}
if let Some(ref overlay_doc) = overlay {
Self::merge_overlay_commands(&mut base, overlay_doc);
}
base
}
JitMode::PlanAndReplay => {
let plan = plan.unwrap_or_else(|| {
jit::plan_document_with_overlay(
doc_id,
&base,
background.as_ref(),
overlay.as_ref(),
page_data,
self.debug.clone(),
Some(self.font_registry.as_ref()),
)
});
let ops = jit::paint_plan(&plan, self.debug.clone());
jit::ops_to_document(plan.page_size, ops)
}
}
}
fn render_to_document_and_page_data_with_resolver_and_report_at(
&self,
doc_id: usize,
html: &str,
page_templates: &[PageTemplate],
resolver: &style::StyleResolver,
report: Option<&mut GlyphCoverageReport>,
) -> Result<(Document, Option<PageDataContext>), FullBleedError> {
let mut report = report;
let perf = self.perf.as_deref();
self.emit_html_asset_warnings(doc_id, html);
let layout = self.build_document_with_layout_strategy(
doc_id,
html,
page_templates,
resolver,
report.as_deref_mut(),
)?;
let built = layout.document;
let story_ms = layout.story_ms;
let layout_ms = layout.layout_ms;
let page_data_override = self
.paginated_context
.as_ref()
.map(|spec| page_data::compute_page_data_context(&built, spec));
let t_plan = std::time::Instant::now();
let planned = plan::plan_document_with_overlay(
doc_id,
&built,
self.paginated_context.as_ref(),
self.template_binding_spec.as_ref(),
page_data_override.clone(),
self.debug.clone(),
self.jit_mode,
Some(self.font_registry.as_ref()),
|page_data| {
self.build_overlay_documents(&built, resolver, page_data, report.as_deref_mut())
},
)?;
let plan_ms = t_plan.elapsed().as_secs_f64() * 1000.0;
let template_binding_count = planned
.template_bindings
.as_ref()
.map(|v| v.len())
.unwrap_or(0);
if let Some(logger) = self.debug.as_deref() {
let t_finalize = std::time::Instant::now();
let built = self.finalize_with_jit(
doc_id,
built,
planned.overlay.clone(),
planned.background.clone(),
planned.page_data.clone(),
planned.plan.clone(),
);
let finalize_ms = t_finalize.elapsed().as_secs_f64() * 1000.0;
log_jit_metrics(
logger,
doc_id,
self.jit_mode,
&self.pdf_options,
story_ms,
layout_ms,
plan_ms,
Some(finalize_ms),
&built,
planned.overlay.as_ref(),
planned.plan.as_ref(),
planned.page_data.as_ref().or(page_data_override.as_ref()),
);
if let Some(perf_logger) = perf {
perf_logger.log_span_ms("story", Some(doc_id), story_ms);
perf_logger.log_span_ms("layout", Some(doc_id), layout_ms);
perf_logger.log_span_ms("plan", Some(doc_id), plan_ms);
perf_logger.log_span_ms("finalize", Some(doc_id), finalize_ms);
let command_count: usize = built.pages.iter().map(|page| page.commands.len()).sum();
perf_logger.log_counts(
"doc",
Some(doc_id),
&[
("pages", built.pages.len() as u64),
("commands", command_count as u64),
("template_bindings", template_binding_count as u64),
],
);
}
return Ok((built, planned.page_data));
}
let built = self.finalize_with_jit(
doc_id,
built,
planned.overlay,
planned.background,
planned.page_data.clone(),
planned.plan,
);
if let Some(perf_logger) = perf {
perf_logger.log_span_ms("story", Some(doc_id), story_ms);
perf_logger.log_span_ms("layout", Some(doc_id), layout_ms);
perf_logger.log_span_ms("plan", Some(doc_id), plan_ms);
let command_count: usize = built.pages.iter().map(|page| page.commands.len()).sum();
perf_logger.log_counts(
"doc",
Some(doc_id),
&[
("pages", built.pages.len() as u64),
("commands", command_count as u64),
("template_bindings", template_binding_count as u64),
],
);
}
Ok((built, planned.page_data.or(page_data_override)))
}
fn render_to_planned_doc_with_resolver_and_report_at(
&self,
doc_id: usize,
html: &str,
page_templates: &[PageTemplate],
resolver: &style::StyleResolver,
report: Option<&mut GlyphCoverageReport>,
) -> Result<plan::PlannedDoc, FullBleedError> {
let mut report = report;
let perf = self.perf.as_deref();
let layout = self.build_document_with_layout_strategy(
doc_id,
html,
page_templates,
resolver,
report.as_deref_mut(),
)?;
let built = layout.document;
let story_ms = layout.story_ms;
let layout_ms = layout.layout_ms;
let page_data_override = self
.paginated_context
.as_ref()
.map(|spec| page_data::compute_page_data_context(&built, spec));
let t_plan = std::time::Instant::now();
let planned = plan::plan_document_with_overlay(
doc_id,
&built,
self.paginated_context.as_ref(),
self.template_binding_spec.as_ref(),
page_data_override.clone(),
self.debug.clone(),
self.jit_mode,
Some(self.font_registry.as_ref()),
|page_data| {
self.build_overlay_documents(&built, resolver, page_data, report.as_deref_mut())
},
)?;
let plan_ms = t_plan.elapsed().as_secs_f64() * 1000.0;
let template_binding_count = planned
.template_bindings
.as_ref()
.map(|v| v.len())
.unwrap_or(0);
if let Some(logger) = self.debug.as_deref() {
log_jit_metrics(
logger,
doc_id,
self.jit_mode,
&self.pdf_options,
story_ms,
layout_ms,
plan_ms,
None,
&built,
planned.overlay.as_ref(),
planned.plan.as_ref(),
planned.page_data.as_ref().or(page_data_override.as_ref()),
);
}
if let Some(perf_logger) = perf {
perf_logger.log_span_ms("story", Some(doc_id), story_ms);
perf_logger.log_span_ms("layout", Some(doc_id), layout_ms);
perf_logger.log_span_ms("plan", Some(doc_id), plan_ms);
let command_count: usize = built.pages.iter().map(|page| page.commands.len()).sum();
perf_logger.log_counts(
"doc",
Some(doc_id),
&[
("pages", built.pages.len() as u64),
("commands", command_count as u64),
("template_bindings", template_binding_count as u64),
],
);
}
Ok(planned)
}
fn render_to_document_and_page_data_with_resolver_and_report(
&self,
html: &str,
page_templates: &[PageTemplate],
resolver: &style::StyleResolver,
report: Option<&mut GlyphCoverageReport>,
) -> Result<(Document, Option<PageDataContext>), FullBleedError> {
self.render_to_document_and_page_data_with_resolver_and_report_at(
0,
html,
page_templates,
resolver,
report,
)
}
fn render_to_document_and_page_data_with_resolver(
&self,
html: &str,
page_templates: &[PageTemplate],
resolver: &style::StyleResolver,
) -> Result<(Document, Option<PageDataContext>), FullBleedError> {
self.render_to_document_and_page_data_with_resolver_and_report(
html,
page_templates,
resolver,
None,
)
}
fn render_to_document_with_resolver(
&self,
html: &str,
page_templates: &[PageTemplate],
resolver: &style::StyleResolver,
) -> Result<Document, FullBleedError> {
self.render_to_document_and_page_data_with_resolver(html, page_templates, resolver)
.map(|(doc, _page_data)| doc)
}
pub fn render_to_document(&self, html: &str, css: &str) -> Result<Document, FullBleedError> {
let context = self.build_render_context(css, Some(0));
self.render_to_document_with_resolver(html, &context.page_templates, &context.resolver)
}
pub fn compile_document(
&self,
html: &str,
css: &str,
) -> Result<CompiledDocument, FullBleedError> {
let started = std::time::Instant::now();
let document = Arc::new(self.render_to_document(html, css)?);
let command_count = document.pages.iter().map(|page| page.commands.len()).sum();
let elapsed = started.elapsed();
let compile_nanos = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX);
if let Some(logger) = self.perf.as_deref() {
logger.log_span_ms("compile.document", Some(0), elapsed.as_secs_f64() * 1000.0);
logger.log_counts(
"compile.document",
Some(0),
&[
("pages", document.pages.len() as u64),
("commands", command_count as u64),
],
);
}
Ok(CompiledDocument {
document,
font_registry: self.font_registry.clone(),
pdf_options: self.pdf_options.clone(),
debug: self.debug.clone(),
perf: self.perf.clone(),
compile_nanos,
command_count,
})
}
pub fn render_to_buffer(&self, html: &str, css: &str) -> Result<Vec<u8>, FullBleedError> {
let document = self.render_to_document(html, css)?;
let bytes = pdf::document_to_pdf_with_metrics_and_registry_with_logs(
&document,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
self.debug.clone(),
self.perf.clone(),
)?;
self.emit_debug_summary("render_to_buffer");
Ok(bytes)
}
pub fn render_with_metrics(
&self,
html: &str,
css: &str,
) -> Result<(Vec<u8>, DocumentMetrics), FullBleedError> {
let context = self.build_render_context(css, Some(0));
let mut metrics = DocumentMetrics::default();
let layout = self.build_document_with_layout_strategy(
0,
html,
&context.page_templates,
&context.resolver,
None,
)?;
let document = layout.document;
metrics.total_render_ms = layout.layout_ms;
metrics.pages = document
.pages
.iter()
.enumerate()
.map(|(idx, page)| PageMetrics {
page_number: idx + 1,
render_ms: 0.0,
command_count: page.commands.len(),
flowable_count: 0,
content_bytes: 0,
})
.collect();
let page_data_override = self
.paginated_context
.as_ref()
.map(|spec| page_data::compute_page_data_context(&document, spec));
let planned = plan::plan_document_with_overlay(
0,
&document,
self.paginated_context.as_ref(),
self.template_binding_spec.as_ref(),
page_data_override.clone(),
self.debug.clone(),
self.jit_mode,
Some(self.font_registry.as_ref()),
|page_data| self.build_overlay_documents(&document, &context.resolver, page_data, None),
)?;
let _template_binding_count = planned
.template_bindings
.as_ref()
.map(|v| v.len())
.unwrap_or(0);
let document = self.finalize_with_jit(
0,
document,
planned.overlay,
planned.background,
planned.page_data.or(page_data_override),
planned.plan,
);
let bytes = pdf::document_to_pdf_with_metrics_and_registry_with_logs(
&document,
Some(&mut metrics),
Some(self.font_registry.as_ref()),
&self.pdf_options,
self.debug.clone(),
self.perf.clone(),
)?;
self.emit_debug_summary("render_with_metrics");
Ok((bytes, metrics))
}
pub fn render_with_page_data(
&self,
html: &str,
css: &str,
) -> Result<(Vec<u8>, Option<PageDataContext>), FullBleedError> {
let context = self.build_render_context(css, Some(0));
let (document, page_data) = self.render_to_document_and_page_data_with_resolver(
html,
&context.page_templates,
&context.resolver,
)?;
let bytes = pdf::document_to_pdf_with_metrics_and_registry_with_logs(
&document,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
self.debug.clone(),
self.perf.clone(),
)?;
self.emit_debug_summary("render_with_page_data");
Ok((bytes, page_data))
}
pub fn render_with_page_data_and_glyph_report(
&self,
html: &str,
css: &str,
) -> Result<(Vec<u8>, Option<PageDataContext>, GlyphCoverageReport), FullBleedError> {
let context = self.build_render_context(css, Some(0));
let mut report = GlyphCoverageReport::default();
let (document, page_data) = self
.render_to_document_and_page_data_with_resolver_and_report(
html,
&context.page_templates,
&context.resolver,
Some(&mut report),
)?;
let bytes = pdf::document_to_pdf_with_metrics_and_registry_with_logs(
&document,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
self.debug.clone(),
self.perf.clone(),
)?;
self.emit_debug_summary("render_with_page_data_and_glyph_report");
Ok((bytes, page_data, report))
}
pub fn render_with_page_data_and_template_bindings(
&self,
html: &str,
css: &str,
) -> Result<
(
Vec<u8>,
Option<PageDataContext>,
Option<Vec<PageBindingDecision>>,
),
FullBleedError,
> {
let context = self.build_render_context(css, Some(0));
let (document, page_data) = self.render_to_document_and_page_data_with_resolver(
html,
&context.page_templates,
&context.resolver,
)?;
let template_bindings = match self.template_binding_spec.as_ref() {
Some(spec) => Some(resolve_template_bindings_for_document(&document, spec)?),
None => None,
};
let bytes = pdf::document_to_pdf_with_metrics_and_registry_with_logs(
&document,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
self.debug.clone(),
self.perf.clone(),
)?;
self.emit_debug_summary("render_with_page_data_and_template_bindings");
Ok((bytes, page_data, template_bindings))
}
pub fn render_with_page_data_and_template_bindings_and_glyph_report(
&self,
html: &str,
css: &str,
) -> Result<
(
Vec<u8>,
Option<PageDataContext>,
Option<Vec<PageBindingDecision>>,
GlyphCoverageReport,
),
FullBleedError,
> {
let context = self.build_render_context(css, Some(0));
let mut report = GlyphCoverageReport::default();
let (document, page_data) = self
.render_to_document_and_page_data_with_resolver_and_report(
html,
&context.page_templates,
&context.resolver,
Some(&mut report),
)?;
let template_bindings = match self.template_binding_spec.as_ref() {
Some(spec) => Some(resolve_template_bindings_for_document(&document, spec)?),
None => None,
};
let bytes = pdf::document_to_pdf_with_metrics_and_registry_with_logs(
&document,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
self.debug.clone(),
self.perf.clone(),
)?;
self.emit_debug_summary("render_with_page_data_and_template_bindings_and_glyph_report");
Ok((bytes, page_data, template_bindings, report))
}
pub fn render_with_glyph_report(
&self,
html: &str,
css: &str,
) -> Result<(Vec<u8>, GlyphCoverageReport), FullBleedError> {
let (bytes, _page_data, report) = self.render_with_page_data_and_glyph_report(html, css)?;
Ok((bytes, report))
}
pub fn render_to_document_with_glyph_report(
&self,
html: &str,
css: &str,
) -> Result<(Document, GlyphCoverageReport), FullBleedError> {
let context = self.build_render_context(css, Some(0));
let mut report = GlyphCoverageReport::default();
let (document, _page_data) = self
.render_to_document_and_page_data_with_resolver_and_report(
html,
&context.page_templates,
&context.resolver,
Some(&mut report),
)?;
self.emit_debug_summary("render_to_document_with_glyph_report");
Ok((document, report))
}
pub fn render_with_glyph_report_and_document(
&self,
html: &str,
css: &str,
) -> Result<(Vec<u8>, GlyphCoverageReport, Document), FullBleedError> {
let (document, report) = self.render_to_document_with_glyph_report(html, css)?;
let bytes = pdf::document_to_pdf_with_metrics_and_registry_with_logs(
&document,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
self.debug.clone(),
self.perf.clone(),
)?;
self.emit_debug_summary("render_with_glyph_report_and_document");
Ok((bytes, report, document))
}
pub fn render_to_writer<W: std::io::Write>(
&self,
html: &str,
css: &str,
writer: &mut W,
) -> Result<usize, FullBleedError> {
let context = self.build_render_context(css, Some(0));
let document = self.render_to_document_with_resolver(
html,
&context.page_templates,
&context.resolver,
)?;
let bytes_written = pdf::document_to_pdf_with_metrics_and_registry_to_writer_with_logs(
&document,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
writer,
self.debug.clone(),
self.perf.clone(),
)?;
self.emit_debug_summary("render_to_writer");
Ok(bytes_written)
}
pub fn render_to_file(
&self,
html: &str,
css: &str,
path: impl AsRef<std::path::Path>,
) -> Result<usize, FullBleedError> {
let mut file = std::fs::File::create(path)?;
self.render_to_writer(html, css, &mut file)
}
pub fn render_image_pages(
&self,
html: &str,
css: &str,
dpi: u32,
) -> Result<Vec<Vec<u8>>, FullBleedError> {
let context = self.build_render_context(css, Some(0));
let document = self.render_to_document_with_resolver(
html,
&context.page_templates,
&context.resolver,
)?;
let start = std::time::Instant::now();
let pages = raster::document_to_png_pages(
&document,
dpi,
Some(self.font_registry.as_ref()),
self.pdf_options.shape_text,
)?;
if let Some(perf_logger) = self.perf.as_deref() {
let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
perf_logger.log_span_ms("raster", Some(0), elapsed_ms);
perf_logger.log_counts("raster", Some(0), &[("pages", pages.len() as u64)]);
}
self.emit_debug_summary("render_image_pages");
Ok(pages)
}
pub fn render_image_pages_to_dir(
&self,
html: &str,
css: &str,
out_dir: impl AsRef<std::path::Path>,
stem: &str,
dpi: u32,
) -> Result<Vec<std::path::PathBuf>, FullBleedError> {
let pages = self.render_image_pages(html, css, dpi)?;
let out_dir = out_dir.as_ref();
std::fs::create_dir_all(out_dir)?;
let stem = if stem.trim().is_empty() {
"render"
} else {
stem
};
let mut paths = Vec::with_capacity(pages.len());
for (idx0, page_bytes) in pages.into_iter().enumerate() {
let path = out_dir.join(format!("{stem}_page{}.png", idx0 + 1));
std::fs::write(&path, page_bytes)?;
paths.push(path);
}
Ok(paths)
}
pub fn render_finalized_pdf_image_pages(
&self,
pdf_path: impl AsRef<std::path::Path>,
dpi: u32,
) -> Result<Vec<Vec<u8>>, FullBleedError> {
let start = std::time::Instant::now();
let pages = pdf_raster::pdf_path_to_png_pages(
pdf_path.as_ref(),
dpi,
Some(self.font_registry.as_ref()),
self.pdf_options.shape_text,
)?;
if let Some(perf_logger) = self.perf.as_deref() {
let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
perf_logger.log_span_ms("raster.finalized_pdf", Some(0), elapsed_ms);
perf_logger.log_counts(
"raster.finalized_pdf",
Some(0),
&[("pages", pages.len() as u64)],
);
}
self.emit_debug_summary("render_finalized_pdf_image_pages");
Ok(pages)
}
pub fn render_finalized_pdf_image_pages_to_dir(
&self,
pdf_path: impl AsRef<std::path::Path>,
out_dir: impl AsRef<std::path::Path>,
stem: &str,
dpi: u32,
) -> Result<Vec<std::path::PathBuf>, FullBleedError> {
let pages = self.render_finalized_pdf_image_pages(pdf_path, dpi)?;
let out_dir = out_dir.as_ref();
std::fs::create_dir_all(out_dir)?;
let stem = if stem.trim().is_empty() {
"render"
} else {
stem
};
let mut paths = Vec::with_capacity(pages.len());
for (idx0, page_bytes) in pages.into_iter().enumerate() {
let path = out_dir.join(format!("{stem}_page{}.png", idx0 + 1));
std::fs::write(&path, page_bytes)?;
paths.push(path);
}
Ok(paths)
}
pub fn render_many_to_buffer(
&self,
html_list: &[String],
css: &str,
) -> Result<Vec<u8>, FullBleedError> {
let context = self.build_render_context(css, None);
let mut documents = Vec::with_capacity(html_list.len());
for (idx, html) in html_list.iter().enumerate() {
let (doc, _page_data) = self
.render_to_document_and_page_data_with_resolver_and_report_at(
idx,
html,
&context.page_templates,
&context.resolver,
None,
)?;
documents.push(doc);
}
let merged = merge_documents(documents)?;
let bytes = pdf::document_to_pdf_with_metrics_and_registry_with_logs(
&merged,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
self.debug.clone(),
self.perf.clone(),
)?;
self.emit_debug_summary("render_many_to_buffer");
Ok(bytes)
}
pub fn render_many_to_writer<W: std::io::Write>(
&self,
html_list: &[String],
css: &str,
writer: &mut W,
) -> Result<usize, FullBleedError> {
let context = self.build_render_context(css, None);
let page_size = context
.page_templates
.get(0)
.ok_or(FullBleedError::MissingPageTemplate)?
.page_size;
let mut pdf_stream = pdf::PdfStreamWriter::new(
writer,
page_size,
Some(self.font_registry.as_ref()),
self.pdf_options.clone(),
self.debug.clone(),
self.perf.clone(),
)?;
for (idx, html) in html_list.iter().enumerate() {
let (doc, _page_data) = self
.render_to_document_and_page_data_with_resolver_and_report_at(
idx,
html,
&context.page_templates,
&context.resolver,
None,
)?;
pdf_stream.add_document(idx, &doc)?;
}
let bytes_written = pdf_stream.finish()?;
self.emit_debug_summary("render_many_to_writer");
Ok(bytes_written)
}
pub fn render_many_to_file(
&self,
html_list: &[String],
css: &str,
path: impl AsRef<std::path::Path>,
) -> Result<usize, FullBleedError> {
let mut file = std::fs::File::create(path)?;
self.render_many_to_writer(html_list, css, &mut file)
}
pub fn render_many_to_buffer_with_css(
&self,
jobs: &[(String, String)],
) -> Result<Vec<u8>, FullBleedError> {
let mut documents = Vec::with_capacity(jobs.len());
for (idx, (html, css)) in jobs.iter().enumerate() {
let context = self.build_render_context(css, Some(idx));
let (doc, _page_data) = self
.render_to_document_and_page_data_with_resolver_and_report_at(
idx,
html,
&context.page_templates,
&context.resolver,
None,
)?;
documents.push(doc);
}
let merged = merge_documents(documents)?;
Ok(pdf::document_to_pdf_with_metrics_and_registry_with_logs(
&merged,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
self.debug.clone(),
self.perf.clone(),
)?)
}
pub fn render_many_to_writer_with_css<W: std::io::Write>(
&self,
jobs: &[(String, String)],
writer: &mut W,
) -> Result<usize, FullBleedError> {
let page_size = jobs
.get(0)
.and_then(|(_, css)| {
let context = self.build_render_context(css, Some(0));
context.page_templates.get(0).map(|t| t.page_size)
})
.unwrap_or(self.default_page_size);
let mut pdf_stream = pdf::PdfStreamWriter::new(
writer,
page_size,
Some(self.font_registry.as_ref()),
self.pdf_options.clone(),
self.debug.clone(),
self.perf.clone(),
)?;
for (idx, (html, css)) in jobs.iter().enumerate() {
let context = self.build_render_context(css, Some(idx));
let (doc, _page_data) = self
.render_to_document_and_page_data_with_resolver_and_report_at(
idx,
html,
&context.page_templates,
&context.resolver,
None,
)?;
pdf_stream.add_document(idx, &doc)?;
}
Ok(pdf_stream.finish()?)
}
pub fn render_many_to_file_with_css(
&self,
jobs: &[(String, String)],
path: impl AsRef<std::path::Path>,
) -> Result<usize, FullBleedError> {
let mut file = std::fs::File::create(path)?;
self.render_many_to_writer_with_css(jobs, &mut file)
}
pub fn render_many_to_buffer_parallel(
&self,
html_list: &[String],
css: &str,
) -> Result<Vec<u8>, FullBleedError> {
let context = self.build_render_context(css, None);
let mut results: Vec<(usize, Result<Document, FullBleedError>)> =
crate::parallel::map_indexed_ordered(html_list, |idx, html| {
let res = self
.render_to_document_and_page_data_with_resolver_and_report_at(
idx,
html,
&context.page_templates,
&context.resolver,
None,
)
.map(|(doc, _page_data)| doc);
(idx, res)
});
results.sort_by_key(|(idx, _)| *idx);
let mut documents = Vec::with_capacity(results.len());
for (_, res) in results {
documents.push(res?);
}
let merged = merge_documents(documents)?;
Ok(pdf::document_to_pdf_with_metrics_and_registry_with_logs(
&merged,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
self.debug.clone(),
self.perf.clone(),
)?)
}
pub fn render_many_to_buffer_parallel_with_page_data(
&self,
html_list: &[String],
css: &str,
) -> Result<(Vec<u8>, Vec<Option<PageDataContext>>), FullBleedError> {
let context = self.build_render_context(css, None);
let mut results: Vec<(
usize,
Result<(Document, Option<PageDataContext>), FullBleedError>,
)> = crate::parallel::map_indexed_ordered(html_list, |idx, html| {
let res = self.render_to_document_and_page_data_with_resolver_and_report_at(
idx,
html,
&context.page_templates,
&context.resolver,
None,
);
(idx, res)
});
results.sort_by_key(|(idx, _)| *idx);
let mut documents = Vec::with_capacity(results.len());
let mut page_data_list = Vec::with_capacity(results.len());
for (_, res) in results {
let (doc, page_data) = res?;
documents.push(doc);
page_data_list.push(page_data);
}
let merged = merge_documents(documents)?;
let bytes = pdf::document_to_pdf_with_metrics_and_registry_with_logs(
&merged,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
self.debug.clone(),
self.perf.clone(),
)?;
Ok((bytes, page_data_list))
}
pub fn render_many_to_writer_parallel<W: std::io::Write>(
&self,
html_list: &[String],
css: &str,
writer: &mut W,
) -> Result<usize, FullBleedError> {
let perf = self.perf.as_deref();
let t_total = std::time::Instant::now();
let context = self.build_render_context(css, None);
let page_size = context
.page_templates
.get(0)
.ok_or(FullBleedError::MissingPageTemplate)?
.page_size;
let mut pdf_stream = pdf::PdfStreamWriter::new(
writer,
page_size,
Some(self.font_registry.as_ref()),
self.pdf_options.clone(),
self.debug.clone(),
self.perf.clone(),
)?;
if matches!(self.jit_mode, JitMode::PlanAndReplay) {
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::mpsc;
use std::thread;
let n = html_list.len();
if n == 0 {
return Err(FullBleedError::EmptyDocumentSet);
}
let spill_enabled = std::env::var("FULLBLEED_JIT_SPILL")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let spill_dir = std::env::var("FULLBLEED_JIT_SPILL_DIR")
.ok()
.map(PathBuf::from)
.or_else(|| {
if spill_enabled {
Some(std::env::temp_dir().join("fullbleed_spill"))
} else {
None
}
});
let spill_store = match spill_dir {
Some(dir) => Some(Arc::new(
spill::SpillStore::new(dir).map_err(FullBleedError::Io)?,
)),
None => None,
};
let parallelism = crate::parallel::current_num_threads();
let buffer_cap = (parallelism * 4).min(256);
let (tx, rx) =
mpsc::sync_channel::<(usize, Result<Document, FullBleedError>)>(buffer_cap);
let mut render_error: Option<FullBleedError> = None;
thread::scope(|scope| {
let rx = rx;
let spill_store = spill_store.as_ref();
scope.spawn(|| {
crate::parallel::with_thread_count(parallelism, || {
crate::parallel::for_each_indexed(html_list, |idx, html| {
let res = self
.render_to_planned_doc_with_resolver_and_report_at(
idx,
html,
&context.page_templates,
&context.resolver,
None,
)
.and_then(|planned| {
let plan = planned.plan.ok_or_else(|| {
FullBleedError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
"jit plan missing in PlanAndReplay mode",
))
})?;
let ops = jit::paint_plan_parallel(&plan, self.debug.clone());
Ok(jit::ops_to_document(plan.page_size, ops))
});
let _ = tx.send((idx, res));
});
});
});
enum PendingDoc {
InMemory(Document),
Spilled(PathBuf),
}
let mut pending: BTreeMap<usize, PendingDoc> = BTreeMap::new();
let mut next_idx: usize = 0;
let spill_threshold = buffer_cap;
while next_idx < n {
match rx.recv() {
Ok((idx, res)) => match res {
Ok(doc) => {
let entry = if let Some(store) = spill_store {
if pending.len() >= spill_threshold {
match store.spill(&doc) {
Ok(path) => PendingDoc::Spilled(path),
Err(err) => {
render_error = Some(FullBleedError::Io(err));
break;
}
}
} else {
PendingDoc::InMemory(doc)
}
} else {
PendingDoc::InMemory(doc)
};
pending.insert(idx, entry);
while let Some(entry) = pending.remove(&next_idx) {
let doc = match entry {
PendingDoc::InMemory(doc) => doc,
PendingDoc::Spilled(path) => {
if let Some(store) = spill_store {
match store.load(&path) {
Ok(doc) => doc,
Err(err) => {
render_error =
Some(FullBleedError::Io(err));
break;
}
}
} else {
render_error =
Some(FullBleedError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
"spill requested without spill store",
)));
break;
}
}
};
if let Err(e) = pdf_stream.add_document(next_idx, &doc) {
render_error = Some(FullBleedError::Io(e));
break;
}
next_idx += 1;
}
if render_error.is_some() {
break;
}
}
Err(e) => {
render_error = Some(e);
break;
}
},
Err(_) => {
render_error = Some(FullBleedError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
"jit batch channel closed unexpectedly",
)));
break;
}
}
}
});
if let Some(err) = render_error {
return Err(err);
}
if let Some(store) = spill_store {
let (files, bytes) = store.metrics();
if let Some(logger) = self.debug.as_deref() {
let json = format!(
"{{\"type\":\"jit.spill\",\"files\":{},\"bytes\":{}}}",
files, bytes
);
logger.log_json(&json);
}
}
return Ok(pdf_stream.finish()?);
}
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
let n = html_list.len();
if n == 0 {
return Err(FullBleedError::EmptyDocumentSet);
}
let parallelism = crate::parallel::current_num_threads();
let buffer_cap = (parallelism * 4).min(256);
let (tx, rx) = mpsc::sync_channel::<(usize, Result<Document, FullBleedError>)>(buffer_cap);
let mut render_error: Option<FullBleedError> = None;
let timing_enabled = std::env::var("FULLBLEED_TIMING")
.map(|v| v == "1")
.unwrap_or(false);
let mut recv_wait = Duration::ZERO;
let mut write_time = Duration::ZERO;
let send_wait = Arc::new(AtomicU64::new(0));
let send_count = Arc::new(AtomicU64::new(0));
let send_blocked = Arc::new(AtomicU64::new(0));
let mut max_pending: usize = 0;
thread::scope(|scope| {
let rx = rx;
scope.spawn(|| {
let send_wait = send_wait.clone();
let send_count = send_count.clone();
let send_blocked = send_blocked.clone();
crate::parallel::with_thread_count(parallelism, || {
crate::parallel::for_each_indexed(html_list, |idx, html| {
let res = self
.render_to_document_and_page_data_with_resolver_and_report_at(
idx,
html,
&context.page_templates,
&context.resolver,
None,
)
.map(|(doc, _page_data)| doc);
let t_send = Instant::now();
let _ = tx.send((idx, res));
let waited = t_send.elapsed();
send_wait.fetch_add(waited.as_nanos() as u64, Ordering::Relaxed);
send_count.fetch_add(1, Ordering::Relaxed);
if waited > Duration::from_millis(1) {
send_blocked.fetch_add(1, Ordering::Relaxed);
}
});
});
});
let mut pending: BTreeMap<usize, Document> = BTreeMap::new();
let mut next_idx: usize = 0;
while next_idx < n {
let t0 = Instant::now();
let msg = rx.recv();
recv_wait += t0.elapsed();
match msg {
Ok((idx, res)) => match res {
Ok(doc) => {
pending.insert(idx, doc);
if pending.len() > max_pending {
max_pending = pending.len();
}
while let Some(doc) = pending.remove(&next_idx) {
let t1 = Instant::now();
if let Err(e) = pdf_stream.add_document(next_idx, &doc) {
render_error = Some(FullBleedError::Io(e));
break;
}
write_time += t1.elapsed();
next_idx += 1;
}
}
Err(e) => {
render_error = Some(e);
break;
}
},
Err(err) => {
render_error = Some(FullBleedError::Io(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
err.to_string(),
)));
break;
}
}
if render_error.is_some() {
break;
}
}
});
if let Some(e) = render_error {
return Err(e);
}
if timing_enabled {
eprintln!(
"[fullbleed] parallel pipeline: recv_wait={:.2?} write_time={:.2?}",
recv_wait, write_time
);
}
let bytes_written = pdf_stream.finish()?;
if let Some(perf_logger) = perf {
perf_logger.log_span_ms("batch.recv_wait", None, recv_wait.as_secs_f64() * 1000.0);
let send_wait_ms = send_wait.load(Ordering::Relaxed) as f64 / 1_000_000.0;
perf_logger.log_span_ms("batch.send_wait", None, send_wait_ms);
perf_logger.log_span_ms("batch.write_time", None, write_time.as_secs_f64() * 1000.0);
perf_logger.log_span_ms(
"batch.total",
None,
t_total.elapsed().as_secs_f64() * 1000.0,
);
perf_logger.log_counts(
"batch",
None,
&[
("bytes", bytes_written as u64),
("docs", n as u64),
("buffer_cap", buffer_cap as u64),
("send_count", send_count.load(Ordering::Relaxed)),
("send_blocked", send_blocked.load(Ordering::Relaxed)),
("pending_max", max_pending as u64),
],
);
}
Ok(bytes_written)
}
pub fn render_many_to_writer_parallel_with_page_data<W: std::io::Write>(
&self,
html_list: &[String],
css: &str,
writer: &mut W,
) -> Result<(usize, Vec<Option<PageDataContext>>), FullBleedError> {
let context = self.build_render_context(css, None);
let mut results: Vec<(
usize,
Result<(Document, Option<PageDataContext>), FullBleedError>,
)> = crate::parallel::map_indexed_ordered(html_list, |idx, html| {
let res = self.render_to_document_and_page_data_with_resolver_and_report_at(
idx,
html,
&context.page_templates,
&context.resolver,
None,
);
(idx, res)
});
results.sort_by_key(|(idx, _)| *idx);
let mut documents = Vec::with_capacity(results.len());
let mut page_data_list = Vec::with_capacity(results.len());
for (_, res) in results {
let (doc, page_data) = res?;
documents.push(doc);
page_data_list.push(page_data);
}
let merged = merge_documents(documents)?;
let bytes_written = pdf::document_to_pdf_with_metrics_and_registry_to_writer_with_logs(
&merged,
None,
Some(self.font_registry.as_ref()),
&self.pdf_options,
writer,
self.debug.clone(),
self.perf.clone(),
)?;
Ok((bytes_written, page_data_list))
}
pub fn render_many_to_file_parallel(
&self,
html_list: &[String],
css: &str,
path: impl AsRef<std::path::Path>,
) -> Result<usize, FullBleedError> {
let mut file = std::fs::File::create(path)?;
self.render_many_to_writer_parallel(html_list, css, &mut file)
}
pub fn render_many_to_file_parallel_with_page_data(
&self,
html_list: &[String],
css: &str,
path: impl AsRef<std::path::Path>,
) -> Result<(usize, Vec<Option<PageDataContext>>), FullBleedError> {
let mut file = std::fs::File::create(path)?;
self.render_many_to_writer_parallel_with_page_data(html_list, css, &mut file)
}
}
impl FullBleedBuilder {
pub fn new() -> Self {
Self {
page_size: Size::a4(),
margins: Margins::all(36.0),
page_size_explicit: false,
margins_explicit: false,
font_dirs: Vec::new(),
font_files: Vec::new(),
pdf_options: PdfOptions::default(),
svg_form_xobjects: false,
svg_raster_fallback: cfg!(feature = "svg_raster"),
unicode_metrics: true,
debug_path: None,
perf_enabled: false,
perf_path: None,
jit_mode: JitMode::Off,
layout_strategy: LayoutStrategy::Eager,
accept_lazy_layout_cost: false,
lazy_max_passes: 4,
lazy_budget_ms: 50.0,
page_header: None,
page_header_html: None,
page_footer: None,
paginated_context: None,
template_binding_spec: None,
page_margins: std::collections::BTreeMap::new(),
watermark: None,
asset_bundle: AssetBundle::default(),
}
}
pub fn page_size(mut self, size: Size) -> Self {
self.page_size = size;
self.page_size_explicit = true;
self
}
pub fn margins(mut self, margins: Margins) -> Self {
self.margins = margins;
self.margins_explicit = true;
self
}
pub fn margin_all(mut self, value: f32) -> Self {
self.margins = Margins::all(value);
self.margins_explicit = true;
self
}
pub fn svg_form_xobjects(mut self, enabled: bool) -> Self {
self.svg_form_xobjects = enabled;
self
}
pub fn svg_raster_fallback(mut self, enabled: bool) -> Self {
self.svg_raster_fallback = enabled;
self
}
pub fn page_margin(mut self, page_number: usize, margins: Margins) -> Self {
if page_number >= 1 {
self.page_margins.insert(page_number, margins);
self.margins_explicit = true;
}
self
}
pub fn register_font_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.font_dirs.push(path.into());
self
}
pub fn register_font_file(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.font_files.push(path.into());
self
}
pub fn reuse_xobjects(mut self, enabled: bool) -> Self {
self.pdf_options.reuse_xobjects = enabled;
self
}
pub fn unicode_support(mut self, enabled: bool) -> Self {
self.pdf_options.unicode_support = enabled;
self
}
pub fn shape_text(mut self, enabled: bool) -> Self {
self.pdf_options.shape_text = enabled;
self
}
pub fn jit_mode(mut self, mode: JitMode) -> Self {
self.jit_mode = mode;
self
}
pub fn layout_strategy(mut self, strategy: LayoutStrategy) -> Self {
self.layout_strategy = strategy;
self
}
pub fn lazy_layout(mut self, enabled: bool) -> Self {
self.layout_strategy = if enabled {
LayoutStrategy::Lazy
} else {
LayoutStrategy::Eager
};
self
}
pub fn accept_lazy_layout_cost(mut self, accepted: bool) -> Self {
self.accept_lazy_layout_cost = accepted;
self
}
pub fn lazy_layout_limits(mut self, max_passes: usize, budget_ms: f64) -> Self {
self.lazy_max_passes = max_passes;
self.lazy_budget_ms = budget_ms;
self
}
pub fn pdf_profile(mut self, profile: PdfProfile) -> Self {
self.pdf_options.pdf_profile = profile;
self
}
pub fn output_intent(mut self, intent: OutputIntent) -> Self {
self.pdf_options.output_intent = Some(intent);
self
}
pub fn clear_output_intent(mut self) -> Self {
self.pdf_options.output_intent = None;
self
}
pub fn pdf_version(mut self, version: PdfVersion) -> Self {
self.pdf_options.pdf_version = version;
self
}
pub fn color_space(mut self, space: ColorSpace) -> Self {
self.pdf_options.color_space = space;
self
}
pub fn document_lang(mut self, lang: impl Into<String>) -> Self {
self.pdf_options.document_lang = Some(lang.into());
self
}
pub fn clear_document_lang(mut self) -> Self {
self.pdf_options.document_lang = None;
self
}
pub fn document_lang_value(&self) -> Option<&str> {
self.pdf_options.document_lang.as_deref()
}
pub fn document_title(mut self, title: impl Into<String>) -> Self {
self.pdf_options.document_title = Some(title.into());
self
}
pub fn clear_document_title(mut self) -> Self {
self.pdf_options.document_title = None;
self
}
pub fn document_title_value(&self) -> Option<&str> {
self.pdf_options.document_title.as_deref()
}
pub fn unicode_metrics(mut self, enabled: bool) -> Self {
self.unicode_metrics = enabled;
self
}
pub fn debug_log(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.debug_path = Some(path.into());
self
}
pub fn perf_log(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.perf_enabled = true;
self.perf_path = Some(path.into());
self
}
pub fn perf_enabled(mut self, enabled: bool) -> Self {
self.perf_enabled = enabled;
self
}
pub fn page_header(
mut self,
first: Option<String>,
each: Option<String>,
last: Option<String>,
x: f32,
y_from_top: f32,
font_name: impl Into<String>,
font_size: f32,
color: Color,
) -> Self {
self.page_header = Some(PageHeaderSpec {
first,
each,
last,
x: Pt::from_f32(x),
y_from_top: Pt::from_f32(y_from_top),
font_name: font_name.into(),
font_size: Pt::from_f32(font_size),
color,
});
self
}
pub fn page_header_html(
mut self,
first: Option<String>,
each: Option<String>,
last: Option<String>,
x: f32,
y_from_top: f32,
width: f32,
height: f32,
) -> Self {
self.page_header_html = Some(PageHeaderHtmlSpec {
first,
each,
last,
x: Pt::from_f32(x),
y_from_top: Pt::from_f32(y_from_top),
width: Pt::from_f32(width),
height: Pt::from_f32(height),
});
self
}
pub fn page_footer(
mut self,
first: Option<String>,
each: Option<String>,
last: Option<String>,
x: f32,
y_from_bottom: f32,
font_name: impl Into<String>,
font_size: f32,
color: Color,
) -> Self {
self.page_footer = Some(PageFooterSpec {
first,
each,
last,
x: Pt::from_f32(x),
y_from_bottom: Pt::from_f32(y_from_bottom),
font_name: font_name.into(),
font_size: Pt::from_f32(font_size),
color,
});
self
}
pub fn watermark(mut self, spec: WatermarkSpec) -> Self {
self.watermark = Some(spec);
self
}
pub fn watermark_semantics(mut self, semantics: WatermarkSemantics) -> Self {
if let Some(spec) = self.watermark.as_mut() {
spec.semantics = semantics;
}
self
}
pub fn watermark_text(mut self, text: impl Into<String>) -> Self {
self.watermark = Some(WatermarkSpec::text(text));
self
}
pub fn watermark_html(mut self, html: impl Into<String>) -> Self {
self.watermark = Some(WatermarkSpec::html(html));
self
}
pub fn watermark_image(mut self, path: impl Into<String>) -> Self {
self.watermark = Some(WatermarkSpec::image(path));
self
}
pub fn paginated_context(mut self, spec: PaginatedContextSpec) -> Self {
self.paginated_context = Some(spec);
self
}
pub fn template_binding_spec(mut self, spec: TemplateBindingSpec) -> Self {
self.template_binding_spec = Some(spec);
self
}
pub fn register_bundle(mut self, bundle: AssetBundle) -> Self {
self.asset_bundle = bundle;
self
}
pub fn build(self) -> Result<FullBleed, FullBleedError> {
if self.layout_strategy == LayoutStrategy::Lazy && !self.accept_lazy_layout_cost {
return Err(FullBleedError::InvalidConfiguration(
"layout_strategy=lazy requires accept_lazy_layout_cost(true)".to_string(),
));
}
if self.layout_strategy == LayoutStrategy::Lazy && self.lazy_max_passes < 2 {
return Err(FullBleedError::InvalidConfiguration(
"layout_strategy=lazy requires lazy_max_passes >= 2".to_string(),
));
}
if self.layout_strategy == LayoutStrategy::Lazy
&& (!self.lazy_budget_ms.is_finite() || self.lazy_budget_ms <= 0.0)
{
return Err(FullBleedError::InvalidConfiguration(
"layout_strategy=lazy requires lazy_budget_ms > 0".to_string(),
));
}
validate_pdf_options(&self.pdf_options)?;
let mut registry = FontRegistry::new();
registry.set_use_full_unicode_metrics(self.unicode_metrics);
for dir in &self.font_dirs {
registry.register_dir(dir);
}
for file in &self.font_files {
registry.register_file(file);
}
for asset in self.asset_bundle.font_assets() {
registry.register_bundle_font_bytes(asset.data.clone(), Some(&asset.name))?;
}
let asset_css = self.asset_bundle.css_text();
let debug = if let Some(path) = self.debug_path {
Some(Arc::new(DebugLogger::new(path)?))
} else {
None
};
let perf = if self.perf_enabled || self.perf_path.is_some() {
let path = self
.perf_path
.unwrap_or_else(|| std::path::PathBuf::from("fullbleed_perf.log"));
Some(Arc::new(PerfLogger::new(path)?))
} else {
None
};
Ok(FullBleed {
default_page_size: self.page_size,
default_margins: self.margins,
page_margins: self.page_margins,
page_size_explicit: self.page_size_explicit,
margins_explicit: self.margins_explicit,
font_registry: Arc::new(registry),
pdf_options: self.pdf_options,
svg_form_xobjects: self.svg_form_xobjects,
svg_raster_fallback: self.svg_raster_fallback,
debug,
perf,
jit_mode: self.jit_mode,
layout_strategy: self.layout_strategy,
lazy_max_passes: self.lazy_max_passes,
lazy_budget_ms: self.lazy_budget_ms,
page_header: self.page_header,
page_header_html: self.page_header_html,
page_footer: self.page_footer,
paginated_context: self.paginated_context,
template_binding_spec: self.template_binding_spec,
watermark: self.watermark,
asset_css,
asset_bundle: Arc::new(self.asset_bundle),
render_context_cache: Mutex::new(RenderContextCache::new()),
})
}
}
fn build_page_templates(
page_size: Size,
base_margins: Margins,
page_margins: &std::collections::BTreeMap<usize, Margins>,
page_styles: style::CssPageStyles,
) -> Vec<PageTemplate> {
let base_margins = base_margins.quantized();
let content_width = (page_size.width - base_margins.left - base_margins.right).max(Pt::ZERO);
let content_height = (page_size.height - base_margins.top - base_margins.bottom).max(Pt::ZERO);
let frame_rect = Rect {
x: base_margins.left,
y: base_margins.top,
width: content_width,
height: content_height,
}
.quantized();
let mut templates: Vec<PageTemplate> = Vec::new();
if page_margins.is_empty() {
if page_styles.has_pseudo_rules() {
let first = page_styles
.base
.cascaded_with(page_styles.right)
.cascaded_with(page_styles.first);
templates.push(build_css_page_template(
"First",
page_size,
base_margins,
first,
PageSelector::First,
));
templates.push(build_css_page_template(
"Left",
page_size,
base_margins,
page_styles.base.cascaded_with(page_styles.left),
PageSelector::Left,
));
templates.push(build_css_page_template(
"Right",
page_size,
base_margins,
page_styles.base.cascaded_with(page_styles.right),
PageSelector::Right,
));
templates.push(build_css_page_template(
"Page",
page_size,
base_margins,
page_styles.base,
PageSelector::Any,
));
} else {
let mut template = PageTemplate::new("Page1", page_size).with_frame(frame_rect);
if let Some(background) = page_styles.base.background {
template = with_page_background(template, page_size, background);
}
templates.push(template);
}
return templates;
}
let max_page = *page_margins.keys().max().unwrap_or(&1);
for page_number in 1..=max_page {
let margins = page_margins
.get(&page_number)
.copied()
.unwrap_or(base_margins)
.quantized();
let content_width = (page_size.width - margins.left - margins.right).max(Pt::ZERO);
let content_height = (page_size.height - margins.top - margins.bottom).max(Pt::ZERO);
let rect = Rect {
x: margins.left,
y: margins.top,
width: content_width,
height: content_height,
}
.quantized();
let mut template =
PageTemplate::new(format!("Page{page_number}"), page_size).with_frame(rect);
if let Some(background) = page_styles.base.background {
template = with_page_background(template, page_size, background);
}
templates.push(template);
}
templates
}
fn build_css_page_template(
name: impl Into<String>,
page_size: Size,
base_margins: Margins,
setup: style::CssPageSetup,
selector: PageSelector,
) -> PageTemplate {
let margins = setup
.resolve_margins(base_margins)
.unwrap_or(base_margins)
.quantized();
let rect = Rect {
x: margins.left,
y: margins.top,
width: (page_size.width - margins.left - margins.right).max(Pt::ZERO),
height: (page_size.height - margins.top - margins.bottom).max(Pt::ZERO),
}
.quantized();
let mut template = PageTemplate::new(name, page_size)
.with_frame(rect)
.with_page_selector(selector);
if let Some(background) = setup.background {
template = with_page_background(template, page_size, background);
}
template
}
fn with_page_background(
template: PageTemplate,
page_size: Size,
(color, alpha): (Color, f32),
) -> PageTemplate {
template.set_on_page(move |canvas, _| {
if alpha <= 0.0 {
return;
}
canvas.save_state();
canvas.set_fill_color(color);
if alpha < 1.0 {
canvas.set_opacity(alpha, alpha);
}
canvas.draw_rect(Pt::ZERO, Pt::ZERO, page_size.width, page_size.height);
canvas.restore_state();
})
}
impl Default for FullBleedBuilder {
fn default() -> Self {
Self::new()
}
}
pub fn merge_documents(documents: Vec<Document>) -> Result<Document, FullBleedError> {
if documents.is_empty() {
return Err(FullBleedError::EmptyDocumentSet);
}
let mut iter = documents.into_iter();
let first = iter.next().expect("checked empty document set");
let page_size = first.page_size;
let mut pages = first.pages;
for doc in iter {
if doc.page_size != page_size {
return Err(FullBleedError::InconsistentPageSize);
}
pages.extend(doc.pages);
}
Ok(Document { page_size, pages })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::flowable::{BorderCollapseMode, BorderSpec, TableCell, TextAlign, VerticalAlign};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn table_cell(text: &str) -> TableCell {
table_cell_with_border(text, EdgeSizes::zero(), Color::BLACK)
}
fn table_cell_with_border(text: &str, widths: EdgeSizes, color: Color) -> TableCell {
TableCell::new(
text.to_string(),
TextStyle::default(),
TextAlign::Left,
VerticalAlign::Top,
EdgeSizes::zero(),
None,
BorderSpec { widths, color },
None,
Some(Arc::<str>::from("TD")),
None,
1,
Pt::from_f32(12.0),
None,
false,
false,
)
}
fn abs(v: f32) -> LengthSpec {
LengthSpec::Absolute(Pt::from_f32(v))
}
fn page_contains_text(page: &Page, needle: &str) -> bool {
page.commands.iter().any(|cmd| match cmd {
Command::DrawString { text, .. } => text.contains(needle),
_ => false,
})
}
fn empty_document(page_count: usize) -> Document {
Document {
page_size: Size::a4(),
pages: (0..page_count)
.map(|_| Page {
commands: Vec::new(),
})
.collect(),
}
}
#[test]
fn html_page_shrink_uses_scrollable_right_edge() {
let page_width = Pt::from_f32(252.0);
let overflow_right = Pt::from_f32(255.0);
let mut doc = Document {
page_size: Size {
width: page_width,
height: Pt::from_f32(72.0),
},
pages: vec![Page {
commands: vec![Command::Meta {
key: canvas::META_HTML_SCROLLABLE_RIGHT_KEY.to_string(),
value: overflow_right.to_milli_i64().to_string(),
}],
}],
};
apply_html_page_shrink_to_fit(&mut doc);
let Command::Scale(scale_x, scale_y) = doc.pages[0].commands[2] else {
panic!("expected a page shrink transform");
};
let expected = page_width.to_f32() / overflow_right.to_f32();
assert!((scale_x - expected).abs() < 1.0e-6);
assert!((scale_y - expected).abs() < 1.0e-6);
}
fn temp_log_path(tag: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"fullbleed_{tag}_{}_{}.jsonl",
std::process::id(),
nanos
))
}
fn repo_font_path(file_name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("python")
.join("fullbleed_assets")
.join("fonts")
.join(file_name)
}
fn count_token(haystack: &[u8], token: &[u8]) -> usize {
if token.is_empty() || haystack.len() < token.len() {
return 0;
}
haystack
.windows(token.len())
.filter(|window| *window == token)
.count()
}
#[test]
fn tagged_pdf_preserves_html_th_scope_row() {
let engine = FullBleed::builder()
.pdf_profile(PdfProfile::Tagged)
.build()
.expect("tagged engine");
let html = r#"
<!doctype html>
<html>
<body>
<table>
<tbody>
<tr>
<th scope="row">Name</th>
<td>Jane</td>
</tr>
</tbody>
</table>
</body>
</html>
"#;
let css = "table, th, td { border: 1px solid #000; }";
let pdf = engine
.render_to_buffer(html, css)
.expect("render tagged pdf");
assert!(
count_token(&pdf, b"/Scope /Row") >= 1,
"expected tagged PDF to contain TH scope derived from HTML scope=row"
);
}
#[test]
fn pdfx4_builder_requires_output_intent() {
let err = match FullBleed::builder().pdf_profile(PdfProfile::PdfX4).build() {
Ok(_) => panic!("pdfx4 should fail without output intent"),
Err(err) => err,
};
assert!(matches!(err, FullBleedError::InvalidConfiguration(_)));
assert!(err.to_string().contains("output_intent"));
}
#[test]
fn lazy_layout_requires_explicit_cost_acceptance() {
let err = match FullBleed::builder()
.layout_strategy(LayoutStrategy::Lazy)
.build()
{
Ok(_) => panic!("lazy layout should require explicit opt-in"),
Err(err) => err,
};
assert!(matches!(err, FullBleedError::InvalidConfiguration(_)));
assert!(err.to_string().contains("accept_lazy_layout_cost"));
}
#[test]
fn lazy_layout_limits_are_validated() {
let err = match FullBleed::builder()
.layout_strategy(LayoutStrategy::Lazy)
.accept_lazy_layout_cost(true)
.lazy_layout_limits(1, 50.0)
.build()
{
Ok(_) => panic!("lazy max passes must be >= 2"),
Err(err) => err,
};
assert!(matches!(err, FullBleedError::InvalidConfiguration(_)));
assert!(err.to_string().contains("lazy_max_passes"));
let err = match FullBleed::builder()
.layout_strategy(LayoutStrategy::Lazy)
.accept_lazy_layout_cost(true)
.lazy_layout_limits(4, 0.0)
.build()
{
Ok(_) => panic!("lazy budget must be positive"),
Err(err) => err,
};
assert!(matches!(err, FullBleedError::InvalidConfiguration(_)));
assert!(err.to_string().contains("lazy_budget_ms"));
}
#[test]
fn lazy_layout_configuration_builds_with_opt_in() {
FullBleed::builder()
.layout_strategy(LayoutStrategy::Lazy)
.accept_lazy_layout_cost(true)
.lazy_layout_limits(4, 50.0)
.build()
.expect("valid lazy config should build");
}
#[test]
fn repeated_css_reuses_compiled_render_context() {
let engine = FullBleed::builder().build().expect("engine");
let css = "@page { size: letter; margin: 0.5in } body { color: #123456 }";
let first = engine.build_render_context(css, Some(0));
let second = engine.build_render_context(css, Some(1));
assert!(Arc::ptr_eq(&first.resolver, &second.resolver));
assert!(Arc::ptr_eq(&first.page_templates, &second.page_templates));
assert_eq!(
engine
.render_context_cache
.lock()
.expect("render context cache")
.map
.len(),
1
);
}
#[test]
fn batch_writer_file_and_parallel_paths_dedupe_embedded_fonts() {
let inter_path = repo_font_path("Inter-Variable.ttf");
let inter_bytes = std::fs::read(&inter_path).expect("read inter");
let mut engine = FullBleed::builder().build().expect("engine");
let font_name = {
let registry = Arc::get_mut(&mut engine.font_registry).expect("unique registry");
registry
.register_bytes(inter_bytes, Some(inter_path.to_string_lossy().as_ref()))
.expect("register inter")
};
let css = format!(
"@page {{ size: 8.5in 11in; margin: 0.5in; }} body {{ margin: 0; font-family: '{}'; font-size: 12pt; }}",
font_name
);
let html_list = vec![
"<html><body><p>Record 1</p></body></html>".to_string(),
"<html><body><p>Record 2</p></body></html>".to_string(),
"<html><body><p>Record 3</p></body></html>".to_string(),
];
let jobs: Vec<(String, String)> = html_list
.iter()
.map(|html| (html.clone(), css.clone()))
.collect();
let mut seq_writer = Vec::new();
engine
.render_many_to_writer(&html_list, &css, &mut seq_writer)
.expect("render_many_to_writer");
assert_eq!(count_token(&seq_writer, b"/FontFile2"), 1);
let mut seq_css_writer = Vec::new();
engine
.render_many_to_writer_with_css(&jobs, &mut seq_css_writer)
.expect("render_many_to_writer_with_css");
assert_eq!(count_token(&seq_css_writer, b"/FontFile2"), 1);
let mut parallel_writer = Vec::new();
engine
.render_many_to_writer_parallel(&html_list, &css, &mut parallel_writer)
.expect("render_many_to_writer_parallel");
assert_eq!(count_token(¶llel_writer, b"/FontFile2"), 1);
let tmp_dir = std::env::temp_dir();
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let seq_path = tmp_dir.join(format!(
"fullbleed_batch_font_dedup_seq_{}_{}.pdf",
std::process::id(),
stamp
));
let parallel_path = tmp_dir.join(format!(
"fullbleed_batch_font_dedup_parallel_{}_{}.pdf",
std::process::id(),
stamp
));
engine
.render_many_to_file(&html_list, &css, &seq_path)
.expect("render_many_to_file");
engine
.render_many_to_file_parallel(&html_list, &css, ¶llel_path)
.expect("render_many_to_file_parallel");
let seq_file_bytes = std::fs::read(&seq_path).expect("read seq file");
let parallel_file_bytes = std::fs::read(¶llel_path).expect("read parallel file");
assert_eq!(count_token(&seq_file_bytes, b"/FontFile2"), 1);
assert_eq!(count_token(¶llel_file_bytes, b"/FontFile2"), 1);
let _ = std::fs::remove_file(seq_path);
let _ = std::fs::remove_file(parallel_path);
}
#[test]
fn watermark_ocg_semantics_wrap_commands() {
let mut spec = WatermarkSpec::text("CONFIDENTIAL");
spec.semantics = WatermarkSemantics::Ocg;
let resolver = style::StyleResolver::new("");
let commands = build_watermark_commands(
&spec,
Size::a4(),
1,
1,
None,
&resolver,
None,
None,
None,
false,
false,
);
assert!(matches!(
commands.first(),
Some(Command::BeginOptionalContent { name }) if name == WATERMARK_OCG_RESOURCE_NAME
));
assert!(matches!(
commands.get(1),
Some(Command::BeginArtifact { subtype: Some(subtype) }) if subtype == "Watermark"
));
assert!(matches!(
commands.iter().rev().nth(1),
Some(Command::EndMarkedContent)
));
assert!(matches!(commands.last(), Some(Command::EndMarkedContent)));
}
#[test]
fn watermark_text_applies_to_each_page() {
let base = empty_document(3);
let spec = WatermarkSpec::text("CONFIDENTIAL");
let resolver = style::StyleResolver::new("");
let wm = build_watermark_document(
&base, &spec, &resolver, None, None, None, None, false, false,
);
assert_eq!(wm.pages.len(), 3);
for page in &wm.pages {
assert!(page.commands.iter().any(
|cmd| matches!(cmd, Command::DrawString { text, .. } if text == "CONFIDENTIAL")
));
}
}
#[test]
fn watermark_image_applies_to_each_page() {
let base = empty_document(4);
let image_source = "examples/img/full_bleed-logo_small.png".to_string();
let spec = WatermarkSpec::image(image_source.clone());
let resolver = style::StyleResolver::new("");
let wm = build_watermark_document(
&base, &spec, &resolver, None, None, None, None, false, false,
);
assert_eq!(wm.pages.len(), 4);
for page in &wm.pages {
assert!(page.commands.iter().any(|cmd| {
matches!(
cmd,
Command::DrawImage { resource_id, .. } if resource_id == &image_source
)
}));
}
}
#[test]
fn watermark_text_uses_transform_compatible_y_coordinate() {
let spec = WatermarkSpec::text("WM");
let resolver = style::StyleResolver::new("");
let page_size = Size::a4();
let commands = build_watermark_commands(
&spec, page_size, 1, 1, None, &resolver, None, None, None, false, false,
);
let draw = commands.iter().find_map(|cmd| match cmd {
Command::DrawString { x, y, text } => Some((*x, *y, text.as_str())),
_ => None,
});
let (_x, y, text) = draw.expect("expected DrawString command");
assert_eq!(text, "WM");
assert_eq!(y, page_size.height - spec.font_size.mul_ratio(1, 2));
}
#[test]
fn watermark_image_uses_transform_compatible_y_coordinate() {
let spec = WatermarkSpec::image("missing-watermark-image.png");
let resolver = style::StyleResolver::new("");
let page_size = Size::a4();
let commands = build_watermark_commands(
&spec, page_size, 1, 1, None, &resolver, None, None, None, false, false,
);
let draw = commands.iter().find_map(|cmd| match cmd {
Command::DrawImage { y, height, .. } => Some((*y, *height)),
_ => None,
});
let (y, height) = draw.expect("expected DrawImage command");
assert_eq!(y, page_size.height - height);
}
#[test]
fn html_img_tag_emits_draw_image_command() {
let html = r#"
<!doctype html>
<html>
<body>
<img class="logo" src="examples/img/full_bleed-logo_small.png" alt="logo" />
</body>
</html>
"#;
let css = r#"
@page { size: 8.5in 11in; margin: 0.5in; }
.logo { width: 210px; height: 86px; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let mut found = false;
for page in &doc.pages {
if page.commands.iter().any(|cmd| {
matches!(cmd, Command::DrawImage { resource_id, .. } if resource_id == "examples/img/full_bleed-logo_small.png")
}) {
found = true;
break;
}
}
assert!(found, "expected <img> to emit DrawImage command");
}
#[test]
fn display_table_cells_share_a_single_row() {
let html = r#"
<!doctype html>
<html>
<body>
<div class="t"><span>AA</span><span>BB</span><span>CC</span></div>
</body>
</html>
"#;
let css = r#"
@page { size: 4in 4in; margin: 0.25in; }
body { margin: 0; font-size: 12px; line-height: 1.2; }
.t { display: table; width: 240px; border: 1px solid #000; }
.t > span { display: table-cell; padding: 2px 4px; border-right: 1px solid #000; }
.t > span:last-child { border-right: 0; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let mut aa: Option<(Pt, Pt)> = None;
let mut bb: Option<(Pt, Pt)> = None;
let mut cc: Option<(Pt, Pt)> = None;
for cmd in &page.commands {
if let Command::DrawString { text, x, y } = cmd {
if text.contains("AA") {
aa = Some((*x, *y));
} else if text.contains("BB") {
bb = Some((*x, *y));
} else if text.contains("CC") {
cc = Some((*x, *y));
}
}
}
let (aa_x, aa_y) = aa.expect("missing AA draw");
let (bb_x, bb_y) = bb.expect("missing BB draw");
let (cc_x, cc_y) = cc.expect("missing CC draw");
assert!((aa_y.to_f32() - bb_y.to_f32()).abs() < 1.0);
assert!((bb_y.to_f32() - cc_y.to_f32()).abs() < 1.0);
assert!(aa_x < bb_x && bb_x < cc_x);
}
#[test]
fn grid_display_contents_promotes_children_into_parent_tracks() {
let html = r#"
<!doctype html>
<html><body>
<div class="grid">
<div class="contents"><div>A</div><div>B</div></div>
<div>C</div>
</div>
</body></html>
"#;
let css = r#"
@page { size: 4in 2in; margin: 0; }
* { margin: 0; box-sizing: border-box; }
.grid {
display: grid;
grid-template-columns: 70px 70px 70px;
grid-template-rows: 60px;
width: 210px;
}
.contents { display: contents; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let mut positions = std::collections::HashMap::new();
for command in &page.commands {
if let Command::DrawString { text, x, y } = command {
let text = text.trim();
if matches!(text, "A" | "B" | "C") {
positions.insert(text.to_string(), (*x, *y));
}
}
}
let (a_x, a_y) = positions["A"];
let (b_x, b_y) = positions["B"];
let (c_x, c_y) = positions["C"];
assert!(
a_x < b_x && b_x < c_x,
"grid items must occupy three columns"
);
assert_eq!(a_y, b_y);
assert_eq!(b_y, c_y);
}
#[test]
fn heading_honors_display_block_on_inline_descendants() {
let html = r#"
<!doctype html>
<html>
<body>
<h1 class="title">
<span class="line">TITLELINE_TOP</span>
<span class="line">TITLELINE_BOTTOM</span>
</h1>
</body>
</html>
"#;
let css = r#"
@page { size: 4in 4in; margin: 0.25in; }
body { margin: 0; font-family: sans-serif; }
.title { margin: 0; font-size: 28px; line-height: 1.1; }
.title > .line { display: block; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let mut top_y: Option<Pt> = None;
let mut bottom_y: Option<Pt> = None;
let mut merged_line = false;
for cmd in &page.commands {
if let Command::DrawString { text, y, .. } = cmd {
if text.contains("TITLELINE_TOP TITLELINE_BOTTOM")
|| text.contains("TITLELINE_BOTTOM TITLELINE_TOP")
{
merged_line = true;
}
if text.contains("TITLELINE_TOP") {
top_y = Some(*y);
}
if text.contains("TITLELINE_BOTTOM") {
bottom_y = Some(*y);
}
}
}
assert!(
!merged_line,
"display:block spans inside heading must not collapse to one line"
);
let top_y = top_y.expect("expected top heading line draw command");
let bottom_y = bottom_y.expect("expected bottom heading line draw command");
assert!(
(top_y.to_f32() - bottom_y.to_f32()).abs() > 1.0,
"expected heading block lines at different y positions, got y={} and y={}",
top_y.to_f32(),
bottom_y.to_f32()
);
assert!(
bottom_y > top_y,
"expected second heading block line to render below first line"
);
}
#[test]
fn html_table_cell_block_children_preserve_vertical_flow() {
let html = r#"
<!doctype html>
<html>
<body>
<table class="t">
<tr>
<td>
<div>TBLTOPMARK</div>
<div>TBLBOTTOMMARK</div>
</td>
</tr>
</table>
</body>
</html>
"#;
let css = r#"
@page { size: 4in 4in; margin: 0.25in; }
body { margin: 0; font-size: 14px; line-height: 1.2; }
table.t { border-collapse: collapse; width: 220px; }
table.t td { border: 1px solid #000; padding: 2px; }
table.t td > div { display: block; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let mut top_y: Option<Pt> = None;
let mut bottom_y: Option<Pt> = None;
let mut merged_line = false;
for cmd in &page.commands {
if let Command::DrawString { text, y, .. } = cmd {
if text.contains("TBLTOPMARK TBLBOTTOMMARK")
|| text.contains("TBLBOTTOMMARK TBLTOPMARK")
{
merged_line = true;
}
if text.contains("TBLTOPMARK") {
top_y = Some(*y);
}
if text.contains("TBLBOTTOMMARK") {
bottom_y = Some(*y);
}
}
}
assert!(
!merged_line,
"table cell block descendants should not collapse into one text line"
);
let top_y = top_y.expect("expected top marker draw command");
let bottom_y = bottom_y.expect("expected bottom marker draw command");
assert!(
(top_y.to_f32() - bottom_y.to_f32()).abs() > 1.0,
"expected block descendants at different y positions, got y={} and y={}",
top_y.to_f32(),
bottom_y.to_f32()
);
assert!(
bottom_y > top_y,
"expected second block to render below first block"
);
}
#[test]
fn html_table_second_row_starts_after_multiline_first_row() {
let html = r#"
<!doctype html>
<html>
<body>
<table class="t">
<tr>
<td>
<div>ROW1A</div>
<div>ROW1B</div>
<div>ROW1C</div>
</td>
</tr>
<tr>
<td>ROW2ONLY</td>
</tr>
</table>
</body>
</html>
"#;
let css = r#"
@page { size: 4in 4in; margin: 0.25in; }
body { margin: 0; font-size: 14px; line-height: 1.2; }
table.t { border-collapse: collapse; width: 220px; }
table.t td { border: 1px solid #000; padding: 2px; }
table.t td > div { display: block; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let mut row1_last_y: Option<Pt> = None;
let mut row2_y: Option<Pt> = None;
for cmd in &page.commands {
if let Command::DrawString { text, y, .. } = cmd {
if text.contains("ROW1C") {
row1_last_y = Some(*y);
}
if text.contains("ROW2ONLY") {
row2_y = Some(*y);
}
}
}
let row1_last_y = row1_last_y.expect("expected ROW1C draw command");
let row2_y = row2_y.expect("expected ROW2ONLY draw command");
assert!(
row2_y > row1_last_y,
"expected second row to render below first row content, got row1={} row2={}",
row1_last_y.to_f32(),
row2_y.to_f32()
);
}
#[test]
fn html_table_colspan_preserves_following_column_alignment() {
let html = r#"
<!doctype html>
<html>
<body>
<table class="t">
<tr>
<td colspan="2">FULLSPAN HEADER</td>
</tr>
<tr>
<td>LEFT</td>
<td>RIGHT</td>
</tr>
</table>
</body>
</html>
"#;
let css = r#"
@page { size: 4in 4in; margin: 0.25in; }
body { margin: 0; font-size: 14px; line-height: 1.2; }
table.t { border-collapse: collapse; width: 300px; table-layout: fixed; }
table.t td { border: 1px solid #000; padding: 2px; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let mut left_x: Option<Pt> = None;
let mut right_x: Option<Pt> = None;
for cmd in &page.commands {
if let Command::DrawString { text, x, .. } = cmd {
if text == "LEFT" {
left_x = Some(*x);
}
if text == "RIGHT" {
right_x = Some(*x);
}
}
}
let left_x = left_x.expect("expected LEFT draw command");
let right_x = right_x.expect("expected RIGHT draw command");
let delta = right_x - left_x;
assert!(
delta > Pt::from_f32(50.0),
"expected RIGHT to be in a separate column, got delta={}",
delta.to_f32()
);
assert!(
delta < Pt::from_f32(220.0),
"colspan should not collapse following column to page edge, got delta={}",
delta.to_f32()
);
}
#[test]
fn html_table_layout_fixed_ignores_later_row_intrinsic_pressure() {
let html = r#"
<!doctype html>
<html>
<body>
<table class="t">
<tr><td>ROW1_LEFT</td><td>ROW1_RIGHT</td></tr>
<tr><td>SUPERCALIFRAGILISTICEXTRAORDINARILYLONGTOKEN</td><td>R2</td></tr>
</table>
</body>
</html>
"#;
let css_fixed = r#"
@page { size: 4in 4in; margin: 0.25in; }
body { margin: 0; font-size: 12px; line-height: 1.2; }
table.t { border-collapse: collapse; width: 300px; table-layout: fixed; }
table.t td { border: 0; padding: 0; }
"#;
let css_auto = r#"
@page { size: 4in 4in; margin: 0.25in; }
body { margin: 0; font-size: 12px; line-height: 1.2; }
table.t { border-collapse: collapse; width: 300px; table-layout: auto; }
table.t td { border: 0; padding: 0; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc_fixed = engine
.render_to_document(html, css_fixed)
.expect("render fixed document");
let page_fixed = doc_fixed.pages.first().expect("fixed page");
let mut right_fixed_x: Option<Pt> = None;
for cmd in &page_fixed.commands {
if let Command::DrawString { text, x, .. } = cmd {
if text == "R2" {
right_fixed_x = Some(*x);
}
}
}
let right_fixed_x = right_fixed_x.expect("expected R2 draw command in fixed layout");
let doc_auto = engine
.render_to_document(html, css_auto)
.expect("render auto document");
let page_auto = doc_auto.pages.first().expect("auto page");
let mut right_auto_x: Option<Pt> = None;
for cmd in &page_auto.commands {
if let Command::DrawString { text, x, .. } = cmd {
if text == "R2" {
right_auto_x = Some(*x);
}
}
}
let right_auto_x = right_auto_x.expect("expected R2 draw command in auto layout");
assert!(
right_auto_x > right_fixed_x + Pt::from_f32(15.0),
"expected fixed layout to keep the right column further left than auto layout, fixed={} auto={}",
right_fixed_x.to_f32(),
right_auto_x.to_f32()
);
}
#[test]
fn html_table_cell_height_sets_the_row_minimum() {
let html = r#"
<!doctype html>
<html>
<body>
<table><tr><td></td></tr></table>
</body>
</html>
"#;
let css = r#"
@page { size: 4in 4in; margin: 0; }
* { margin: 0; box-sizing: border-box; }
body { margin: 0; }
table { border-collapse: collapse; width: 120px; }
td { height: 60px; background: #a8dadc; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let mut fill = Color::BLACK;
let mut cell_height = None;
for command in &page.commands {
match command {
Command::SetFillColor(color) => fill = *color,
Command::DrawRect { height, .. }
if fill == Color::rgb(168.0 / 255.0, 218.0 / 255.0, 220.0 / 255.0) =>
{
cell_height = Some(*height);
break;
}
_ => {}
}
}
assert_eq!(cell_height, Some(Pt::from_f32(45.0)));
}
#[test]
fn html_table_column_and_row_group_backgrounds_propagate_into_transparent_cells() {
let html = r#"
<!doctype html><html><body><table>
<colgroup><col class="first"><col></colgroup>
<tbody><tr><td></td><td></td></tr></tbody>
</table></body></html>
"#;
let css = r#"
@page { size: 4in 4in; margin: 0; }
* { margin: 0; box-sizing: border-box; }
table { border-collapse: separate; border-spacing: 0; }
col.first { background: #e63946; }
tbody { background: #2a9d8f; }
td { width: 60px; height: 40px; background: transparent; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let row_group = Color::rgb(42.0 / 255.0, 157.0 / 255.0, 143.0 / 255.0);
let mut fill = Color::BLACK;
let mut row_group_rects = 0usize;
for command in &page.commands {
match command {
Command::SetFillColor(color) => fill = *color,
Command::DrawRect { .. } if fill == row_group => row_group_rects += 1,
_ => {}
}
}
assert_eq!(row_group_rects, 2);
}
#[test]
fn collapsed_table_appends_the_trailing_grid_borders() {
let html = r#"
<!doctype html>
<html><body><table>
<tr><td></td><td></td><td></td></tr>
<tr><td></td><td></td><td></td></tr>
</table></body></html>
"#;
let css = r#"
@page { size: 5in 5in; margin: 0; }
* { margin: 0; box-sizing: border-box; }
table { border-collapse: collapse; table-layout: fixed; width: 360px; }
td { width: 120px; height: 60px; border: 3px solid #1d3557; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let border_color = Color::rgb(29.0 / 255.0, 53.0 / 255.0, 87.0 / 255.0);
let mut fill = Color::BLACK;
let mut right = Pt::ZERO;
let mut bottom = Pt::ZERO;
for command in &page.commands {
match command {
Command::SetFillColor(color) => fill = *color,
Command::DrawRect {
x,
y,
width,
height,
} if fill == border_color => {
right = right.max(*x + *width);
bottom = bottom.max(*y + *height);
}
_ => {}
}
}
assert_eq!(right, Pt::from_f32(272.25));
assert_eq!(bottom, Pt::from_f32(92.25));
}
#[test]
fn html_table_rowspan_reserves_columns_and_spans_row_heights() {
let html = r#"
<!doctype html>
<html><body><table>
<tr><td class="span" rowspan="2"></td><td></td><td></td></tr>
<tr><td></td><td></td></tr>
</table></body></html>
"#;
let css = r#"
@page { size: 5in 5in; margin: 0; }
* { margin: 0; box-sizing: border-box; }
table { border-collapse: collapse; table-layout: fixed; width: 300px; }
td { width: 100px; height: 55px; border: 3px solid #003049; background: #d62828; }
td.span { background: #fcbf49; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let span_color = Color::rgb(252.0 / 255.0, 191.0 / 255.0, 73.0 / 255.0);
let cell_color = Color::rgb(214.0 / 255.0, 40.0 / 255.0, 40.0 / 255.0);
let mut fill = Color::BLACK;
let mut span_rect = None;
let mut second_row_cell = false;
for command in &page.commands {
match command {
Command::SetFillColor(color) => fill = *color,
Command::DrawRect {
x,
y,
width,
height,
} if fill == span_color => span_rect = Some((*x, *y, *width, *height)),
Command::DrawRect { x, y, .. }
if fill == cell_color
&& *x == Pt::from_f32(77.25)
&& *y == Pt::from_f32(43.5) =>
{
second_row_cell = true;
}
_ => {}
}
}
assert_eq!(
span_rect,
Some((Pt::ZERO, Pt::ZERO, Pt::from_f32(75.0), Pt::from_f32(82.5),))
);
assert!(
second_row_cell,
"the second row must start after the occupied rowspan column"
);
}
#[test]
fn html_table_rowspan_minimum_is_distributed_across_rows() {
let html = r#"
<!doctype html>
<html><body><table>
<tr><td class="span" rowspan="2"></td><td class="peer"></td></tr>
<tr><td class="peer"></td></tr>
</table></body></html>
"#;
let css = r#"
@page { size: 4in 4in; margin: 0; }
* { margin: 0; box-sizing: border-box; }
table { border-collapse: separate; border-spacing: 0; }
td { width: 64px; height: 28px; border: 2px solid #111; }
td.span { height: 100px; background: #e63946; }
td.peer { background: #2a9d8f; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let span_color = Color::rgb(230.0 / 255.0, 57.0 / 255.0, 70.0 / 255.0);
let peer_color = Color::rgb(42.0 / 255.0, 157.0 / 255.0, 143.0 / 255.0);
let mut fill = Color::BLACK;
let mut span_height = None;
let mut peer_rows = Vec::new();
for command in &page.commands {
match command {
Command::SetFillColor(color) => fill = *color,
Command::DrawRect { height, .. } if fill == span_color => {
span_height = Some(*height)
}
Command::DrawRect { y, .. } if fill == peer_color => peer_rows.push(*y),
_ => {}
}
}
assert_eq!(span_height, Some(Pt::from_f32(75.0)));
assert!(peer_rows.contains(&Pt::ZERO));
assert!(peer_rows.contains(&Pt::from_f32(37.5)));
}
#[test]
fn fixed_table_slack_is_shared_by_first_row_cells_across_colspans() {
let html = r#"
<!doctype html>
<html><body><table>
<tr><td class="big" colspan="2" rowspan="2"></td><td></td></tr>
<tr><td></td></tr>
<tr><td></td><td></td><td></td></tr>
</table></body></html>
"#;
let css = r#"
@page { size: 5in 5in; margin: 0; }
* { margin: 0; box-sizing: border-box; }
table { border-collapse: collapse; table-layout: fixed; width: 360px; }
td { width: 120px; height: 50px; border: 3px solid #1d3557; background: #a8dadc; }
td.big { background: #e63946; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let big_color = Color::rgb(230.0 / 255.0, 57.0 / 255.0, 70.0 / 255.0);
let mut fill = Color::BLACK;
let mut big_width = None;
for command in &page.commands {
match command {
Command::SetFillColor(color) => fill = *color,
Command::DrawRect { width, .. } if fill == big_color => {
big_width = Some(*width);
break;
}
_ => {}
}
}
assert_eq!(big_width, Some(Pt::from_f32(133.875)));
}
#[test]
fn html_auto_table_shrink_wraps_cells_and_distributes_table_height() {
let html = r#"
<!doctype html>
<html><body><table><tr><td></td><td></td></tr></table></body></html>
"#;
let css = r#"
@page { size: 4in 4in; margin: 0; }
* { margin: 0; box-sizing: border-box; }
table { height: 120px; border-collapse: separate; border-spacing: 0; }
td { width: 70px; height: 30px; border: 2px solid #111; background: #2a9d8f; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let cell_color = Color::rgb(42.0 / 255.0, 157.0 / 255.0, 143.0 / 255.0);
let mut fill = Color::BLACK;
let mut cells = Vec::new();
for command in &page.commands {
match command {
Command::SetFillColor(color) => fill = *color,
Command::DrawRect {
x,
y,
width,
height,
} if fill == cell_color => cells.push((*x, *y, *width, *height)),
_ => {}
}
}
assert_eq!(
cells,
vec![
(Pt::ZERO, Pt::ZERO, Pt::from_f32(52.5), Pt::from_f32(90.0),),
(
Pt::from_f32(52.5),
Pt::ZERO,
Pt::from_f32(52.5),
Pt::from_f32(90.0),
),
]
);
}
#[test]
fn html_legacy_cellspacing_and_cellpadding_attributes_affect_the_grid() {
let engine = FullBleed::builder().build().expect("engine");
let spacing_doc = engine
.render_to_document(
r#"<!doctype html><html><body><table cellspacing="14"><tr><td class="a"></td><td class="b"></td></tr></table></body></html>"#,
r#"
@page { size: 4in 4in; margin: 0; }
* { margin: 0; box-sizing: border-box; }
table { background: #111; }
td { width: 50px; height: 42px; padding: 0; border: 0; }
.a { background: #e63946; }
.b { background: #2a9d8f; }
"#,
)
.expect("render cellspacing document");
let page = spacing_doc.pages.first().expect("spacing page");
let first_color = Color::rgb(230.0 / 255.0, 57.0 / 255.0, 70.0 / 255.0);
let second_color = Color::rgb(42.0 / 255.0, 157.0 / 255.0, 143.0 / 255.0);
let mut fill = Color::BLACK;
let mut first_x = None;
let mut second_x = None;
for command in &page.commands {
match command {
Command::SetFillColor(color) => fill = *color,
Command::DrawRect { x, .. } if fill == first_color => first_x = Some(*x),
Command::DrawRect { x, .. } if fill == second_color => second_x = Some(*x),
_ => {}
}
}
assert_eq!(first_x, Some(Pt::from_f32(10.5)));
assert_eq!(second_x, Some(Pt::from_f32(58.5)));
let padding_doc = engine
.render_to_document(
r#"<!doctype html><html><body><table cellpadding="16"><tr><td><div class="mark"></div></td></tr></table></body></html>"#,
r#"
@page { size: 4in 4in; margin: 0; }
* { margin: 0; box-sizing: border-box; }
table { border-collapse: separate; border-spacing: 0; }
td { width: 80px; height: 60px; border: 2px solid #111; }
.mark { width: 28px; height: 20px; background: #e63946; }
"#,
)
.expect("render cellpadding document");
let page = padding_doc.pages.first().expect("padding page");
let mut fill = Color::BLACK;
let mut marker_x = None;
for command in &page.commands {
match command {
Command::SetFillColor(color) => fill = *color,
Command::DrawRect { x, .. } if fill == first_color => marker_x = Some(*x),
_ => {}
}
}
assert_eq!(marker_x, Some(Pt::from_f32(13.5)));
}
#[test]
fn html_legacy_border_attribute_builds_beveled_table_and_cell_edges() {
let html = r#"<!doctype html><html><body><table border="4"><tr><td class="a"></td><td class="b"></td></tr></table></body></html>"#;
let css = r#"
@page { size: 4in 4in; margin: 0; }
* { margin: 0; box-sizing: border-box; }
td { width: 64px; height: 44px; }
.a { background: #e63946; }
.b { background: #2a9d8f; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let light = Color::rgb(238.0 / 255.0, 238.0 / 255.0, 238.0 / 255.0);
let dark = Color::rgb(154.0 / 255.0, 154.0 / 255.0, 154.0 / 255.0);
let mut fill = Color::BLACK;
let mut light_seen = false;
let mut dark_seen = false;
let mut right = Pt::ZERO;
let mut bottom = Pt::ZERO;
for command in &page.commands {
match command {
Command::SetFillColor(color) => fill = *color,
Command::DrawRect {
x,
y,
width,
height,
} => {
if fill == light {
light_seen = true;
}
if fill == dark {
dark_seen = true;
}
right = right.max(*x + *width);
bottom = bottom.max(*y + *height);
}
_ => {}
}
}
assert!(light_seen && dark_seen);
assert_eq!(right, Pt::from_f32(106.5));
assert_eq!(bottom, Pt::from_f32(42.0));
}
#[test]
fn list_item_block_children_preserve_vertical_flow() {
let html = r#"
<!doctype html>
<html>
<body>
<ul class="menu">
<li>
<div class="title">ITEMHEADONLY</div>
<div class="desc">ITEMDESCONLY</div>
</li>
</ul>
</body>
</html>
"#;
let css = r#"
@page { size: 4in 4in; margin: 0.25in; }
body { margin: 0; font-size: 14px; line-height: 1.2; }
ul, li { margin: 0; padding: 0; list-style: none; }
.title, .desc { display: block; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let mut title_y: Option<Pt> = None;
let mut desc_y: Option<Pt> = None;
let mut merged_line = false;
for cmd in &page.commands {
if let Command::DrawString { text, y, .. } = cmd {
if text.contains("ITEMHEADONLY ITEMDESCONLY")
|| text.contains("ITEMDESCONLY ITEMHEADONLY")
{
merged_line = true;
}
if text.contains("ITEMHEADONLY") {
title_y = Some(*y);
}
if text.contains("ITEMDESCONLY") {
desc_y = Some(*y);
}
}
}
assert!(
!merged_line,
"list-item block children should not collapse into one line"
);
let title_y = title_y.expect("expected title draw command");
let desc_y = desc_y.expect("expected description draw command");
assert!(
(title_y.to_f32() - desc_y.to_f32()).abs() > 1.0,
"expected list-item block children on separate lines, got y={} and y={}",
title_y.to_f32(),
desc_y.to_f32()
);
}
#[test]
fn generated_counter_prefix_does_not_narrow_following_inline_text() {
let html = r#"
<!doctype html>
<html><body>
<div class="row">minus two</div>
<div class="row">minus one</div>
<div class="row">zero</div>
</body></html>
"#;
let css = r#"
@page { size: 336px 160px; margin: 0; }
html { font-family: Inter; line-height: 1.5; }
* { margin: 0; box-sizing: border-box; }
@counter-style negwrap {
system: extends decimal;
negative: '(' ')';
suffix: ' ';
}
body { padding: 12px; counter-reset: n -3; }
.row {
counter-increment: n;
font-size: 22px;
line-height: 32px;
margin-bottom: 4px;
}
.row::before { content: counter(n, negwrap); font-weight: bold; }
"#;
let engine = FullBleed::builder()
.register_font_file(repo_font_path("Inter-Variable.ttf"))
.build()
.expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
let page = doc.pages.first().expect("page");
let draws: Vec<_> = page
.commands
.iter()
.filter_map(|command| match command {
Command::DrawString { text, x, y } => Some((text.as_str(), *x, *y)),
_ => None,
})
.collect();
assert!(
draws.iter().any(|(text, _, _)| text.contains("minus two")),
"the first row must remain a single inline run: {draws:?}"
);
assert!(
draws.iter().any(|(text, _, _)| text.contains("minus one")),
"the second row must remain a single inline run: {draws:?}"
);
}
#[test]
fn css_page_size_applies_when_builder_page_size_is_default() {
let html = "<!doctype html><html><body><p>hello</p></body></html>";
let css = "@page { size: letter; margin: 0.5in; }";
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
assert!((doc.page_size.width.to_f32() - 612.0).abs() < 0.01);
assert!((doc.page_size.height.to_f32() - 792.0).abs() < 0.01);
}
#[test]
fn css_page_pseudo_classes_select_physical_margins_and_background() {
let css = r#"
@page { size: 192px 200px; margin: 16px; background: #ffd6d6; }
@page :left { margin: 8px 32px 24px 8px; }
@page :right { margin: 16px 8px 8px 32px; }
@page :first { margin: 32px 8px 16px 24px; }
html, body { background: #ffffff; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let templates = engine.resolve_page_templates_for_css(css, None);
let frame_for = |selector| {
templates
.iter()
.find(|template| template.page_selector() == selector)
.expect("page selector template")
.instantiate_frames()[0]
.rect()
};
let first = frame_for(PageSelector::First);
assert_eq!(first.x, Pt::from_f32(18.0));
assert_eq!(first.y, Pt::from_f32(24.0));
assert_eq!(first.width, Pt::from_f32(120.0));
assert_eq!(first.height, Pt::from_f32(114.0));
let left = frame_for(PageSelector::Left);
assert_eq!(left.x, Pt::from_f32(6.0));
assert_eq!(left.y, Pt::from_f32(6.0));
assert_eq!(left.width, Pt::from_f32(114.0));
assert_eq!(left.height, Pt::from_f32(126.0));
let right = frame_for(PageSelector::Right);
assert_eq!(right.x, Pt::from_f32(24.0));
assert_eq!(right.y, Pt::from_f32(12.0));
assert_eq!(right.width, Pt::from_f32(114.0));
assert_eq!(right.height, Pt::from_f32(132.0));
let html = r#"
<div style="break-after: page">one</div>
<div style="break-after: page">two</div>
<div>three</div>
"#;
let doc = engine.render_to_document(html, css).expect("render");
assert_eq!(doc.pages.len(), 3);
let names: Vec<_> = doc
.pages
.iter()
.map(|page| {
page.commands
.iter()
.find_map(|command| match command {
Command::Meta { key, value } if key == META_PAGE_TEMPLATE_KEY => {
Some(value.as_str())
}
_ => None,
})
.expect("page template metadata")
})
.collect();
assert_eq!(names, ["First", "Left", "Right"]);
assert!(doc.pages.iter().all(|page| {
page.commands.iter().any(|command| {
matches!(
command,
Command::DrawRect { x, y, width, height }
if *x == Pt::ZERO
&& *y == Pt::ZERO
&& *width == Pt::from_f32(144.0)
&& *height == Pt::from_f32(150.0)
)
})
}));
assert!(doc.pages[2].commands.iter().any(|command| {
matches!(
command,
Command::DrawRect { x, y, width, height }
if *x == Pt::from_f32(24.0)
&& *y == Pt::from_f32(12.0)
&& *width == Pt::from_f32(114.0)
&& *height == Pt::from_f32(132.0)
)
}));
}
#[test]
fn generated_before_and_after_are_flex_items_in_source_order() {
let html = "<html><body><div class='row'><span>M</span></div></body></html>";
let css = r#"
@page { size: 200px 100px; margin: 0; }
body { margin: 0; }
.row { display: flex; width: 120px; gap: 8px; }
.row::before { content: 'L'; }
.row::after { content: 'R'; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine.render_to_document(html, css).expect("render");
let draws: Vec<_> = doc.pages[0]
.commands
.iter()
.filter_map(|command| match command {
Command::DrawString { text, x, .. } if matches!(text.as_str(), "L" | "M" | "R") => {
Some((text.as_str(), *x))
}
_ => None,
})
.collect();
assert_eq!(draws.len(), 3, "expected both pseudo flex items: {draws:?}");
assert_eq!(
draws.iter().map(|(text, _)| *text).collect::<Vec<_>>(),
["L", "M", "R"]
);
assert!(draws[0].1 < draws[1].1 && draws[1].1 < draws[2].1);
}
#[test]
fn definite_flex_item_width_caps_its_automatic_minimum() {
let html =
"<html><body><div class='row'><div class='item'>123456789</div></div></body></html>";
let css = r#"
@page { size: 200px 100px; margin: 0; }
* { box-sizing: border-box; }
body { margin: 0; }
.row { display: flex; width: 108px; }
.item { width: 58px; padding: 5px; border: 2px solid #000; background: #e7f5ff; }
"#;
let engine = FullBleed::builder().build().expect("engine");
let doc = engine.render_to_document(html, css).expect("render");
assert!(doc.pages[0].commands.iter().any(|command| {
matches!(
command,
Command::DrawRect { width, .. } if *width == Pt::from_f32(43.5)
)
}));
}
#[test]
fn explicit_builder_page_size_logs_page_size_overridden() {
let log_path = temp_log_path("page_size_override");
let html = "<!doctype html><html><body><p>hello</p></body></html>";
let css = "@page { size: letter; }";
let engine = FullBleed::builder()
.page_size(Size::a4())
.debug_log(&log_path)
.build()
.expect("engine");
let _ = engine
.render_to_document(html, css)
.expect("render document");
drop(engine);
let log = std::fs::read_to_string(&log_path).expect("read debug log");
assert!(log.contains("\"PAGE_SIZE_OVERRIDDEN\""));
let _ = std::fs::remove_file(log_path);
}
#[test]
fn pagination_emits_page_break_trigger_event() {
let log_path = temp_log_path("page_break_trigger");
let html = "<!doctype html><html><body><p>one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty.</p><p>one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty.</p><p>one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty.</p></body></html>";
let css = "body { margin: 0; font-size: 14px; line-height: 1.2; }";
let engine = FullBleed::builder()
.page_size(Size::from_inches(3.0, 3.0))
.margin_all(18.0)
.debug_log(&log_path)
.build()
.expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
assert!(doc.pages.len() > 1);
drop(engine);
let log = std::fs::read_to_string(&log_path).expect("read debug log");
assert!(log.contains("\"PAGE_BREAK_TRIGGER\""));
let _ = std::fs::remove_file(log_path);
}
#[test]
fn html_table_row_collection_excludes_nested_tables() {
let log_path = temp_log_path("table_row_scope");
let html = r#"
<!doctype html>
<html>
<body>
<table class="outer">
<tr>
<td>
<div>Outer left</div>
<table class="inner">
<tr>
<td>Inner A</td>
<td>Inner B</td>
<td>Inner C</td>
</tr>
</table>
</td>
<td>Outer right</td>
</tr>
</table>
</body>
</html>
"#;
let css = r#"
body { margin: 0; font-size: 10pt; }
table { border-collapse: collapse; width: 100%; }
td { border: 1px solid #333; padding: 2pt; }
"#;
let engine = FullBleed::builder()
.page_size(Size::from_inches(4.0, 4.0))
.margin_all(0.0)
.debug_log(&log_path)
.build()
.expect("engine");
let _doc = engine
.render_to_document(html, css)
.expect("render document");
drop(engine);
let log = std::fs::read_to_string(&log_path).expect("read debug log");
let table_rows_events: Vec<&str> = log
.lines()
.filter(|line| line.contains("\"type\":\"table.rows\""))
.collect();
assert!(
table_rows_events.len() >= 2,
"expected outer and inner table row events, got {}",
table_rows_events.len()
);
assert!(
table_rows_events[0].contains("\"total\":1"),
"outer table should only collect direct rows: {}",
table_rows_events[0]
);
let _ = std::fs::remove_file(log_path);
}
#[test]
fn paragraph_split_produces_two_parts() {
let text = "one two three four five six seven eight nine ten eleven twelve";
let mut style = TextStyle::default();
style.font_size = Pt::from_f32(10.0);
style.line_height = Pt::from_f32(12.0);
let paragraph = Paragraph::new(text)
.with_style(style)
.with_pagination(Pagination {
orphans: 1,
widows: 1,
..Pagination::default()
});
let avail_width = Pt::from_f32(60.0);
let avail_height = Pt::from_f32(24.0);
let split = paragraph.split(avail_width, avail_height);
assert!(split.is_some());
let (first, second) = split.unwrap();
let first_size = first.wrap(avail_width, avail_height);
let second_size = second.wrap(avail_width, avail_height);
assert!(first_size.height <= avail_height);
assert!(second_size.height > Pt::ZERO);
}
#[test]
fn frame_overflows_on_extra_spacer() {
let mut frame = Frame::new(Rect {
x: Pt::ZERO,
y: Pt::ZERO,
width: Pt::from_f32(200.0),
height: Pt::from_f32(50.0),
});
let mut canvas = Canvas::new(Size {
width: Pt::from_f32(200.0),
height: Pt::from_f32(200.0),
});
for _ in 0..10 {
let result = frame.add(Box::new(Spacer::new_pt(Pt::from_f32(5.0))), &mut canvas);
assert!(matches!(result, AddResult::Placed(_)));
}
let result = frame.add(Box::new(Spacer::new_pt(Pt::from_f32(5.0))), &mut canvas);
assert!(matches!(result, AddResult::Overflow(_, _)));
}
#[test]
fn table_split_repeats_header_when_enabled() {
let header = vec![vec![table_cell("HDR_ID"), table_cell("HDR_NAME")]];
let body: Vec<Vec<TableCell>> = (1..=20)
.map(|i| vec![table_cell(&i.to_string()), table_cell(&format!("row-{i}"))])
.collect();
let table = TableFlowable::new(body)
.with_header(header)
.repeat_header(true);
let frame_rect = Rect {
x: Pt::ZERO,
y: Pt::ZERO,
width: Pt::from_f32(300.0),
height: Pt::from_f32(72.0),
};
let mut frame1 = Frame::new(frame_rect);
let mut canvas = Canvas::new(Size {
width: Pt::from_f32(300.0),
height: Pt::from_f32(200.0),
});
let second = match frame1.add(Box::new(table), &mut canvas) {
AddResult::Split(rest, _) => rest,
other => panic!(
"expected split, got variant {:?}",
std::mem::discriminant(&other)
),
};
canvas.show_page();
let mut frame2 = Frame::new(frame_rect);
let result2 = frame2.add(second, &mut canvas);
assert!(matches!(
result2,
AddResult::Placed(_) | AddResult::Split(_, _)
));
let doc = canvas.finish();
assert!(doc.pages.len() >= 2);
assert!(page_contains_text(&doc.pages[0], "HDR_ID"));
assert!(page_contains_text(&doc.pages[1], "HDR_ID"));
}
#[test]
fn table_split_does_not_repeat_header_when_disabled() {
let header = vec![vec![table_cell("HDR_OFF"), table_cell("HDR_OFF_NAME")]];
let body: Vec<Vec<TableCell>> = (1..=20)
.map(|i| vec![table_cell(&i.to_string()), table_cell(&format!("row-{i}"))])
.collect();
let table = TableFlowable::new(body)
.with_header(header)
.repeat_header(false);
let frame_rect = Rect {
x: Pt::ZERO,
y: Pt::ZERO,
width: Pt::from_f32(300.0),
height: Pt::from_f32(72.0),
};
let mut frame1 = Frame::new(frame_rect);
let mut canvas = Canvas::new(Size {
width: Pt::from_f32(300.0),
height: Pt::from_f32(200.0),
});
let second = match frame1.add(Box::new(table), &mut canvas) {
AddResult::Split(rest, _) => rest,
other => panic!(
"expected split, got variant {:?}",
std::mem::discriminant(&other)
),
};
canvas.show_page();
let mut frame2 = Frame::new(frame_rect);
let result2 = frame2.add(second, &mut canvas);
assert!(matches!(
result2,
AddResult::Placed(_) | AddResult::Split(_, _)
));
let doc = canvas.finish();
assert!(doc.pages.len() >= 2);
assert!(page_contains_text(&doc.pages[0], "HDR_OFF"));
assert!(!page_contains_text(&doc.pages[1], "HDR_OFF"));
}
#[test]
fn collapsed_border_prefers_wider_adjacent_edge_color() {
let left = table_cell_with_border(
"A",
EdgeSizes {
top: abs(0.0),
right: abs(1.0),
bottom: abs(0.0),
left: abs(0.0),
},
Color::rgb(1.0, 0.0, 0.0),
);
let right = table_cell_with_border(
"B",
EdgeSizes {
top: abs(0.0),
right: abs(0.0),
bottom: abs(0.0),
left: abs(4.0),
},
Color::rgb(0.0, 0.0, 1.0),
);
let table = TableFlowable::new(vec![vec![left, right]])
.with_border_collapse(BorderCollapseMode::Collapse);
let mut frame = Frame::new(Rect {
x: Pt::ZERO,
y: Pt::ZERO,
width: Pt::from_f32(200.0),
height: Pt::from_f32(60.0),
});
let mut canvas = Canvas::new(Size {
width: Pt::from_f32(200.0),
height: Pt::from_f32(60.0),
});
let result = frame.add(Box::new(table), &mut canvas);
assert!(matches!(result, AddResult::Placed(_)));
let doc = canvas.finish();
let page = &doc.pages[0];
let mut current_fill = Color::BLACK;
let mut found_winner_edge = false;
for command in &page.commands {
match command {
Command::SetFillColor(color) => current_fill = *color,
Command::DrawRect { width, .. } => {
if *width == Pt::from_f32(4.0) && current_fill == Color::rgb(0.0, 0.0, 1.0) {
found_winner_edge = true;
break;
}
}
_ => {}
}
}
assert!(
found_winner_edge,
"expected 4pt shared border drawn in blue"
);
}
#[test]
fn rendered_pages_emit_page_template_meta_for_finalize_binding() {
let html = "<!doctype html><html><body><p>hello</p></body></html>";
let css = "@page { size: letter; margin: 0.5in; }";
let engine = FullBleed::builder().build().expect("engine");
let doc = engine
.render_to_document(html, css)
.expect("render document");
assert!(!doc.pages.is_empty(), "expected at least one page");
for page in &doc.pages {
let has_template_meta = page.commands.iter().any(|cmd| {
matches!(
cmd,
Command::Meta { key, value }
if key == META_PAGE_TEMPLATE_KEY && !value.trim().is_empty()
)
});
assert!(
has_template_meta,
"expected page to include {} metadata",
META_PAGE_TEMPLATE_KEY
);
}
}
#[test]
fn template_binding_accepts_feature_meta_from_plain_div_data_fb() {
let html = r#"
<!doctype html>
<html>
<body>
<section>
<div data-fb="fb.feature.red=1"></div>
<p>Page one marker</p>
</section>
<section style="page-break-before: always;">
<div data-fb="fb.feature.green=1"></div>
<p>Page two marker</p>
</section>
</body>
</html>
"#;
let css = "@page { size: letter; margin: 0.5in; }";
let mut spec = TemplateBindingSpec::default();
spec.default_template_id = Some("tpl-default".to_string());
spec.by_feature = std::collections::BTreeMap::from([
("red".to_string(), "tpl-red".to_string()),
("green".to_string(), "tpl-green".to_string()),
]);
let engine = FullBleed::builder()
.template_binding_spec(spec)
.build()
.expect("engine");
let (_pdf, _page_data, bindings) = engine
.render_with_page_data_and_template_bindings(html, css)
.expect("render");
let bindings = bindings.expect("expected bindings");
assert_eq!(bindings.len(), 2, "expected two pages");
assert_eq!(bindings[0].template_id, "tpl-red");
assert_eq!(bindings[0].source, BindingSource::Feature);
assert_eq!(bindings[1].template_id, "tpl-green");
assert_eq!(bindings[1].source, BindingSource::Feature);
}
#[test]
fn template_binding_honors_page_break_inside_transparent_div_wrapper() {
let html = r#"
<!doctype html>
<html>
<body>
<div class="ui-stack">
<section>
<div data-fb="fb.feature.red=1"></div>
<p>Page one marker</p>
</section>
<section style="page-break-before: always;">
<div data-fb="fb.feature.green=1"></div>
<p>Page two marker</p>
</section>
</div>
</body>
</html>
"#;
let css = "@page { size: letter; margin: 0.5in; }";
let mut spec = TemplateBindingSpec::default();
spec.default_template_id = Some("tpl-default".to_string());
spec.by_feature = std::collections::BTreeMap::from([
("red".to_string(), "tpl-red".to_string()),
("green".to_string(), "tpl-green".to_string()),
]);
let engine = FullBleed::builder()
.template_binding_spec(spec)
.build()
.expect("engine");
let (_pdf, _page_data, bindings) = engine
.render_with_page_data_and_template_bindings(html, css)
.expect("render");
let bindings = bindings.expect("expected bindings");
assert_eq!(bindings.len(), 2, "expected two pages");
assert_eq!(bindings[0].template_id, "tpl-red");
assert_eq!(bindings[0].source, BindingSource::Feature);
assert_eq!(bindings[1].template_id, "tpl-green");
assert_eq!(bindings[1].source, BindingSource::Feature);
}
#[test]
fn template_binding_accepts_feature_meta_from_metadata_only_div_pages() {
let html = r#"
<!doctype html>
<html>
<body>
<section>
<div data-fb="fb.feature.red=1"></div>
</section>
<section style="page-break-before: always;">
<div data-fb="fb.feature.green=1"></div>
</section>
</body>
</html>
"#;
let css = "@page { size: letter; margin: 0.5in; }";
let mut spec = TemplateBindingSpec::default();
spec.default_template_id = Some("tpl-default".to_string());
spec.by_feature = std::collections::BTreeMap::from([
("red".to_string(), "tpl-red".to_string()),
("green".to_string(), "tpl-green".to_string()),
]);
let engine = FullBleed::builder()
.template_binding_spec(spec)
.build()
.expect("engine");
let (_pdf, _page_data, bindings) = engine
.render_with_page_data_and_template_bindings(html, css)
.expect("render");
let bindings = bindings.expect("expected bindings");
assert_eq!(bindings.len(), 2, "expected two pages");
assert_eq!(bindings[0].template_id, "tpl-red");
assert_eq!(bindings[0].source, BindingSource::Feature);
assert_eq!(bindings[1].template_id, "tpl-green");
assert_eq!(bindings[1].source, BindingSource::Feature);
}
#[test]
fn render_with_page_data_and_glyph_report_smoke() {
let html = "<!doctype html><html><body><p>hello</p></body></html>";
let css = "@page { size: letter; margin: 0.5in; }";
let engine = FullBleed::builder().build().expect("engine");
let (pdf, page_data, report) = engine
.render_with_page_data_and_glyph_report(html, css)
.expect("render");
assert!(
!pdf.is_empty(),
"expected pdf bytes from combined page_data+glyph report render"
);
assert!(page_data.is_none(), "no page_data expected without context");
assert!(
report.is_empty(),
"expected no missing glyphs for simple ascii sample"
);
}
#[test]
fn render_with_page_data_template_bindings_and_glyph_report_smoke() {
let html = r#"
<!doctype html>
<html>
<body>
<section>
<div data-fb="fb.feature.red=1"></div>
<p>Page one marker</p>
</section>
<section style="page-break-before: always;">
<div data-fb="fb.feature.green=1"></div>
<p>Page two marker</p>
</section>
</body>
</html>
"#;
let css = "@page { size: letter; margin: 0.5in; }";
let mut spec = TemplateBindingSpec::default();
spec.default_template_id = Some("tpl-default".to_string());
spec.by_feature = std::collections::BTreeMap::from([
("red".to_string(), "tpl-red".to_string()),
("green".to_string(), "tpl-green".to_string()),
]);
let engine = FullBleed::builder()
.template_binding_spec(spec)
.build()
.expect("engine");
let (pdf, _page_data, bindings, report) = engine
.render_with_page_data_and_template_bindings_and_glyph_report(html, css)
.expect("render");
assert!(
!pdf.is_empty(),
"expected pdf bytes from combined bindings+glyph report render"
);
assert!(
report.is_empty(),
"expected no missing glyphs for simple ascii sample"
);
let bindings = bindings.expect("expected bindings");
assert_eq!(bindings.len(), 2, "expected two pages");
assert_eq!(bindings[0].template_id, "tpl-red");
assert_eq!(bindings[1].template_id, "tpl-green");
}
#[test]
fn render_to_buffer_pdf_bytes_are_deterministic() {
let html =
"<!doctype html><html><body><h1>Determinism</h1><p>alpha beta gamma</p></body></html>";
let css = "@page { size: letter; margin: 0.5in; } body { margin: 0; font-size: 12pt; }";
let bytes_a = FullBleed::builder()
.build()
.expect("engine a")
.render_to_buffer(html, css)
.expect("render a");
let bytes_b = FullBleed::builder()
.build()
.expect("engine b")
.render_to_buffer(html, css)
.expect("render b");
assert_eq!(
bytes_a, bytes_b,
"render_to_buffer should be byte deterministic for identical input"
);
}
#[test]
fn compiled_document_skips_frontend_and_links_deterministically() {
let engine = FullBleed::builder().build().expect("engine");
let html = "<!doctype html><html><body><h1>Compiled</h1><p>fixed point</p></body></html>";
let css = "@page { size: letter; margin: 0.5in; } body { margin: 0; font-size: 12pt; }";
let expected = engine.render_to_buffer(html, css).expect("ordinary render");
let compiled = engine
.compile_document(html, css)
.expect("compile document");
assert_eq!(compiled.page_count(), 1);
assert!(compiled.command_count() > 0);
let first = compiled.render_to_buffer().expect("compiled render");
let second = compiled.render_to_buffer().expect("compiled rerender");
assert_eq!(first, expected);
assert_eq!(second, expected);
let batch = compiled.render_many_to_buffer(3).expect("compiled batch");
let inspection = inspect_pdf_bytes(&batch).expect("inspect compiled batch");
assert_eq!(inspection.page_count, 3);
let parsed = crate::pdf_native::Document::load_mem(&batch).expect("parse compiled batch");
let content_ids: std::collections::BTreeSet<_> = parsed
.get_pages()
.values()
.map(|page_id| {
parsed
.get_object(*page_id)
.and_then(crate::pdf_native::Object::as_dict)
.and_then(|page| page.get(b"Contents"))
.and_then(crate::pdf_native::Object::as_reference)
.expect("page content reference")
})
.collect();
assert_eq!(content_ids.len(), 1, "compiled copies share one stream");
assert!(matches!(
compiled.render_many_to_buffer(0),
Err(FullBleedError::EmptyDocumentSet)
));
}
#[test]
fn compiled_tagged_copies_keep_page_specific_content_streams() {
let engine = FullBleed::builder()
.pdf_profile(PdfProfile::Tagged)
.build()
.expect("tagged engine");
let compiled = engine
.compile_document("<main><p>Tagged copy</p></main>", "")
.expect("compile tagged document");
let batch = compiled.render_many_to_buffer(2).expect("tagged batch");
let parsed = crate::pdf_native::Document::load_mem(&batch).expect("parse tagged batch");
let content_ids: std::collections::BTreeSet<_> = parsed
.get_pages()
.values()
.map(|page_id| {
parsed
.get_object(*page_id)
.and_then(crate::pdf_native::Object::as_dict)
.and_then(|page| page.get(b"Contents"))
.and_then(crate::pdf_native::Object::as_reference)
.expect("tagged page content reference")
})
.collect();
assert_eq!(content_ids.len(), 2);
assert_eq!(count_token(&batch, b"/StructParents "), 2);
}
#[test]
fn render_many_parallel_pdf_bytes_are_deterministic_across_thread_counts() {
let engine = FullBleed::builder().build().expect("engine");
let css = "@page { size: letter; margin: 0.5in; } body { margin: 0; font-size: 12pt; }";
let html_list = vec![
"<!doctype html><html><body><p>Row 1</p></body></html>".to_string(),
"<!doctype html><html><body><p>Row 2</p></body></html>".to_string(),
"<!doctype html><html><body><p>Row 3</p></body></html>".to_string(),
];
let render_with_threads = |threads: usize| -> Vec<u8> {
crate::parallel::with_thread_count(threads, || {
let mut out = Vec::new();
engine
.render_many_to_writer_parallel(&html_list, css, &mut out)
.expect("parallel render");
out
})
};
let bytes_1 = render_with_threads(1);
let bytes_4 = render_with_threads(4);
assert_eq!(
bytes_1, bytes_4,
"parallel PDF output should be byte deterministic across thread counts"
);
}
#[test]
fn render_image_pages_png_bytes_are_deterministic() {
let engine = FullBleed::builder().build().expect("engine");
let html = "<!doctype html><html><body><h1>PNG Determinism</h1><p>same input same output</p></body></html>";
let css = "@page { size: 6in 4in; margin: 0.25in; } body { margin: 0; font-size: 12pt; }";
let pages_a = engine
.render_image_pages(html, css, 120)
.expect("image render a");
let pages_b = engine
.render_image_pages(html, css, 120)
.expect("image render b");
assert_eq!(
pages_a, pages_b,
"render_image_pages should be byte deterministic for identical input"
);
}
#[test]
fn render_finalized_pdf_image_pages_png_bytes_are_deterministic() {
let engine = FullBleed::builder().build().expect("engine");
let html = "<!doctype html><html><body><h1>Finalize PNG Determinism</h1><p>stable</p></body></html>";
let css = "@page { size: 6in 4in; margin: 0.25in; } body { margin: 0; font-size: 12pt; }";
let pdf = engine.render_to_buffer(html, css).expect("render pdf");
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pdf_path = std::env::temp_dir().join(format!(
"fullbleed_finalize_png_determinism_{}_{}.pdf",
std::process::id(),
stamp
));
std::fs::write(&pdf_path, &pdf).expect("write temp pdf");
let pages_a = engine
.render_finalized_pdf_image_pages(&pdf_path, 120)
.expect("finalized raster a");
let pages_b = engine
.render_finalized_pdf_image_pages(&pdf_path, 120)
.expect("finalized raster b");
let _ = std::fs::remove_file(&pdf_path);
assert_eq!(
pages_a, pages_b,
"render_finalized_pdf_image_pages should be byte deterministic for identical input"
);
}
}