Skip to main content

pdf_xfa/
flatten.rs

1//! # XFA Flattening Pipeline
2//!
3//! This module parses XFA template, runs layout, and writes PDF content streams.
4//!
5//! ## Pipeline Stages
6//!
7//! 1. **Extract** — `extract_embedded_fonts()` reads font programs and /Widths
8//!    arrays from PDF font dictionaries
9//! 2. **Store** — `store_font_data()` saves font bytes + widths keyed by name
10//! 3. **Resolve** — `XfaFontResolver::resolve()` matches XFA font names to
11//!    stored fonts, with fallbacks (alias, family, system)
12//! 4. **Inject** — `inject_resolved_metrics()` pushes resolved widths into
13//!    FontMetrics for the layout engine
14//! 5. **Layout** — `LayoutEngine::layout()` computes page positions using
15//!    resolved font metrics for accurate text measurement
16//! 6. **Render** — `generate_page_overlay()` in render_bridge converts LayoutDom
17//!    to PDF content stream operators
18//! 7. **Embed** — `embed_resolved_fonts()` writes font data into the PDF
19//!    and creates /Font resources
20//! 8. **Write** — The content streams are written back to PDF pages
21//!
22//! ## Static vs Dynamic Forms
23//!
24//! XFA Spec 3.3 §1.7 (p28-30):
25//! - **Static (XFAF)**: boilerplate in PDF, fields/subforms in XFA. Fixed layout.
26//! - **Dynamic (full XFA)**: all content in XFA. Layout computed at runtime.
27//! - `baseProfile="interactiveForms"` indicates static (XFAF) forms.
28//!
29//! XFA Spec 3.3 §2.9 (p72) — PDF-XFA Connection:
30//! - NeedsRendering flag: dynamic=true, XFAF=false.
31//! - XFA packets stored in AcroForm/XFA entry in catalog.
32//!
33//! ## /Widths Handling
34//!
35//! PDF /Widths arrays start at FirstChar (typically 32). For simple fonts we
36//! remap those code-indexed widths through the font encoding so the layout
37//! engine receives Unicode-indexed measurements.
38//!
39//! ## CID Font /W Arrays (PDF spec §9.7.4.3)
40//!
41//! CID fonts (Type0/composite) use `/W` arrays in the CIDFont descendant
42//! dictionary instead of simple `/Widths`. Two element types:
43//!   - `cid_start [w1 w2 ...]` — consecutive CIDs starting at cid_start
44//!   - `cid_first cid_last width` — range of CIDs with same width
45//!
46//! `/DW` (default width, defaults to 1000) covers CIDs not in `/W`.
47//!
48//! ## Known Limitations
49//!
50//! - CID-to-Unicode mapping (ToUnicode CMap) is not yet parsed
51//! - System font fallback may have different metrics than the PDF's embedded font
52
53// Copyright (c) 2026 Innovation Trigger B.V.
54//
55// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
56// the PDFluent Commercial Licence. See the LICENSE file in this repository --
57// that file travels with the copy you received, which a URL does not.
58
59use lopdf::{
60    dictionary, Dictionary, Document, LoadOptions, Object, ObjectId, Stream, StringFormat,
61};
62use std::cell::Cell;
63use std::collections::{HashMap, HashSet};
64use std::fmt::Write as FmtWrite;
65#[cfg(not(target_arch = "wasm32"))]
66use std::thread;
67#[cfg(not(target_arch = "wasm32"))]
68use std::time::Duration;
69
70// GL-QA36: Re-entrance guard for flatten_xfa_to_pdf.
71//
72// When the XFA layout fails and static_fallback returns the original bytes
73// unchanged (because lopdf also cannot parse the file), a caller that retries
74// flatten on those same bytes will trigger the same failure path again,
75// causing infinite recursion and ultimately a stack overflow.
76//
77// This thread-local counter is incremented on entry to flatten_xfa_to_pdf and
78// decremented by a drop guard on exit.  If the counter is already ≥ 1 when
79// the function is entered, we return an error immediately to break the cycle.
80//
81// The counter is thread-local so the spawned worker thread (thread::spawn
82// inside flatten_xfa_to_pdf) starts with its own fresh counter = 0 and is
83// not affected by the caller's guard.
84thread_local! {
85    static FLATTEN_DEPTH: Cell<u32> = const { Cell::new(0) };
86}
87
88#[cfg(feature = "xfa-js-sandboxed")]
89use crate::dynamic::apply_dynamic_scripts_with_runtime;
90use crate::dynamic::{
91    apply_dynamic_scripts, apply_dynamic_scripts_with_mode, runtime_diag_enabled,
92    DynamicScriptOutcome, FormDomMatchEntry, JsExecutionMode, OutputQuality,
93};
94use crate::error::{Result, XfaError};
95use crate::extract::extract_xfa_from_bytes;
96use crate::flatten_trace;
97use crate::font_bridge::{
98    font_variant_key, pdf_glyph_name_to_unicode, CidFontInfo, EmbeddedFontData, PdfBaseEncoding,
99    PdfSimpleEncoding, PdfSourceFont, ResolvedFont, XfaFontResolver, XfaFontSpec,
100};
101use crate::image_bridge::embed_image;
102use crate::javascript_policy::{self, JavaScriptEntryPoint};
103use crate::merger::FormMerger;
104use crate::render_bridge::{
105    generate_all_overlays, generate_field_values_overlays, unicode_to_winansi, FontMetricsData,
106    PageOverlay, XfaRenderConfig,
107};
108use xfa_dom_resolver::data_dom::DataDom;
109use xfa_layout_engine::form::{DrawContent, FormNodeId, FormNodeStyle, FormTree};
110use xfa_layout_engine::layout::{
111    LayoutContent, LayoutDom, LayoutEngine, LayoutNode, LayoutProfile,
112};
113
114// ---------------------------------------------------------------------------
115// XFA-F6-01 (#1109): Pipeline stage ordering contract.
116//
117// The XFA flatten pipeline must execute stages in strict order:
118//   Extract → Bind → Layout → Render → Embed → Write → Cleanup
119//
120// `debug_assert!` calls at stage boundaries verify this order at runtime in
121// debug builds. The PipelineStage enum is Ord so comparisons are cheap.
122// ---------------------------------------------------------------------------
123
124/// Ordered pipeline stages for the XFA flatten process.
125///
126/// Stages must execute in ascending order. Use `debug_assert!` at each stage
127/// boundary to verify ordering in debug builds.
128#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
129enum PipelineStage {
130    Extract = 0,
131    Bind = 1,
132    Layout = 2,
133    Render = 3,
134    Embed = 4,
135    Write = 5,
136    Cleanup = 6,
137}
138
139fn create_minimal_pdf_document() -> Document {
140    let mut doc = Document::new();
141    let pages_id = doc.add_object(Object::Dictionary(dictionary! {
142        "Type" => Object::Name(b"Pages".to_vec()),
143        "Kids" => Object::Array(vec![]),
144        "Count" => Object::Integer(0)
145    }));
146    let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
147        "Type" => Object::Name(b"Catalog".to_vec()),
148        "Pages" => Object::Reference(pages_id)
149    }));
150    doc.trailer.set("Root", Object::Reference(catalog_id));
151    doc
152}
153
154/// Layout metadata emitted only for CLI diagnostics.
155#[derive(Debug, Clone, Default)]
156pub struct LayoutDump {
157    /// Per-page layout entries (one per rendered page).
158    pub pages: Vec<LayoutDumpEntry>,
159    /// Outcome of any dynamic script processing applied before layout.
160    pub dynamic_scripts: DynamicScriptOutcome,
161    /// Overall quality level of the flattened output.
162    pub output_quality: OutputQuality,
163}
164
165/// One page entry in the optional layout dump.
166#[derive(Debug, Clone)]
167pub struct LayoutDumpEntry {
168    /// 1-based page number.
169    pub page_num: u32,
170    /// Total height of the page area in points.
171    pub page_height: f64,
172    /// Height consumed by laid-out content on this page, in points.
173    pub used_height: f64,
174    /// True when content overflowed and continued on the next page.
175    pub overflow_to_next: bool,
176    /// Name of the first element that triggered overflow, if any.
177    pub first_overflow_element: Option<String>,
178}
179
180/// **D11/D12.** XFA flatten rendering policy — how PDFluent resolves a conflict
181/// between a PDF's embedded form DOM (Adobe Reader's saved runtime state) and a
182/// fresh `template + datasets` re-merge.
183///
184/// See `benchmarks/runs/xfa_enterprise_plan/d10_formdom_vs_remerge_policy/` for
185/// the decision record. The default is [`XfaRenderingPolicy::SavedStateFaithful`].
186///
187/// **D12 validation (2026-05-21):** `FreshMergeExperimental` was measured on a
188/// 9-doc target set and returned a GREEN verdict — no page-count regressions,
189/// `01de9ce4` recovered +80% text content. Corpus-scale measurement (D13) is
190/// pending. `FreshMergeExperimental` remains explicitly experimental and is not
191/// the production default.
192///
193/// PDFluent does **not** claim a single universal Adobe-parity mode: XFA
194/// rendering is policy-dependent (saved-state vs fresh-merge), mirroring Adobe's
195/// own static-vs-dynamic rendering distinction.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
197pub enum XfaRenderingPolicy {
198    /// Honor the embedded form DOM — render the instance set the document was
199    /// last saved with; suppress template/data instances the form DOM did not
200    /// enumerate. This is the default, fully-supported, production behaviour.
201    #[default]
202    SavedStateFaithful,
203    /// **Experimental.** Ignore the saved form DOM for dynamic sections; admit
204    /// data-bound subforms the form DOM omitted. D12 validation (2026-05-21)
205    /// confirmed improvement on `01de9ce4` (+80% text, 20 recovered rows) with
206    /// no page-count regressions on 8 protected targets. Some targets (`13275420`,
207    /// `b0389682`) do admit extra nodes under this policy, so page counts may
208    /// change. Corpus-scale measurement (D13) is pending before production use.
209    /// Default behavior (`SavedStateFaithful`) is unchanged.
210    FreshMergeExperimental,
211}
212
213impl XfaRenderingPolicy {
214    /// Stable lowercase identifier for reports / trace / CLI
215    /// (`"saved_state_faithful"` | `"fresh_merge_experimental"`).
216    #[must_use]
217    pub const fn as_str(self) -> &'static str {
218        match self {
219            Self::SavedStateFaithful => "saved_state_faithful",
220            Self::FreshMergeExperimental => "fresh_merge_experimental",
221        }
222    }
223
224    /// Parse a CLI/API token (`"saved-state"` | `"fresh-merge"`, plus the
225    /// `as_str` forms). Returns `None` for an unrecognised token.
226    #[must_use]
227    pub fn from_token(token: &str) -> Option<Self> {
228        match token.trim().to_ascii_lowercase().as_str() {
229            "saved-state" | "saved_state" | "saved_state_faithful" | "savedstatefaithful" => {
230                Some(Self::SavedStateFaithful)
231            }
232            "fresh-merge"
233            | "fresh_merge"
234            | "fresh_merge_experimental"
235            | "freshmergeexperimental" => Some(Self::FreshMergeExperimental),
236            _ => None,
237        }
238    }
239
240    /// Whether this policy has an implementation. Both policies are implemented
241    /// and D12-validated (9-doc set, GREEN, 2026-05-21). `FreshMergeExperimental`
242    /// is still experimental — corpus-scale measurement (D13) pending before
243    /// production use.
244    #[must_use]
245    pub const fn is_supported(self) -> bool {
246        true
247    }
248}
249
250/// Lightweight metadata returned alongside the flattened PDF bytes.
251#[derive(Debug, Clone, PartialEq, Eq, Default)]
252pub struct FlattenMetadata {
253    /// Outcome of dynamic script processing applied during flattening.
254    pub dynamic_scripts: DynamicScriptOutcome,
255    /// Overall output quality level of the flattened result.
256    pub output_quality: OutputQuality,
257    /// **D11.** The rendering policy that produced this result.
258    pub rendering_policy: XfaRenderingPolicy,
259    /// **D12.** Count of `formdom_unmatched` nodes that were **admitted**
260    /// (not suppressed) under [`XfaRenderingPolicy::FreshMergeExperimental`].
261    /// These are data-bound, non-zero-instance, non-template-hidden subforms
262    /// that the form DOM omitted but the merger matched to data.
263    ///
264    /// Always 0 under `SavedStateFaithful`.  Under `FreshMergeExperimental`
265    /// a non-zero value means the output may differ from `SavedStateFaithful`.
266    ///
267    /// **D12-validated** (9-doc set, GREEN, 2026-05-21). On `01de9ce4` a value
268    /// of 20 confirmed 20 recovered purchase-order rows. Corpus-scale measurement
269    /// (D13) pending; treat non-zero values as informational until D13 completes.
270    pub fresh_merge_admitted_nodes: usize,
271}
272
273impl FlattenMetadata {
274    fn from_dynamic_scripts(dynamic_scripts: DynamicScriptOutcome) -> Self {
275        let output_quality = dynamic_scripts.output_quality;
276        Self {
277            dynamic_scripts,
278            output_quality,
279            rendering_policy: XfaRenderingPolicy::SavedStateFaithful,
280            fresh_merge_admitted_nodes: 0,
281        }
282    }
283}
284
285struct FlattenOutput {
286    pdf_bytes: Vec<u8>,
287    layout_dump: LayoutDump,
288    metadata: FlattenMetadata,
289}
290
291impl FlattenOutput {
292    fn new(
293        pdf_bytes: Vec<u8>,
294        mut layout_dump: LayoutDump,
295        dynamic_scripts: DynamicScriptOutcome,
296    ) -> Self {
297        let output_quality = dynamic_scripts.output_quality;
298        let metadata = FlattenMetadata::from_dynamic_scripts(dynamic_scripts.clone());
299        layout_dump.dynamic_scripts = dynamic_scripts;
300        layout_dump.output_quality = output_quality;
301        Self {
302            pdf_bytes,
303            layout_dump,
304            metadata,
305        }
306    }
307
308    fn without_dump(pdf_bytes: Vec<u8>) -> Self {
309        Self::new(
310            pdf_bytes,
311            LayoutDump::default(),
312            DynamicScriptOutcome::default(),
313        )
314    }
315}
316
317/// Returns `true` if the PDF bytes contain an `/Encrypt` entry in the trailer.
318pub fn is_pdf_encrypted(pdf_bytes: &[u8]) -> bool {
319    Document::load_mem(pdf_bytes)
320        .map(|doc| doc.trailer.get(b"Encrypt").is_ok())
321        .unwrap_or(false)
322}
323
324enum DecryptResult {
325    NotEncrypted,
326    Decrypted(Vec<u8>),
327    NeedsPassword,
328}
329
330/// Try to handle encryption: if not encrypted return as-is, otherwise try the
331/// caller's password and then the empty one, and report needs-password if
332/// neither opens it.
333///
334/// `password` is `None` when the caller has none. Until 24-08-2026 that was the
335/// only possibility: the XFA route tried the empty password and nothing else,
336/// so a protected form always dropped out. On a business corpus that is 3 to 5%
337/// of the documents you cannot process without knowing why. See #149.
338///
339/// The order is deliberate: the supplied password first, then the empty one.
340/// The other way round, an owner-only encrypted document would open on the
341/// empty password while the caller had supplied a user password -- that works,
342/// but it hands them fewer rights than they have.
343fn try_decrypt_pdf(pdf_bytes: &[u8], password: Option<&str>) -> DecryptResult {
344    let mut doc = match Document::load_mem(pdf_bytes) {
345        Ok(d) => d,
346        Err(_) => return DecryptResult::NotEncrypted, // Can't parse — let downstream handle it
347    };
348
349    // lopdf auto-decrypts with empty password on load and removes /Encrypt.
350    // Use was_encrypted() to detect this — the original bytes are still encrypted
351    // and downstream parsers (pdf_syntax) can't read them.
352    if doc.was_encrypted() {
353        // Already decrypted by lopdf — save the decrypted document.
354        let mut buf = Vec::new();
355        match doc.save_to(&mut buf) {
356            Ok(()) => return DecryptResult::Decrypted(buf),
357            Err(_) => return DecryptResult::NeedsPassword,
358        }
359    }
360
361    if doc.trailer.get(b"Encrypt").is_ok() {
362        // The caller's password first, if they supplied one.
363        if let Some(pw) = password.filter(|p| !p.is_empty()) {
364            if let Ok(mut decrypted) =
365                Document::load_mem_with_options(pdf_bytes, LoadOptions::with_password(pw))
366            {
367                decrypted.trailer.remove(b"Encrypt");
368                let mut buf = Vec::new();
369                if decrypted.save_to(&mut buf).is_ok() {
370                    return DecryptResult::Decrypted(buf);
371                }
372                return DecryptResult::NeedsPassword;
373            }
374        }
375        // /Encrypt present but lopdf couldn't auto-decrypt — try explicit empty password.
376        match Document::load_mem_with_options(pdf_bytes, LoadOptions::with_password("")) {
377            Ok(mut decrypted_doc) => {
378                decrypted_doc.trailer.remove(b"Encrypt");
379                let mut buf = Vec::new();
380                match decrypted_doc.save_to(&mut buf) {
381                    Ok(()) => return DecryptResult::Decrypted(buf),
382                    Err(_) => return DecryptResult::NeedsPassword,
383                }
384            }
385            Err(_) => return DecryptResult::NeedsPassword,
386        }
387    }
388
389    DecryptResult::NotEncrypted
390}
391
392/// Returns `true` if the layout nodes contain at least one data-bearing field.
393///
394/// Checks the FormTree source node because the layout engine may emit
395/// `WrappedText` instead of `Field` for fields with content.
396///
397/// Non-data-bearing widgets are excluded so pages whose only interactive
398/// elements are decorative or structural are treated as static-only pages
399/// and are never suppressed by the page-drop heuristic:
400///
401/// * `Draw` elements are purely static content — text labels, images, lines.
402/// * `FieldKind::Signature` — a signature box carries no user-typed value.
403/// * `FieldKind::Button` — a push-button carries no data value by design.
404/// * `FieldKind::Barcode` — barcodes are presentation-only.
405fn page_has_fields(nodes: &[LayoutNode], tree: &FormTree) -> bool {
406    use xfa_layout_engine::form::{FieldKind, FormNodeType};
407    nodes.iter().any(|n| {
408        // Draw nodes (text labels, lines, images) are static content; they
409        // must never count as data fields for the page-suppression heuristic.
410        let is_data_field = matches!(tree.get(n.form_node).node_type, FormNodeType::Field { .. })
411            && !matches!(
412                tree.meta(n.form_node).field_kind,
413                FieldKind::Signature | FieldKind::Button | FieldKind::Barcode
414            );
415        is_data_field || page_has_fields(&n.children, tree)
416    })
417}
418
419/// Returns `true` if the layout nodes contain at least one field with a
420/// non-empty value.  Checks the FormTree source node because the layout
421/// engine converts non-empty field values to `WrappedText` for line-
422/// wrapping, making `LayoutContent::Field` unreliable for data detection.
423fn page_has_field_data(nodes: &[LayoutNode], tree: &FormTree) -> bool {
424    use xfa_layout_engine::form::FormNodeType;
425    nodes.iter().any(|n| {
426        matches!(
427            &tree.get(n.form_node).node_type,
428            FormNodeType::Field { value } if !value.is_empty()
429        ) || page_has_field_data(&n.children, tree)
430    })
431}
432
433/// Per-page field counts `(total, empty, nonempty)` for suppression tracing.
434fn page_field_counts(nodes: &[LayoutNode], tree: &FormTree) -> (usize, usize, usize) {
435    use xfa_layout_engine::form::FormNodeType;
436    let mut total = 0;
437    let mut empty = 0;
438    let mut nonempty = 0;
439    for n in nodes {
440        if let FormNodeType::Field { value } = &tree.get(n.form_node).node_type {
441            total += 1;
442            if value.trim().is_empty() {
443                empty += 1;
444            } else {
445                nonempty += 1;
446            }
447        }
448        let (t, e, ne) = page_field_counts(&n.children, tree);
449        total += t;
450        empty += e;
451        nonempty += ne;
452    }
453    (total, empty, nonempty)
454}
455
456/// Static (non-field) visible text characters on a page (draw text + loose/
457/// wrapped text not from a field value). Used only for suppression tracing.
458fn page_static_draw_chars(nodes: &[LayoutNode]) -> usize {
459    let mut total = 0usize;
460    for n in nodes {
461        match &n.content {
462            LayoutContent::Text(t) => total += t.chars().count(),
463            LayoutContent::Draw(DrawContent::Text(t)) => total += t.chars().count(),
464            LayoutContent::WrappedText {
465                lines, from_field, ..
466            } if !*from_field => {
467                total += lines.iter().map(|l| l.chars().count()).sum::<usize>();
468            }
469            _ => {}
470        }
471        total += page_static_draw_chars(&n.children);
472    }
473    total
474}
475
476/// Returns `true` when the page's layout nodes render any visible ink — a
477/// placed field (border/caption/value box), a static draw (line/rect/arc),
478/// non-empty draw/loose/wrapped text, or an image. A page whose nodes all
479/// carry `LayoutContent::None` or only whitespace renders nothing and is *not*
480/// visible.
481///
482/// This is the keep predicate for the [`suppression_trust_layout_enabled`]
483/// relaxation: it lets §4.3 keep data-empty pages that still draw content
484/// (trusting the layout page count) while still dropping truly-blank pages,
485/// rather than dropping every page whose fields lack a bound value.
486fn page_has_visible_content(nodes: &[LayoutNode]) -> bool {
487    nodes.iter().any(|n| {
488        let self_visible = match &n.content {
489            LayoutContent::None => false,
490            LayoutContent::Text(t) => !t.trim().is_empty(),
491            LayoutContent::WrappedText { lines, .. } => lines.iter().any(|l| !l.trim().is_empty()),
492            LayoutContent::Draw(DrawContent::Text(t)) => !t.trim().is_empty(),
493            // Lines, rectangles and arcs are visible structural ink.
494            LayoutContent::Draw(_) => true,
495            // A placed field renders its border/caption/value box even when
496            // the bound value is empty.
497            LayoutContent::Field { .. } => true,
498            LayoutContent::Image { .. } => true,
499        };
500        self_visible || page_has_visible_content(&n.children)
501    })
502}
503
504/// Opt-in (default-off) for `XFA_SUPPRESSION_TRUST_LAYOUT`: when set to a
505/// truthy value (`1`/`on`/`true`), §4.3 suppression trusts the layout page
506/// count and keeps every laid-out page that renders visible content
507/// ([`page_has_visible_content`]) instead of dropping data-empty pages.
508///
509/// This is the suppression half of the over-pagination/occur-instance fix.
510/// It is intentionally NOT default-on: until the layout engine stops
511/// over-producing pages for a handful of docs, trusting the layout count
512/// re-inflates those over-produced pages. The flag therefore stays off by
513/// default and pairs with the layout over-production milestone.
514fn suppression_trust_layout_enabled() -> bool {
515    matches!(std::env::var("XFA_SUPPRESSION_TRUST_LAYOUT"), Ok(v) if {
516        let v = v.trim();
517        v == "1" || v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("true")
518    })
519}
520
521/// BE-1 harvest-mode (default OFF). When set, the §4.3 `data_empty_dropped`
522/// page-suppression decision is taken against the **pre-JS** (data-bound) field
523/// values rather than the post-JS live tree. This preserves the static
524/// data-empty suppression under `XFA_JS_EXECUTION_MODE=sandboxed`: the runtime
525/// still applies structural intents (instanceManager / presence), but JS field
526/// population (`#items` list writes, value mutations) no longer keeps an
527/// otherwise data-empty page alive. Only meaningful when sandboxed JS actually
528/// mutates the tree; in the static default path pre-JS == live, so this is a
529/// no-op and the default binary stays byte-identical.
530fn harvest_mode_enabled() -> bool {
531    matches!(std::env::var("XFA_JS_HARVEST_MODE"), Ok(v) if {
532        let v = v.trim();
533        v == "1" || v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("true")
534    })
535}
536
537/// BE-1 harvest-mode: snapshot the ids of all `Field` nodes that carry a
538/// non-empty value at the moment of capture (taken pre-JS, right after the
539/// data merge). Used by [`page_has_field_data_snapshot`] so the suppression
540/// decision reflects data-bound emptiness, not JS-populated values.
541fn snapshot_nonempty_field_ids(tree: &FormTree) -> HashSet<FormNodeId> {
542    use xfa_layout_engine::form::FormNodeType;
543    let mut ids = HashSet::new();
544    for i in 0..tree.nodes.len() {
545        let id = FormNodeId(i);
546        if let FormNodeType::Field { value } = &tree.get(id).node_type {
547            if !value.trim().is_empty() {
548                ids.insert(id);
549            }
550        }
551    }
552    ids
553}
554
555/// BE-1 harvest-mode counterpart to [`page_has_field_data`]: a page "has field
556/// data" iff one of its nodes was a non-empty field in the pre-JS snapshot.
557fn page_has_field_data_snapshot(nodes: &[LayoutNode], snapshot: &HashSet<FormNodeId>) -> bool {
558    nodes.iter().any(|n| {
559        snapshot.contains(&n.form_node) || page_has_field_data_snapshot(&n.children, snapshot)
560    })
561}
562
563/// Sorted distinct FormNode ids referenced on a page — the "occur-instance
564/// signature". Two pages with identical signatures are repeated instances of
565/// the same template subtree (occur expansion reuses the template id).
566fn page_form_node_signature(nodes: &[LayoutNode], out: &mut Vec<usize>) {
567    for n in nodes {
568        out.push(n.form_node.0);
569        page_form_node_signature(&n.children, out);
570    }
571}
572
573/// Compute per-page suppression diagnostics mirroring the keep decision in the
574/// XFA §4.3 suppression block. Used only by the env-gated flatten trace.
575/// Build a child→parent index map over the FormTree (`parent[i]` = parent
576/// FormNodeId.0, or `usize::MAX` for roots). Used for occur-ancestor walks.
577fn build_parent_map(tree: &FormTree) -> Vec<usize> {
578    let mut parent = vec![usize::MAX; tree.nodes.len()];
579    for (pid, node) in tree.nodes.iter().enumerate() {
580        for &child in &node.children {
581            if child.0 < parent.len() {
582                parent[child.0] = pid;
583            }
584        }
585    }
586    parent
587}
588
589/// Count page nodes bound to a data node (`meta.bound_data_node.is_some()`).
590fn page_data_bound_count(nodes: &[LayoutNode], tree: &FormTree) -> usize {
591    let mut c = 0;
592    for n in nodes {
593        if tree.meta(n.form_node).bound_data_node.is_some() {
594            c += 1;
595        }
596        c += page_data_bound_count(&n.children, tree);
597    }
598    c
599}
600
601/// Nearest repeating-subform ancestor (`occur.is_repeating()`) of any form node
602/// on the page, walking up `parent_map`. Returns its FormNodeId.0, or None.
603fn page_repeating_ancestor(
604    distinct_ids: &[usize],
605    tree: &FormTree,
606    parent_map: &[usize],
607) -> Option<usize> {
608    use xfa_layout_engine::form::FormNodeId;
609    for &start in distinct_ids {
610        let mut cur = start;
611        let mut depth = 0;
612        while cur != usize::MAX && depth < 4096 {
613            if cur < tree.nodes.len() && tree.get(FormNodeId(cur)).occur.is_repeating() {
614                return Some(cur);
615            }
616            cur = parent_map.get(cur).copied().unwrap_or(usize::MAX);
617            depth += 1;
618        }
619    }
620    None
621}
622
623fn compute_suppression_diags(
624    layout: &LayoutDom,
625    tree: &FormTree,
626    pre_js_nonempty: Option<&HashSet<FormNodeId>>,
627) -> Vec<flatten_trace::PageSuppressionDiag> {
628    let parent_map = build_parent_map(tree);
629    let n = layout.pages.len();
630    let trust_layout = suppression_trust_layout_enabled();
631    // Raw per-page keep (matches the suppression `map`). Under harvest-mode the
632    // "has field data" test uses the pre-JS snapshot so the diag reflects the
633    // real (harvest) keep decision rather than the post-JS live tree.
634    let raw: Vec<(bool, bool, bool)> = layout
635        .pages
636        .iter()
637        .map(|p| {
638            let hd = match pre_js_nonempty {
639                Some(snap) => page_has_field_data_snapshot(&p.nodes, snap),
640                None => page_has_field_data(&p.nodes, tree),
641            };
642            (p.runtime_instantiated, page_has_fields(&p.nodes, tree), hd)
643        })
644        .collect();
645    let raw_keep = |i: usize| -> bool {
646        // Mirrors the suppression `map`: keep when runtime-instantiated, when a
647        // field carries data, or when the page has no fields at all.
648        // (`(hf && hd) || !hf` reduces to `hd || !hf`.)
649        let (rt, hf, hd) = raw[i];
650        rt || hd || !hf
651    };
652    let any_keep = (0..n).any(raw_keep);
653
654    // Signatures for repeated-instance detection.
655    let mut sigs: Vec<Vec<usize>> = Vec::with_capacity(n);
656    for p in &layout.pages {
657        let mut s = Vec::new();
658        page_form_node_signature(&p.nodes, &mut s);
659        s.sort_unstable();
660        s.dedup();
661        sigs.push(s);
662    }
663
664    let mut diags = Vec::with_capacity(n);
665    for i in 0..n {
666        let (rt, hf, hd) = raw[i];
667        let (fc, ef, nf) = page_field_counts(&layout.pages[i].nodes, tree);
668        let static_chars = page_static_draw_chars(&layout.pages[i].nodes);
669        let dup = (0..i)
670            .find(|&j| sigs[j] == sigs[i])
671            .map_or(-1, |j| j as i64);
672        let (keep, reason) = if n <= 1 {
673            (true, "single_page")
674        } else if rt {
675            (true, "runtime_instantiated")
676        } else if hf && hd {
677            (true, "has_field_data")
678        } else if !hf {
679            (true, "no_fields_static_kept")
680        } else if any_keep {
681            // Data-empty page. Default drops it; with XFA_SUPPRESSION_TRUST_LAYOUT
682            // on, keep it when it still renders visible content.
683            if trust_layout && page_has_visible_content(&layout.pages[i].nodes) {
684                (true, "trust_layout_kept")
685            } else {
686                (false, "data_empty_dropped")
687            }
688        } else {
689            (true, "all_empty_kept")
690        };
691
692        // --- Layout provenance ---
693        let data_bound = page_data_bound_count(&layout.pages[i].nodes, tree);
694        let repeating_ancestor = page_repeating_ancestor(&sigs[i], tree, &parent_map);
695        let under_repeating = repeating_ancestor.is_some();
696        let occur_template_id = repeating_ancestor.map_or(-1, |id| id as i64);
697        let has_data = nf > 0 || data_bound > 0;
698        let page_reason = if rt {
699            "root_page"
700        } else if under_repeating && !has_data {
701            "repeated_empty_instance"
702        } else if under_repeating {
703            "occur_instance"
704        } else if has_data {
705            "continuation"
706        } else if static_chars > 0 {
707            "static_page_area"
708        } else {
709            "unknown"
710        };
711        let suppression_safe_to_drop = page_reason == "repeated_empty_instance";
712        let provenance_confidence = if rt || under_repeating || has_data {
713            "exact"
714        } else if static_chars > 0 {
715            "inferred"
716        } else {
717            "unknown"
718        };
719
720        diags.push(flatten_trace::PageSuppressionDiag {
721            page_index: i,
722            keep,
723            reason,
724            field_count: fc,
725            empty_field_count: ef,
726            nonempty_field_count: nf,
727            static_draw_text_chars: static_chars,
728            distinct_form_nodes: sigs[i].len(),
729            duplicate_of_page: dup,
730            runtime_instantiated: rt,
731            under_repeating_subform: under_repeating,
732            occur_template_id,
733            data_bound_nodes_count: data_bound,
734            page_reason,
735            suppression_safe_to_drop,
736            provenance_confidence,
737        });
738    }
739    diags
740}
741
742/// Flatten all XFA content in `pdf_bytes` to static PDF content streams.
743///
744/// Returns the modified PDF bytes. The /AcroForm entry is removed so the
745/// result is a plain PDF/1.4 document.
746///
747/// If the PDF has no XFA content, returns a clone of the input unchanged.
748///
749/// # Performance Target
750///
751/// P95 latency ≤ 5 seconds for 50-page documents (see
752/// `docs/XFA_SUCCESS_CRITERIA.md`).  The pipeline uses a 30-second hard
753/// timeout per document; pathological inputs fall back to `static_fallback`.
754///
755/// # Debug Logging
756///
757/// Enable debug logging with `RUST_LOG=pdf_xfa=debug`.
758///
759/// # Oracle Comparison Approach (XFA-F1-04)
760///
761/// Reference ("oracle") output for quality comparison is generated using:
762///
763/// 1. **pdfRest** — `POST https://api.pdfrest.com/flatten-pdf`
764///    Uses Adobe's XFA engine.  Highest fidelity.  Rate-limited to ~1200
765///    calls/month across two accounts.  Keys at
766///    `~/.config/pdfluent/pdfrest-keys.json`.
767///
768///    ```bash
769///    # curl -X POST "https://api.pdfrest.com/flatten-pdf" \
770///    #   -H "Api-Key: <KEY>" \
771///    #   --form "input=@form.xfa.pdf;type=application/pdf" \
772///    #   -o reference.pdfrest.pdf
773///    ```
774///
775/// 2. **mutool** — `mutool convert -o reference.pdf input.xfa.pdf`
776///    Secondary oracle.  Free, offline, limited XFA support.
777///
778///    ```bash
779///    # mutool convert -o reference.mutool.pdf input.xfa.pdf
780///    ```
781///
782/// Quality is measured as per-page SSIM vs. the pdfRest oracle (target ≥ 0.95).
783/// See `scripts/generate_xfa_reference.sh` and `docs/XFA_SUCCESS_CRITERIA.md`.
784#[must_use = "flattened PDF bytes must be used; discarding them loses output"]
785pub fn flatten_xfa_to_pdf(pdf_bytes: &[u8]) -> Result<Vec<u8>> {
786    flatten_xfa_to_pdf_internal(
787        pdf_bytes,
788        false,
789        XfaRenderingPolicy::SavedStateFaithful,
790        None,
791    )
792    .map(|out| out.pdf_bytes)
793}
794/// Flatten XFA content and return the PDF bytes together with a per-page layout dump.
795///
796/// The [`LayoutDump`] is useful for CLI diagnostics and automated testing; use
797/// [`flatten_xfa_to_pdf`] when you only need the output bytes.
798///
799/// # Errors
800///
801/// Returns [`XfaError`] on parse, layout, or render failures.
802#[must_use = "flattened PDF bytes and layout dump must be used; discarding them loses output"]
803pub fn flatten_xfa_to_pdf_with_layout_dump(pdf_bytes: &[u8]) -> Result<(Vec<u8>, LayoutDump)> {
804    let out = flatten_xfa_to_pdf_internal(
805        pdf_bytes,
806        true,
807        XfaRenderingPolicy::SavedStateFaithful,
808        None,
809    )?;
810    Ok((out.pdf_bytes, out.layout_dump))
811}
812
813/// Flatten XFA content and return the PDF bytes together with [`FlattenMetadata`].
814///
815/// Metadata includes the dynamic-script outcome and overall output quality level.
816///
817/// # Errors
818///
819/// Returns [`XfaError`] on parse, layout, or render failures.
820#[must_use = "flattened PDF bytes and metadata must be used; discarding them loses output"]
821pub fn flatten_xfa_to_pdf_with_metadata(pdf_bytes: &[u8]) -> Result<(Vec<u8>, FlattenMetadata)> {
822    let out = flatten_xfa_to_pdf_internal(
823        pdf_bytes,
824        false,
825        XfaRenderingPolicy::SavedStateFaithful,
826        None,
827    )?;
828    Ok((out.pdf_bytes, out.metadata))
829}
830
831/// Flatten XFA content and return the PDF bytes, a layout dump, and metadata in one call.
832///
833/// Combines [`flatten_xfa_to_pdf_with_layout_dump`] and
834/// [`flatten_xfa_to_pdf_with_metadata`] without running the pipeline twice.
835///
836/// # Errors
837///
838/// Returns [`XfaError`] on parse, layout, or render failures.
839#[must_use = "flattened PDF bytes, layout dump, and metadata must be used; discarding them loses output"]
840pub fn flatten_xfa_to_pdf_with_layout_dump_and_metadata(
841    pdf_bytes: &[u8],
842) -> Result<(Vec<u8>, LayoutDump, FlattenMetadata)> {
843    let out = flatten_xfa_to_pdf_internal(
844        pdf_bytes,
845        true,
846        XfaRenderingPolicy::SavedStateFaithful,
847        None,
848    )?;
849    Ok((out.pdf_bytes, out.layout_dump, out.metadata))
850}
851
852/// **D11/D12.** Flatten XFA content under an explicit [`XfaRenderingPolicy`].
853///
854/// [`XfaRenderingPolicy::SavedStateFaithful`] (the default) behaves identically
855/// to [`flatten_xfa_to_pdf`]. [`XfaRenderingPolicy::FreshMergeExperimental`]
856/// is D12-validated (9-doc set, GREEN, 2026-05-21) but remains experimental —
857/// corpus-scale measurement (D13) is pending.
858///
859/// # Errors
860///
861/// Returns [`XfaError`] on parse, layout, or render failures.
862#[must_use = "flattened PDF bytes must be used; discarding them loses output"]
863pub fn flatten_xfa_to_pdf_with_policy(
864    pdf_bytes: &[u8],
865    policy: XfaRenderingPolicy,
866) -> Result<Vec<u8>> {
867    flatten_xfa_to_pdf_with_policy_and_metadata(pdf_bytes, policy).map(|(bytes, _)| bytes)
868}
869
870/// **D11/D12.** Flatten XFA content under an explicit [`XfaRenderingPolicy`],
871/// returning the bytes and [`FlattenMetadata`] (whose `rendering_policy` field
872/// records the selected policy and `fresh_merge_admitted_nodes` the count of
873/// nodes admitted under `FreshMergeExperimental`).
874///
875/// # Errors
876///
877/// Returns [`XfaError`] on parse, layout, or render failures.
878#[must_use = "flattened PDF bytes and metadata must be used; discarding them loses output"]
879pub fn flatten_xfa_to_pdf_with_policy_and_metadata(
880    pdf_bytes: &[u8],
881    policy: XfaRenderingPolicy,
882) -> Result<(Vec<u8>, FlattenMetadata)> {
883    // D12: FreshMergeExperimental is now plumbed through the pipeline.
884    let out = flatten_xfa_to_pdf_internal(pdf_bytes, false, policy, None)?;
885    let mut metadata = out.metadata;
886    metadata.rendering_policy = policy;
887    Ok((out.pdf_bytes, metadata))
888}
889
890/// Flatten XFA content in an encrypted document, using `password` to open it.
891///
892/// Added 24-08-2026 (#149). The other entry points try the empty password and
893/// nothing else, which works for owner-only encryption and no further. On a
894/// business corpus that drops 3 to 5% of the documents, and the caller cannot
895/// tell whether that is the document's fault or ours.
896///
897/// An empty `password` behaves like [`flatten_xfa_to_pdf`].
898///
899/// # Errors
900///
901/// Returns [`XfaError`] on parse, layout, or render failures, and
902/// [`XfaError::Encrypted`] when neither this password nor the empty one opens
903/// the document.
904#[must_use = "flattened PDF bytes must be used; discarding them loses output"]
905pub fn flatten_xfa_to_pdf_with_password(pdf_bytes: &[u8], password: &str) -> Result<Vec<u8>> {
906    flatten_xfa_to_pdf_internal(
907        pdf_bytes,
908        false,
909        XfaRenderingPolicy::SavedStateFaithful,
910        Some(password),
911    )
912    .map(|out| out.pdf_bytes)
913}
914
915fn flatten_xfa_to_pdf_internal(
916    pdf_bytes: &[u8],
917    collect_layout_dump: bool,
918    policy: XfaRenderingPolicy,
919    password: Option<&str>,
920) -> Result<FlattenOutput> {
921    // GL-QA36: Re-entrance guard.  If this function is entered while already
922    // running on this thread (depth ≥ 1), a recursive call has occurred —
923    // most likely a fallback path returning the original bytes which still
924    // contain /AcroForm + xdp:xdp markers.  Abort immediately with an error
925    // to prevent the infinite recursion / stack overflow.
926    //
927    // The worker thread spawned below has its own thread-local so its depth
928    // starts at 0 and is unaffected by this guard.
929    let depth = FLATTEN_DEPTH.with(|d| d.get());
930    if depth >= 1 {
931        return Err(XfaError::LayoutFailed(
932            "flatten_xfa_to_pdf called recursively — aborting to prevent stack overflow".into(),
933        ));
934    }
935    FLATTEN_DEPTH.with(|d| d.set(depth + 1));
936    // Drop guard: decrement the counter even if we return early.
937    struct DepthGuard;
938    impl Drop for DepthGuard {
939        fn drop(&mut self) {
940            FLATTEN_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
941        }
942    }
943    let _depth_guard = DepthGuard;
944
945    // 0a. Quick byte-level pre-check: if the raw bytes don't contain /AcroForm
946    //     (where XFA lives per the spec) and no XDP namespace, skip expensive
947    //     parsing. This prevents multi-second stalls on large non-XFA PDFs.
948    if !pdf_bytes.windows(9).any(|w| w == b"/AcroForm")
949        && !pdf_bytes.windows(7).any(|w| w == b"xdp:xdp")
950    {
951        return Ok(FlattenOutput::without_dump(pdf_bytes.to_vec()));
952    }
953
954    // 0b. Handle encrypted PDFs: try empty-password decrypt (owner-only encryption),
955    //     otherwise reject early — encrypted content produces garbage output.
956    let decrypted;
957    let pdf_bytes = match try_decrypt_pdf(pdf_bytes, password) {
958        DecryptResult::NotEncrypted => pdf_bytes,
959        DecryptResult::Decrypted(bytes) => {
960            decrypted = bytes;
961            &decrypted
962        }
963        DecryptResult::NeedsPassword => {
964            return Err(XfaError::Encrypted(
965                "PDF is encrypted and requires a password".into(),
966            ));
967        }
968    };
969
970    // 1. Extract XFA packets.
971    let packets = match extract_xfa_from_bytes(pdf_bytes.to_vec()) {
972        Ok(p) => p,
973        Err(_) => {
974            // No XFA packet was extracted, but the byte-level pre-check already
975            // established that the document carries /AcroForm or XFA markers.
976            // Fall back to static cleanup so AcroForm-only inputs still flatten.
977            return static_fallback(pdf_bytes).map(FlattenOutput::without_dump);
978        }
979    };
980
981    let template_xml = match packets.template() {
982        Some(t) => strip_undefined_xml_entities(t),
983        None => {
984            // XFA present but template packet missing/unparseable (truncated XML).
985            // Strip AcroForm + NeedsRendering so renderers use static content.
986            return static_fallback(pdf_bytes).map(FlattenOutput::without_dump);
987        }
988    };
989
990    // 1b. Detect corrupt/minimal XFA: tiny PDFs (<1KB) whose template has no
991    //     real content (no <subform> or <pageSet> children) produce blank output.
992    //     Fall back to static page copy so the original pages are preserved.
993    if is_corrupt_xfa_template(pdf_bytes.len(), &template_xml) {
994        return static_fallback(pdf_bytes).map(FlattenOutput::without_dump);
995    }
996
997    // 2. Try XFA template → layout → render pipeline.
998    //    If this fails (parse error, empty template, layout 0 pages, lopdf error),
999    //    fall back to preserving the existing page content with AcroForm stripped.
1000    //
1001    //    Wrap in a thread-based timeout (30s) to prevent hangs on pathological
1002    //    XFA documents. If the timeout fires, the join handle's result is an Err
1003    //    and we fall back to static_fallback.
1004    let datasets_xml_owned = packets.datasets().map(strip_undefined_xml_entities);
1005    let form_xml_owned = packets.get_packet("form").map(|s| s.to_string());
1006
1007    #[cfg(not(target_arch = "wasm32"))]
1008    {
1009        // Native: wrap in a thread-based timeout (30s) so pathological XFA
1010        // documents cannot hang a server. If the timeout fires, the join
1011        // handle's result is an Err and we fall back to static_fallback.
1012        const FLATTEN_TIMEOUT: Duration = Duration::from_secs(30);
1013        let pdf_bytes_ref = pdf_bytes.to_vec();
1014        let template_xml_owned = template_xml.clone();
1015
1016        // An explicit stack rather than the platform default.
1017        //
1018        // This thread exists for the timeout above, but its stack is what the
1019        // FormCalc evaluator actually recurses on -- the caller cannot size it,
1020        // so `render_page` behaved the same whether its own thread had 256 KB or
1021        // 8 MB. `MAX_EVAL_DEPTH` is the bound; this only makes sure the bound is
1022        // reachable before the stack runs out. Rust's default is 2 MB, and a
1023        // *debug* build costs ~17 KB per evaluation level against release's
1024        // ~780 B, so 2 MB ran out around depth 122 -- below the budget, which
1025        // meant a debug build still aborted where a release build refused.
1026        const FLATTEN_STACK: usize = 32 * 1024 * 1024;
1027        let handle = thread::Builder::new()
1028            .stack_size(FLATTEN_STACK)
1029            .spawn(move || {
1030                xfa_flatten_inner(
1031                    &pdf_bytes_ref,
1032                    &template_xml_owned,
1033                    datasets_xml_owned.as_deref(),
1034                    form_xml_owned.as_deref(),
1035                    collect_layout_dump,
1036                    policy,
1037                )
1038            })
1039            .map_err(XfaError::Io)?;
1040
1041        match handle.join() {
1042            Ok(Ok(out)) => Ok(out),
1043            Ok(Err(e @ XfaError::UnsupportedFeature(_))) => Err(e),
1044            Ok(Err(e)) => {
1045                eprintln!("XFA flatten failed: {e:?}");
1046                static_fallback(pdf_bytes).map(FlattenOutput::without_dump)
1047            }
1048            Err(_) => {
1049                eprintln!("XFA flatten timed out after {:?}", FLATTEN_TIMEOUT);
1050                static_fallback(pdf_bytes).map(FlattenOutput::without_dump)
1051            }
1052        }
1053    }
1054
1055    // wasm32 has no `std::thread`, no preemption, and the host page is the
1056    // natural cancellation boundary, so run the same pipeline inline (no
1057    // watchdog thread — `thread::spawn` is unsupported on wasm32 and panics).
1058    #[cfg(target_arch = "wasm32")]
1059    {
1060        match xfa_flatten_inner(
1061            pdf_bytes,
1062            &template_xml,
1063            datasets_xml_owned.as_deref(),
1064            form_xml_owned.as_deref(),
1065            collect_layout_dump,
1066            policy,
1067        ) {
1068            Ok(out) => Ok(out),
1069            Err(e @ XfaError::UnsupportedFeature(_)) => Err(e),
1070            Err(e) => {
1071                eprintln!("XFA flatten failed: {e:?}");
1072                static_fallback(pdf_bytes).map(FlattenOutput::without_dump)
1073            }
1074        }
1075    }
1076}
1077
1078/// BE-1 tranche #1: collect the names of structural containers
1079/// (`subform`/`subformSet`/`exclGroup`/`area`) declared anywhere in the XFA
1080/// template. Installed on the sandboxed runtime before script execution so a
1081/// bare implicit SOM reference to a declared-but-absent container resolves to a
1082/// benign empty node (Adobe semantics) instead of `undefined`. Only built on
1083/// the sandboxed path; the default binary never calls this.
1084#[cfg(feature = "xfa-js-sandboxed")]
1085fn collect_declared_container_names(template_xml: &str) -> std::collections::HashSet<String> {
1086    let mut names = std::collections::HashSet::new();
1087    if let Ok(doc) = roxmltree::Document::parse(template_xml) {
1088        for node in doc.descendants() {
1089            if node.is_element()
1090                && matches!(
1091                    node.tag_name().name(),
1092                    "subform" | "subformSet" | "exclGroup" | "area"
1093                )
1094            {
1095                if let Some(name) = node.attribute("name") {
1096                    if !name.is_empty() {
1097                        names.insert(name.to_string());
1098                    }
1099                }
1100            }
1101        }
1102    }
1103    names
1104}
1105
1106/// Core XFA flatten pipeline: parse template, bind data, layout, render.
1107fn xfa_flatten_inner(
1108    pdf_bytes: &[u8],
1109    template_xml: &str,
1110    datasets_xml: Option<&str>,
1111    form_xml: Option<&str>,
1112    collect_layout_dump: bool,
1113    policy: XfaRenderingPolicy,
1114) -> Result<FlattenOutput> {
1115    // XFA-F6-01 (#1109): pipeline stage tracker — verifies strict ordering via
1116    // debug_assert in each stage transition below.
1117    let mut _stage = PipelineStage::Extract;
1118
1119    // PIPELINE: stage 0 — Extract (parse datasets and image files from PDF)
1120    log::debug!(
1121        "XFA flatten: {} bytes input, template={} bytes",
1122        pdf_bytes.len(),
1123        template_xml.len()
1124    );
1125
1126    let data_dom = if let Some(ds_xml) = datasets_xml {
1127        DataDom::from_xml(ds_xml)
1128            .map_err(|e| XfaError::ParseFailed(format!("datasets parse: {e}")))?
1129    } else {
1130        DataDom::new()
1131    };
1132
1133    // Extract embedded image files from the PDF for resolving <image href="…">
1134    // references in the XFA template (XFA §2.3).
1135    let image_files = match Document::load_mem(pdf_bytes) {
1136        Ok(doc) => extract_embedded_images(&doc),
1137        Err(_) => HashMap::new(),
1138    };
1139
1140    // XFA-F9-02 (#1121): Graceful degradation — warn on unsupported features
1141    // instead of failing silently.  These checks run once per document after
1142    // template extraction so they add negligible overhead.
1143    if template_xml.contains("barcode") {
1144        log::warn!("XFA barcode elements found but not supported — rendered as empty boxes");
1145    }
1146    if template_xml.contains("<signature") || template_xml.contains("<Signature") {
1147        log::warn!("XFA signature elements found but not supported — elements skipped");
1148    }
1149    if javascript_policy::template_mentions_javascript(template_xml) {
1150        log::warn!(
1151            "{}",
1152            javascript_policy::execution_denied_message(JavaScriptEntryPoint::XfaEventHook)
1153        );
1154    }
1155
1156    // PIPELINE: stage 1 — Bind (merge template with data DOM)
1157    debug_assert!(
1158        _stage <= PipelineStage::Bind,
1159        "pipeline stage order violated: expected <= Bind"
1160    );
1161    _stage = PipelineStage::Bind;
1162
1163    // Trace capture (env-gated emit at end of function; counts are cheap).
1164    let trace_image_files = image_files.len();
1165    let merger = FormMerger::new(&data_dom).with_image_files(image_files);
1166    let (mut tree, root_id) = merger
1167        .merge(template_xml)
1168        .map_err(|e| XfaError::ParseFailed(format!("template merge: {e}")))?;
1169
1170    log::debug!("XFA bind: {} form nodes created", tree.nodes.len());
1171
1172    // M3-B Phase C validation hook (2026-05-03):
1173    // Allow operators (CLI, integration tests, cohort runs) to engage the
1174    // sandboxed JavaScript runtime by setting `XFA_JS_EXECUTION_MODE`.
1175    // - unset / "default" / "best_effort_static" → existing default
1176    //   (`BestEffortStatic`), no behaviour change for any existing user.
1177    // - "strict" → `Strict` (M8 `DENY_EXECUTION`).
1178    // - "sandboxed" / "sandboxed_runtime" → `SandboxedRuntime` (Phase B+C).
1179    //   Only effective when the `xfa-js-sandboxed` Cargo feature is compiled
1180    //   in; otherwise NullRuntime returns NotCompiledIn and the dispatch
1181    //   path falls back to the same skip behaviour as `BestEffortStatic`.
1182    //
1183    // BE-1 harvest-mode (default OFF): snapshot data-bound field emptiness
1184    // BEFORE the scripts run, so the later §4.3 page-suppression can preserve
1185    // the static `data_empty_dropped` behaviour even when sandboxed JS populates
1186    // fields. `None` (flag off) → suppression uses the live tree (byte-identical).
1187    let pre_js_nonempty_fields: Option<HashSet<FormNodeId>> = if harvest_mode_enabled() {
1188        Some(snapshot_nonempty_field_ids(&tree))
1189    } else {
1190        None
1191    };
1192    let dynamic_scripts = match std::env::var("XFA_JS_EXECUTION_MODE")
1193        .ok()
1194        .map(|s| s.to_ascii_lowercase())
1195        .as_deref()
1196    {
1197        Some("strict") => {
1198            apply_dynamic_scripts_with_mode(&mut tree, root_id, JsExecutionMode::Strict)?
1199        }
1200        Some("sandboxed") | Some("sandboxed_runtime") => {
1201            // Phase D-γ: create the runtime manually so we can call
1202            // `set_data_handle` before script execution, making the DataDom
1203            // accessible from `$record` and `xfa.resolveNodes("data.*")`.
1204            #[cfg(feature = "xfa-js-sandboxed")]
1205            {
1206                use crate::js_runtime::{NullRuntime, QuickJsRuntime, XfaJsRuntime};
1207                match QuickJsRuntime::new() {
1208                    Ok(mut rt) => {
1209                        rt.set_data_handle(&data_dom as *const _);
1210                        // BE-1 tranche #1: install template-declared container
1211                        // names so a bare implicit ref to a declared-but-absent
1212                        // subform resolves to a benign empty node (isNull=true)
1213                        // instead of `undefined`, letting guarded second-party
1214                        // scripts run their else branch (setInstances(0)/hide).
1215                        rt.set_declared_subform_names(collect_declared_container_names(
1216                            template_xml,
1217                        ));
1218                        apply_dynamic_scripts_with_runtime(
1219                            &mut tree,
1220                            root_id,
1221                            JsExecutionMode::SandboxedRuntime,
1222                            &mut rt,
1223                        )?
1224                    }
1225                    Err(_) => apply_dynamic_scripts_with_runtime(
1226                        &mut tree,
1227                        root_id,
1228                        JsExecutionMode::SandboxedRuntime,
1229                        &mut NullRuntime::new(),
1230                    )?,
1231                }
1232            }
1233            #[cfg(not(feature = "xfa-js-sandboxed"))]
1234            apply_dynamic_scripts_with_mode(&mut tree, root_id, JsExecutionMode::SandboxedRuntime)?
1235        }
1236        _ => apply_dynamic_scripts(&mut tree, root_id)?,
1237    };
1238    if dynamic_scripts.output_quality != OutputQuality::Exact {
1239        // M3-B Phase C (2026-05-03): appended host-binding counters after
1240        // the Phase B JS runtime counters. Defaults stay 0 in
1241        // `BestEffortStatic` mode so existing log parsers remain compatible.
1242        log::warn!(
1243            "XFA script metadata: output_quality={} js_present={} js_skipped={} other_skipped={} formcalc_run={} formcalc_errors={} js_executed={} js_runtime_errors={} js_timeouts={} js_oom={} js_host_calls={} js_mutations={} js_instance_writes={} js_list_writes={} js_binding_errors={} js_resolve_failures={} js_data_reads={} js_unsupported_host_calls={} js_probe_skips={}",
1244            dynamic_scripts.output_quality.as_str(),
1245            dynamic_scripts.js_present,
1246            dynamic_scripts.js_skipped,
1247            dynamic_scripts.other_skipped,
1248            dynamic_scripts.formcalc_run,
1249            dynamic_scripts.formcalc_errors,
1250            dynamic_scripts.js_executed,
1251            dynamic_scripts.js_runtime_errors,
1252            dynamic_scripts.js_timeouts,
1253            dynamic_scripts.js_oom,
1254            dynamic_scripts.js_host_calls,
1255            dynamic_scripts.js_mutations,
1256            dynamic_scripts.js_instance_writes,
1257            dynamic_scripts.js_list_writes,
1258            dynamic_scripts.js_binding_errors,
1259            dynamic_scripts.js_resolve_failures,
1260            dynamic_scripts.js_data_reads,
1261            dynamic_scripts.js_unsupported_host_calls,
1262            dynamic_scripts.js_probe_skips,
1263        );
1264        eprintln!(
1265            "XFA script metadata: output_quality={} js_present={} js_skipped={} other_skipped={} formcalc_run={} formcalc_errors={} js_executed={} js_runtime_errors={} js_timeouts={} js_oom={} js_host_calls={} js_mutations={} js_instance_writes={} js_list_writes={} js_binding_errors={} js_resolve_failures={} js_data_reads={} js_unsupported_host_calls={} js_probe_skips={}",
1266            dynamic_scripts.output_quality.as_str(),
1267            dynamic_scripts.js_present,
1268            dynamic_scripts.js_skipped,
1269            dynamic_scripts.other_skipped,
1270            dynamic_scripts.formcalc_run,
1271            dynamic_scripts.formcalc_errors,
1272            dynamic_scripts.js_executed,
1273            dynamic_scripts.js_runtime_errors,
1274            dynamic_scripts.js_timeouts,
1275            dynamic_scripts.js_oom,
1276            dynamic_scripts.js_host_calls,
1277            dynamic_scripts.js_mutations,
1278            dynamic_scripts.js_instance_writes,
1279            dynamic_scripts.js_list_writes,
1280            dynamic_scripts.js_binding_errors,
1281            dynamic_scripts.js_resolve_failures,
1282            dynamic_scripts.js_data_reads,
1283            dynamic_scripts.js_unsupported_host_calls,
1284            dynamic_scripts.js_probe_skips,
1285        );
1286    }
1287
1288    // XFA §3: when the PDF contains a pre-merged form DOM (saved by Adobe's
1289    // runtime after scripts executed), use its presence attributes to override
1290    // the template-based defaults. This captures script-driven visibility
1291    // changes (e.g. Avoka framework's sfcUtils.updateVisibility) that our
1292    // FormCalc interpreter cannot execute.
1293    // Graduated default-ON (static-parity-rc1): `SavedStateFaithful` admits
1294    // data-bound unmatched subforms through the same guarded branch as
1295    // `FreshMergeExperimental` instead of suppressing them, WITHOUT flipping the
1296    // policy default. Opt out with `XFA_FORMDOM_ADMIT_DATABOUND=0|off|false`.
1297    // Read here at the pipeline boundary (like `XFA_JS_EXECUTION_MODE`) so the
1298    // presence logic stays a pure, deterministically-testable function. No-op
1299    // under `FreshMergeExperimental` (which already admits the same set).
1300    let admit_databound_override = std::env::var("XFA_FORMDOM_ADMIT_DATABOUND")
1301        .map(|v| {
1302            let v = v.trim();
1303            !(v.is_empty()
1304                || v == "0"
1305                || v.eq_ignore_ascii_case("off")
1306                || v.eq_ignore_ascii_case("false"))
1307        })
1308        .unwrap_or(true);
1309    let (fresh_merge_admitted, form_dom_match_failures, form_dom_match_log) =
1310        if let Some(fxml) = form_xml {
1311            apply_form_dom_presence(&mut tree, root_id, fxml, policy, admit_databound_override)
1312        } else {
1313            (0, 0, Vec::new())
1314        };
1315
1316    // Resolve fonts BEFORE layout so the layout engine uses actual font metrics
1317    // (widths, ascender, descender) instead of generic AFM tables.
1318    let resolved_fonts = resolve_template_fonts(template_xml, pdf_bytes);
1319    inject_resolved_metrics(&mut tree, &resolved_fonts);
1320
1321    // PIPELINE: stage 2 — Layout (compute page positions using resolved font metrics)
1322    debug_assert!(
1323        _stage <= PipelineStage::Layout,
1324        "pipeline stage order violated: expected <= Layout"
1325    );
1326    _stage = PipelineStage::Layout;
1327
1328    let engine = LayoutEngine::new(&tree);
1329    let (mut layout, mut layout_dump) = if collect_layout_dump {
1330        let (layout, profile) = engine
1331            .layout_with_profile(root_id)
1332            .map_err(|e| XfaError::LayoutFailed(format!("{e:?}")))?;
1333        (layout, Some(layout_dump_from_profile(profile)))
1334    } else {
1335        let layout = engine
1336            .layout(root_id)
1337            .map_err(|e| XfaError::LayoutFailed(format!("{e:?}")))?;
1338        (layout, None)
1339    };
1340
1341    if layout.pages.is_empty() {
1342        return Err(XfaError::LayoutFailed("layout produced 0 pages".into()));
1343    }
1344
1345    log::debug!("XFA layout: {} pages produced", layout.pages.len());
1346
1347    // XFA Spec §4.3: suppress page subforms whose data is empty or absent.
1348    // A page with fields but no populated values is considered "data-empty"
1349    // and should be suppressed.  Pages without fields (static-only pages with
1350    // draws/images) are always kept.  At least one page is retained.
1351    //
1352    // Guard: when the form carries datasets data but our binding has not yet
1353    // propagated values into LayoutContent::Field.value for any page (e.g.
1354    // nested or namespace-qualified data paths), keep all layout pages.
1355    // Suppressing when binding is incomplete would incorrectly drop pages of
1356    // explicitly-paginated documents whose fields appear data-empty to this
1357    // heuristic even though real data is present.
1358    let trace_pages_produced = layout.pages.len();
1359    // Capture per-page suppression diagnostics BEFORE the retain mutates pages
1360    // (env-gated; empty when tracing is off).
1361    let trace_suppression = if flatten_trace::enabled() {
1362        compute_suppression_diags(&layout, &tree, pre_js_nonempty_fields.as_ref())
1363    } else {
1364        Vec::new()
1365    };
1366    // Default-off opt-in: trust the layout page count and keep every laid-out
1367    // page that still renders visible content, rather than dropping data-empty
1368    // pages. Pairs with the layout over-production fix (see helper docs).
1369    let trust_layout = suppression_trust_layout_enabled();
1370    if layout.pages.len() > 1 {
1371        let keep: Vec<bool> = layout
1372            .pages
1373            .iter()
1374            .map(|p| {
1375                // XFA 3.3 §3.1 / §8.6: pages emitted onto runtime-allocated
1376                // pageAreas (recorded in the form-DOM packet) are an explicit
1377                // commitment by Adobe's runtime — never drop them on a
1378                // data-empty heuristic.
1379                if p.runtime_instantiated {
1380                    true
1381                } else if page_has_fields(&p.nodes, &tree) {
1382                    // Default: keep only when a field carries data. With
1383                    // XFA_SUPPRESSION_TRUST_LAYOUT on, also keep data-empty
1384                    // pages that still render visible content (the `&&`
1385                    // short-circuits, so flag-off is byte-identical).
1386                    //
1387                    // BE-1 harvest-mode: when a pre-JS snapshot is present,
1388                    // base "has data" on the data-bound (pre-JS) field values
1389                    // so sandboxed JS field population can't keep an otherwise
1390                    // data-empty page. `None` (flag off) → live-tree check,
1391                    // byte-identical to the static default.
1392                    let has_field_data = match pre_js_nonempty_fields {
1393                        Some(ref snap) => page_has_field_data_snapshot(&p.nodes, snap),
1394                        None => page_has_field_data(&p.nodes, &tree),
1395                    };
1396                    has_field_data || (trust_layout && page_has_visible_content(&p.nodes))
1397                } else {
1398                    true
1399                }
1400            })
1401            .collect();
1402        let any_keep = keep.iter().any(|&k| k);
1403        if any_keep {
1404            let mut idx = 0;
1405            layout.pages.retain(|_| {
1406                let k = keep[idx];
1407                idx += 1;
1408                k
1409            });
1410            if let Some(ref mut dump) = layout_dump {
1411                let mut idx = 0;
1412                dump.pages.retain(|_| {
1413                    let k = keep[idx];
1414                    idx += 1;
1415                    k
1416                });
1417            }
1418        }
1419        // When NO page has data, keep all pages: the form is empty and
1420        // all structural pages should be preserved (e.g. a 6-page
1421        // inspection report with no filled-in values).
1422    }
1423
1424    if let Some(ref mut dump) = layout_dump {
1425        renumber_layout_dump_pages(dump);
1426    }
1427
1428    // PIPELINE: stage 3 — Render (generate XFA overlay content streams from layout)
1429    debug_assert!(
1430        _stage <= PipelineStage::Render,
1431        "pipeline stage order violated: expected <= Render"
1432    );
1433    _stage = PipelineStage::Render;
1434
1435    let mut doc = match Document::load_mem(pdf_bytes) {
1436        Ok(d) => d,
1437        Err(_) => {
1438            eprintln!("lopdf load failed, creating minimal PDF structure for XFA layout");
1439            create_minimal_pdf_document()
1440        }
1441    };
1442
1443    // PIPELINE: stage 4 — Embed (embed fonts/images into PDF document)
1444    debug_assert!(
1445        _stage <= PipelineStage::Embed,
1446        "pipeline stage order violated: expected <= Embed"
1447    );
1448    _stage = PipelineStage::Embed;
1449
1450    // PERF: embed_resolved_fonts is O(f * p) where f = unique resolved fonts
1451    // and p = PDF pages.  Each font requires a full font-program copy into the
1452    // PDF object stream plus /Widths array serialisation.  For documents with
1453    // many embedded fonts and many pages this is the dominant allocation source.
1454    // Potential optimisation: share font objects across pages (already done for
1455    // standard Type1 fonts F1-F3; extend to TrueType/CID fonts).
1456    let (font_map, embedded_font_objects, metrics_data) =
1457        embed_resolved_fonts(&mut doc, &resolved_fonts, &layout);
1458
1459    let config = XfaRenderConfig {
1460        font_map: std::sync::Arc::new(font_map),
1461        font_metrics_data: std::sync::Arc::new(metrics_data),
1462        ..Default::default()
1463    };
1464
1465    let overlays = generate_all_overlays(&layout, &config)
1466        .map_err(|e| XfaError::LayoutFailed(format!("overlay generation: {e:?}")))?;
1467
1468    log::debug!(
1469        "XFA render: {} content streams generated ({} bytes total)",
1470        overlays.len(),
1471        overlays
1472            .iter()
1473            .map(|o| o.content_stream.len())
1474            .sum::<usize>()
1475    );
1476
1477    // Register standard PDF fonts: F1=Times-Roman (serif), F2=Helvetica (sans), F3=Courier (mono).
1478    let font_ids: [ObjectId; 3] = [
1479        doc.add_object(Object::Dictionary(dictionary! {
1480            "Type"     => Object::Name(b"Font".to_vec()),
1481            "Subtype"  => Object::Name(b"Type1".to_vec()),
1482            "BaseFont" => Object::Name(b"Times-Roman".to_vec()),
1483            "Encoding" => Object::Name(b"WinAnsiEncoding".to_vec())
1484        })),
1485        doc.add_object(Object::Dictionary(dictionary! {
1486            "Type"     => Object::Name(b"Font".to_vec()),
1487            "Subtype"  => Object::Name(b"Type1".to_vec()),
1488            "BaseFont" => Object::Name(b"Helvetica".to_vec()),
1489            "Encoding" => Object::Name(b"WinAnsiEncoding".to_vec())
1490        })),
1491        doc.add_object(Object::Dictionary(dictionary! {
1492            "Type"     => Object::Name(b"Font".to_vec()),
1493            "Subtype"  => Object::Name(b"Type1".to_vec()),
1494            "BaseFont" => Object::Name(b"Courier".to_vec()),
1495            "Encoding" => Object::Name(b"WinAnsiEncoding".to_vec())
1496        })),
1497    ];
1498
1499    let existing_page_ids: Vec<ObjectId> = doc.page_iter().collect();
1500    let n_layout = overlays.len();
1501    let n_existing = existing_page_ids.len();
1502
1503    // XFA Spec 3.3 §9.1 — Static vs Dynamic Forms: a form is static (XFAF)
1504    // when it uses only the restricted XFAF grammar subset (§7.6).  In
1505    // practice, Adobe identifies static forms by `baseProfile="interactiveForms"`
1506    // on the <template> element.  A dynamic form uses the full XFA grammar
1507    // and re-lays out content based on data/scripts.
1508    //
1509    // §7.6 enumerates grammar excluded from XFAF: area, occur (non-default),
1510    // multiple pageAreas, scripts that modify instance count, etc.
1511    //
1512    // Our detection uses baseProfile — this matches Adobe's behavior.  A more
1513    // rigorous check would inspect the template grammar for XFAF-excluded
1514    // elements, but baseProfile is the standard signal in real-world PDFs.
1515    let is_static_form = template_xml.contains("baseProfile=\"interactiveForms\"");
1516    let has_static_content = pages_have_static_content(&doc);
1517
1518    // Preserve pre-rendered PDF page content when:
1519    // 1. Explicit static form (baseProfile="interactiveForms"), OR
1520    // 2. Pages have substantial pre-rendered content AND the XFA layout
1521    //    produces at least as many pages as the original AND the XFA overlay
1522    //    has enough content to indicate a full page re-render.
1523    // 3. Layout engine produces fewer pages than the original — regardless
1524    //    of whether we detect static content in page streams. XFA PDFs often
1525    //    have form content in widget annotations rather than page content
1526    //    streams, so `has_static_content` may return false even when pages
1527    //    have substantial pre-rendered form content. When our layout is
1528    //    incomplete (fewer pages), preserving the original pages matches
1529    //    Adobe/pdfRest output better than truncated single-page output.
1530    //
1531    //    When the XFA overlay is minimal (e.g. just a title/header), the form
1532    //    relies on AcroForm widgets for its content. Preserving static content
1533    //    + baking widgets adds spurious form fields. Using the XFA path gives
1534    //    the correct minimal output matching pdfrest/Adobe behavior.
1535    //    When the XFA overlay is substantial (re-renders the full page), the
1536    //    pre-rendered content is authoritative — replacing it with XFA causes
1537    //    SSIM regressions due to font/rendering differences.
1538    //
1539    // The 1000-byte threshold separates minimal XFA templates (title/header
1540    // only, ~200-500 bytes) from full page re-renders (5000+ bytes).
1541    let overlay_is_substantial = overlays.iter().any(|o| o.content_stream.len() > 1000);
1542    // Clamp 1-page over-pagination only for static XFAF forms. Dynamic forms
1543    // often start from a 1-page placeholder PDF and legitimately flow onto
1544    // additional pages once XFA data is laid out. Clamping all 1-page inputs
1545    // to the original page count causes under-pagination on dynamic forms such
1546    // as Travel Expense Report / Checklist where Adobe renders 2-3 pages.
1547    //
1548    let preserve_static =
1549        is_static_form || n_layout < n_existing || has_static_content && overlay_is_substantial;
1550
1551    // PIPELINE: stage 5 — Write (write content streams to PDF pages)
1552    debug_assert!(
1553        _stage <= PipelineStage::Write,
1554        "pipeline stage order violated: expected <= Write"
1555    );
1556    _stage = PipelineStage::Write;
1557
1558    let mut trace_widgets_baked = 0usize;
1559    let mut trace_excess_deleted = 0usize;
1560    if preserve_static {
1561        let baked = flatten_widget_appearances(&mut doc);
1562        trace_widgets_baked = baked;
1563        if baked == 0 {
1564            // No widget APs were baked — the form structure lives in the
1565            // pre-rendered page content but field values exist only in the
1566            // XFA overlay.  Generate a lightweight overlay with just field
1567            // value text (no backgrounds/borders/captions) and append it
1568            // on top so field values become visible without visual artifacts.
1569            if let Ok(fv_overlays) = generate_field_values_overlays(&layout, &config) {
1570                for (i, overlay) in fv_overlays.iter().enumerate() {
1571                    if i < n_existing && !overlay.content_stream.is_empty() {
1572                        let _ = overlay_page_content(
1573                            &mut doc,
1574                            existing_page_ids[i],
1575                            overlay,
1576                            &font_ids,
1577                            &embedded_font_objects,
1578                        );
1579                    }
1580                }
1581            }
1582        }
1583        // When widgets WERE baked, their AP streams already contain field
1584        // content.  Overlaying XFA on top of baked widget appearances
1585        // causes ghost/double text because widget APs may contain rotation
1586        // matrices that produce differently-positioned text.
1587    } else {
1588        // Dynamic form: the layout engine determines page count.
1589        // Write each layout page to the output: overwrite existing pages
1590        // and add new pages when the layout produces more than the original.
1591        // NOTE: page cap (n_layout.min(n_existing)) was removed — it caused
1592        // 30 GATE #12 regressions because dynamic XFA forms often have a
1593        // single placeholder page while the actual form has many data-driven
1594        // pages. Capping to n_existing destroyed multi-page content.
1595        for (i, overlay) in overlays.iter().enumerate() {
1596            if i < n_existing {
1597                let lp = &layout.pages[i];
1598                write_page_content(
1599                    &mut doc,
1600                    existing_page_ids[i],
1601                    overlay,
1602                    &font_ids,
1603                    &embedded_font_objects,
1604                    Some(lp.width),
1605                    Some(lp.height),
1606                )?;
1607            } else {
1608                let lp = &layout.pages[i];
1609                add_new_page(
1610                    &mut doc,
1611                    lp.width,
1612                    lp.height,
1613                    overlay,
1614                    &font_ids,
1615                    &embedded_font_objects,
1616                )?;
1617            }
1618        }
1619
1620        // Bake checkbox/radio AP marks from AcroForm widgets onto existing
1621        // pages.  The XFA overlay draws borders and captions; the AP "on"
1622        // stream adds the filled mark (circle, checkmark, etc.) that the
1623        // oracle renders for hybrid forms (#886).
1624        for &page_id in &existing_page_ids[..n_existing.min(n_layout)] {
1625            bake_checkbox_radio_ap_marks(&mut doc, page_id);
1626        }
1627    }
1628
1629    // Remove excess pages when XFA layout produces fewer pages than the
1630    // original static content. This is the core fix for over-pagination
1631    // (#744): XFA PDFs often carry pre-rendered static pages that far exceed
1632    // the dynamic page count Adobe would produce.
1633    // But for static/hybrid forms (preserve_static), keep all original pages —
1634    // the static content lives in the PDF page streams, not in XFA draw
1635    // elements (#750).
1636    if n_layout < n_existing && !preserve_static {
1637        // delete_pages takes 1-indexed page numbers, highest first to avoid
1638        // index shifts.
1639        let excess: Vec<u32> = ((n_layout + 1) as u32..=(n_existing as u32))
1640            .rev()
1641            .collect();
1642        trace_excess_deleted = excess.len();
1643        doc.delete_pages(&excess);
1644    }
1645
1646    if is_static_form {
1647        // Static forms: strip Widget annotations but keep non-Widget (links,
1648        // stamps, etc.).  flatten_widget_appearances already baked widgets
1649        // with AP into the page content and removed them from Annots, but
1650        // widgets without AP may remain.  Remove those too so PDF viewers
1651        // don't render interactive fields over the baked content.
1652        for &page_id in &existing_page_ids {
1653            strip_widget_annotations(&mut doc, page_id);
1654        }
1655    } else {
1656        // Dynamic/hybrid forms: strip ALL annotations — pages were
1657        // overwritten by XFA layout or widget baking covered field values.
1658        for &page_id in existing_page_ids.iter().take(n_layout.min(n_existing)) {
1659            if let Ok(Object::Dictionary(ref mut dict)) = doc.get_object_mut(page_id) {
1660                dict.remove(b"Annots");
1661            }
1662        }
1663    }
1664
1665    // PIPELINE: stage 6 — Cleanup (remove AcroForm/XFA markers)
1666    debug_assert!(
1667        _stage <= PipelineStage::Cleanup,
1668        "pipeline stage order violated: expected <= Cleanup"
1669    );
1670    #[allow(unused_assignments)]
1671    {
1672        _stage = PipelineStage::Cleanup;
1673    }
1674
1675    remove_acroform(&mut doc);
1676    let stripped_js = javascript_policy::strip_javascript_for_flatten(&mut doc);
1677    if stripped_js > 0 {
1678        log::warn!("stripped {stripped_js} JavaScript action(s) from flattened output");
1679    }
1680
1681    // Env-gated flatten trace (default OFF): capture stage signals BEFORE the
1682    // document is consumed by serialization. Built only when XFA_FLATTEN_TRACE set.
1683    let trace_ctx = if flatten_trace::enabled() {
1684        let (acroform_removed, xfa_removed_structural, needs_rendering_removed) =
1685            catalog_cleanup_status(&doc);
1686        Some((
1687            acroform_removed,
1688            xfa_removed_structural,
1689            needs_rendering_removed,
1690            doc.page_iter().count(),
1691            layout
1692                .pages
1693                .iter()
1694                .filter(|p| p.runtime_instantiated)
1695                .count(),
1696        ))
1697    } else {
1698        None
1699    };
1700
1701    let mut out = Vec::new();
1702    doc.save_to(&mut out)
1703        .map_err(|e| XfaError::LayoutFailed(format!("save: {e}")))?;
1704
1705    if let Some((
1706        acroform_removed,
1707        xfa_removed_structural,
1708        needs_rendering_removed,
1709        output_page_count,
1710        runtime_pages,
1711    )) = trace_ctx
1712    {
1713        let js_mode =
1714            std::env::var("XFA_JS_EXECUTION_MODE").unwrap_or_else(|_| "best_effort_static".into());
1715        flatten_trace::emit(&flatten_trace::TraceInputs {
1716            suppression: &trace_suppression,
1717            input_bytes: pdf_bytes.len(),
1718            template_bytes: template_xml.len(),
1719            js_execution_mode: &js_mode,
1720            flatten_path: if preserve_static {
1721                "static_preserve"
1722            } else {
1723                "dynamic"
1724            },
1725            template_packet_found: true,
1726            datasets_packet_found: datasets_xml.is_some(),
1727            form_packet_found: form_xml.is_some(),
1728            image_files: trace_image_files,
1729            tree: &tree,
1730            scripts: &dynamic_scripts,
1731            layout: &layout,
1732            pages_produced: trace_pages_produced,
1733            pages_after_suppression: layout.pages.len(),
1734            runtime_instantiated_pages: runtime_pages,
1735            overlays: &overlays,
1736            n_layout,
1737            n_existing,
1738            is_static_form,
1739            has_static_content,
1740            preserve_static,
1741            excess_pages_deleted: trace_excess_deleted,
1742            widgets_baked: trace_widgets_baked,
1743            acroform_removed,
1744            xfa_removed_structural,
1745            needs_rendering_removed,
1746            javascript_actions_stripped: stripped_js,
1747            output_bytes: out.len(),
1748            output_page_count,
1749        });
1750    }
1751
1752    // Epic A E-5: patch match-failure data into the dynamic_scripts outcome.
1753    let mut dynamic_scripts = dynamic_scripts;
1754    dynamic_scripts.form_dom_match_failures = form_dom_match_failures;
1755    dynamic_scripts.form_dom_match_log = form_dom_match_log;
1756
1757    let mut flatten_out = FlattenOutput::new(out, layout_dump.unwrap_or_default(), dynamic_scripts);
1758    flatten_out.metadata.fresh_merge_admitted_nodes = fresh_merge_admitted;
1759    Ok(flatten_out)
1760}
1761
1762/// Inspect the catalog after cleanup: returns
1763/// `(acroform_removed, xfa_removed_structural, needs_rendering_removed)`.
1764/// Used by the env-gated flatten trace to confirm structural XFA removal.
1765fn catalog_cleanup_status(doc: &Document) -> (bool, bool, bool) {
1766    let root_id = match doc.trailer.get(b"Root") {
1767        Ok(Object::Reference(id)) => *id,
1768        _ => return (true, true, true),
1769    };
1770    let Ok(cat) = doc.get_dictionary(root_id) else {
1771        return (true, true, true);
1772    };
1773    let acroform_present = cat.get(b"AcroForm").is_ok();
1774    let needs_rendering_present = cat.get(b"NeedsRendering").is_ok();
1775    let direct_xfa = cat.get(b"XFA").is_ok();
1776    let acroform_xfa = cat
1777        .get(b"AcroForm")
1778        .ok()
1779        .and_then(|o| match o {
1780            Object::Reference(id) => doc.get_dictionary(*id).ok(),
1781            Object::Dictionary(d) => Some(d),
1782            _ => None,
1783        })
1784        .map(|d| d.get(b"XFA").is_ok())
1785        .unwrap_or(false);
1786    (
1787        !acroform_present,
1788        !(direct_xfa || acroform_xfa),
1789        !needs_rendering_present,
1790    )
1791}
1792
1793fn layout_dump_from_profile(profile: LayoutProfile) -> LayoutDump {
1794    LayoutDump {
1795        pages: profile
1796            .pages
1797            .into_iter()
1798            .enumerate()
1799            .map(|(idx, page)| LayoutDumpEntry {
1800                page_num: idx as u32 + 1,
1801                page_height: page.page_height,
1802                used_height: page.used_height,
1803                overflow_to_next: page.overflow_to_next,
1804                first_overflow_element: page.first_overflow_element,
1805            })
1806            .collect(),
1807        ..Default::default()
1808    }
1809}
1810
1811fn renumber_layout_dump_pages(dump: &mut LayoutDump) {
1812    for (idx, page) in dump.pages.iter_mut().enumerate() {
1813        page.page_num = idx as u32 + 1;
1814    }
1815}
1816
1817// ---------------------------------------------------------------------------
1818// Embedded image files extraction (XFA §2.3 href resolution)
1819// ---------------------------------------------------------------------------
1820
1821/// Extract embedded files from the PDF's Names/EmbeddedFiles tree.
1822///
1823/// XFA `<image href=".\filename.jpg">` references are resolved against this
1824/// tree at merge time (XFA Spec 3.3 §2.3).  The returned map is keyed by
1825/// the filename as it appears in the Names array (e.g. `.\lintje.jpg`).
1826pub(crate) fn extract_embedded_images(doc: &Document) -> HashMap<String, Vec<u8>> {
1827    let mut images = HashMap::new();
1828
1829    // Helper: resolve a potentially indirect object.
1830    fn deref_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> {
1831        match obj {
1832            Object::Reference(id) => doc.get_dictionary(*id).ok(),
1833            Object::Dictionary(d) => Some(d),
1834            _ => None,
1835        }
1836    }
1837
1838    // Helper: extract stream content (decompressed).
1839    fn extract_stream(doc: &Document, obj: &Object) -> Option<Vec<u8>> {
1840        let stream_obj = match obj {
1841            Object::Reference(id) => doc.get_object(*id).ok()?,
1842            other => other,
1843        };
1844        if let Object::Stream(ref stream) = *stream_obj {
1845            let mut s = stream.clone();
1846            let _ = s.decompress();
1847            Some(s.content.clone())
1848        } else {
1849            None
1850        }
1851    }
1852
1853    // Traverse: Catalog → /Names → /EmbeddedFiles → /Names array
1854    let catalog = match doc.catalog() {
1855        Ok(c) => c,
1856        Err(_) => return images,
1857    };
1858    let names_obj = match catalog.get(b"Names") {
1859        Ok(obj) => obj,
1860        Err(_) => {
1861            eprintln!("[img-href] no /Names in catalog");
1862            return images;
1863        }
1864    };
1865    let names_dict = match deref_dict(doc, names_obj) {
1866        Some(d) => d,
1867        None => return images,
1868    };
1869    // XFA PDFs may use /XFAImages instead of /EmbeddedFiles for image
1870    // references.  Check both keys.
1871    let ef_obj = match names_dict
1872        .get(b"XFAImages")
1873        .or_else(|_| names_dict.get(b"EmbeddedFiles"))
1874    {
1875        Ok(obj) => obj,
1876        Err(_) => return images,
1877    };
1878    let ef_dict = match deref_dict(doc, ef_obj) {
1879        Some(d) => d,
1880        None => return images,
1881    };
1882
1883    // The EmbeddedFiles name tree has a /Names array: [(name1, ref1), …]
1884    let names_arr_obj = match ef_dict.get(b"Names") {
1885        Ok(obj) => obj,
1886        Err(_) => return images,
1887    };
1888    let names_array = match names_arr_obj {
1889        Object::Array(arr) => arr,
1890        Object::Reference(id) => match doc.get_object(*id) {
1891            Ok(Object::Array(arr)) => arr,
1892            _ => return images,
1893        },
1894        _ => return images,
1895    };
1896
1897    // Process pairs: (name_string, value_ref)
1898    let mut i = 0;
1899    while i + 1 < names_array.len() {
1900        let name = match &names_array[i] {
1901            Object::String(bytes, _) => String::from_utf8_lossy(bytes).to_string(),
1902            _ => {
1903                i += 2;
1904                continue;
1905            }
1906        };
1907
1908        // The value can be:
1909        //   1. A FileSpec dict: /EF → /F → stream
1910        //   2. Directly a stream (non-standard but seen in XFA PDFs)
1911        let value_ref = &names_array[i + 1];
1912
1913        // Try path 1: FileSpec dict
1914        if let Some(filespec) = deref_dict(doc, value_ref) {
1915            if let Ok(ef_obj) = filespec.get(b"EF") {
1916                if let Some(ef) = deref_dict(doc, ef_obj) {
1917                    if let Ok(f_ref) = ef.get(b"F") {
1918                        if let Some(data) = extract_stream(doc, f_ref) {
1919                            images.insert(name.clone(), data);
1920                            i += 2;
1921                            continue;
1922                        }
1923                    }
1924                }
1925            }
1926        }
1927
1928        // Try path 2: Direct stream reference
1929        if let Some(data) = extract_stream(doc, value_ref) {
1930            images.insert(name.clone(), data);
1931        }
1932
1933        i += 2;
1934    }
1935    images
1936}
1937
1938// ---------------------------------------------------------------------------
1939// Font extraction, resolution, and embedding
1940// ---------------------------------------------------------------------------
1941
1942/// Extract embedded font programs from a lopdf `Document`, including `/Widths`
1943/// arrays and encoding metadata.
1944///
1945/// This is the flatten-pipeline-internal variant. It differs from the public
1946/// `extract::extract_embedded_fonts` in three ways:
1947/// - Input type: `lopdf::Document` (lopdf object model) vs `pdf_syntax::Pdf`.
1948/// - Return type: [`EmbeddedFontData`] structs (with widths + encoding) vs
1949///   plain `(name, bytes)` tuples.
1950/// - Purpose: metric capture for layout + font embedding inside flatten.
1951///   Not intended for external callers; use `pdf_xfa::extract_embedded_fonts`
1952///   for inspection-only use cases.
1953///
1954/// Canonical public API: [`crate::extract::extract_embedded_fonts`].
1955#[doc(hidden)]
1956pub fn extract_embedded_fonts(doc: &Document) -> Vec<EmbeddedFontData> {
1957    let mut fonts = Vec::new();
1958    let mut seen = std::collections::HashSet::new();
1959    for (&font_object_id, obj) in &doc.objects {
1960        let dict = match obj.as_dict() {
1961            Ok(d) => d,
1962            Err(_) => continue,
1963        };
1964        let is_font =
1965            dict.get(b"Type").ok().and_then(|o| o.as_name().ok()) == Some(b"Font".as_slice());
1966        if !is_font {
1967            continue;
1968        }
1969        let base_font = match dict.get(b"BaseFont").ok().and_then(|o| o.as_name().ok()) {
1970            Some(n) => String::from_utf8_lossy(n).to_string(),
1971            None => continue,
1972        };
1973
1974        let pdf_widths = extract_font_widths(dict);
1975        let pdf_encoding = extract_font_encoding(doc, dict);
1976        let pdf_source_font =
1977            extract_simple_pdf_source_font(doc, font_object_id, dict, pdf_widths.as_ref());
1978
1979        // First try direct FontDescriptor path (simple TrueType/OpenType fonts)
1980        if let Some((stream_id, data)) = extract_font_from_direct_fd(doc, dict, &base_font) {
1981            if seen.insert(stream_id) {
1982                store_font_data(
1983                    &mut fonts,
1984                    &base_font,
1985                    data,
1986                    pdf_widths.clone(),
1987                    pdf_encoding.clone(),
1988                    pdf_source_font,
1989                );
1990            }
1991            continue;
1992        }
1993
1994        // For CIDFont Type0: also check DescendantFonts path
1995        // CIDFont fonts store their font data in /DescendantFonts[n]/FontDescriptor/FontFile*
1996        // CID fonts use /W arrays (PDF spec §9.7.4.3) instead of simple /Widths.
1997        if let Some((stream_id, data)) = extract_cidfont_data(doc, dict, &base_font, &seen) {
1998            if seen.insert(stream_id) {
1999                let cid_widths = extract_cid_font_widths(doc, dict);
2000                store_font_data(&mut fonts, &base_font, data, cid_widths, None, None);
2001            }
2002            continue;
2003        }
2004
2005        if let Some(source_font) = pdf_source_font {
2006            // fix(#811): if the source PDF already exposes a reusable simple
2007            // font object with /Widths, keep that object alive through the XFA
2008            // pipeline. PDF 1.7 §5.5 defines those widths as the authoritative
2009            // simple-font metrics, and XFA 3.3 §11.7.1 relies on those metrics
2010            // for field fitting.
2011            store_font_data(
2012                &mut fonts,
2013                &base_font,
2014                Vec::new(),
2015                pdf_widths.clone(),
2016                pdf_encoding.clone(),
2017                Some(source_font),
2018            );
2019        }
2020    }
2021    fonts
2022}
2023
2024/// Extract /FirstChar, /LastChar, and /Widths from a font dictionary.
2025fn extract_font_widths(dict: &lopdf::Dictionary) -> Option<(u16, Vec<u16>)> {
2026    let first_char = dict.get(b"FirstChar").ok()?.as_i64().ok()? as u16;
2027    let _last_char = dict.get(b"LastChar").ok()?.as_i64().ok()? as u16;
2028    let widths_array = dict.get(b"Widths").ok()?.as_array().ok()?;
2029    let widths: Vec<u16> = widths_array
2030        .iter()
2031        .filter_map(|w| w.as_i64().ok().map(|v| v as u16))
2032        .collect();
2033    if widths.is_empty() {
2034        return None;
2035    }
2036    Some((first_char, widths))
2037}
2038
2039/// Extract CID font widths from a Type0 (composite) font's `/W` array.
2040///
2041/// CID fonts (PDF spec §9.7.4.3, Table 114) use a different width format than
2042/// simple fonts. Instead of `/FirstChar` + `/Widths`, they use a `/W` array in
2043/// the CIDFont descendant dictionary with two element types:
2044///
2045///   `cid_start [w1 w2 w3 ...]`   — consecutive CIDs starting at cid_start
2046///   `cid_first cid_last width`   — range of CIDs all sharing the same width
2047///
2048/// `/DW` (default width, defaults to 1000) applies to CIDs not listed in `/W`.
2049///
2050/// The result is converted to the same `(first_char, widths)` representation
2051/// used by simple fonts, where `widths[cid - first_char]` gives the width.
2052///
2053/// LIMITATION: CID-to-Unicode mapping via ToUnicode CMap is not parsed here;
2054/// the widths are indexed by raw CID values.
2055fn extract_cid_font_widths(
2056    doc: &Document,
2057    type0_dict: &lopdf::Dictionary,
2058) -> Option<(u16, Vec<u16>)> {
2059    let descendants = type0_dict.get(b"DescendantFonts").ok()?.as_array().ok()?;
2060    let desc_ref = descendants.first()?;
2061    let cid_dict = match desc_ref {
2062        Object::Reference(id) => doc.get_dictionary(*id).ok()?,
2063        Object::Dictionary(d) => d,
2064        _ => return None,
2065    };
2066
2067    let default_width = cid_dict
2068        .get(b"DW")
2069        .ok()
2070        .and_then(|o| o.as_i64().ok())
2071        .unwrap_or(1000) as u16;
2072
2073    let w_array = cid_dict.get(b"W").ok()?;
2074    let w_array = match resolve_object(doc, w_array) {
2075        Some(obj) => obj.as_array().ok()?,
2076        None => return None,
2077    };
2078
2079    if w_array.is_empty() {
2080        return None;
2081    }
2082
2083    // First pass: collect all (cid, width) pairs to find bounds.
2084    let mut entries: Vec<(u16, u16)> = Vec::new();
2085    let mut i = 0;
2086    while i < w_array.len() {
2087        let cid_start = match w_array[i].as_i64() {
2088            Ok(v) => v as u16,
2089            Err(_) => {
2090                i += 1;
2091                continue;
2092            }
2093        };
2094        i += 1;
2095        if i >= w_array.len() {
2096            break;
2097        }
2098
2099        // Next element: array → consecutive widths, integer → range end
2100        if let Ok(widths_arr) = w_array[i].as_array() {
2101            // Format: cid_start [w1 w2 w3 ...]
2102            for (j, w_obj) in widths_arr.iter().enumerate() {
2103                if let Ok(w) = w_obj.as_i64() {
2104                    entries.push((cid_start + j as u16, w as u16));
2105                }
2106            }
2107            i += 1;
2108        } else if let Ok(cid_last) = w_array[i].as_i64() {
2109            // Format: cid_first cid_last width
2110            i += 1;
2111            if i >= w_array.len() {
2112                break;
2113            }
2114            if let Ok(width) = w_array[i].as_i64() {
2115                let cid_last = cid_last as u16;
2116                for cid in cid_start..=cid_last {
2117                    entries.push((cid, width as u16));
2118                }
2119            }
2120            i += 1;
2121        } else {
2122            i += 1;
2123        }
2124    }
2125
2126    if entries.is_empty() {
2127        return None;
2128    }
2129
2130    // SAFETY: entries is non-empty (guarded above), so min/max always yield Some.
2131    let min_cid = entries
2132        .iter()
2133        .map(|(c, _)| *c)
2134        .min()
2135        .expect("entries is non-empty");
2136    let max_cid = entries
2137        .iter()
2138        .map(|(c, _)| *c)
2139        .max()
2140        .expect("entries is non-empty");
2141    let len = (max_cid - min_cid + 1) as usize;
2142    let mut widths = vec![default_width; len];
2143    for (cid, w) in &entries {
2144        widths[(*cid - min_cid) as usize] = *w;
2145    }
2146
2147    Some((min_cid, widths))
2148}
2149
2150/// Parse a simple-font `/Encoding` dictionary with `/Differences`.
2151///
2152/// WHY: Custom encodings via `/Differences` are essential for correct glyph
2153/// width mapping. Without this, widths are indexed against the wrong
2154/// characters and text wrapping breaks for fonts that deviate from WinAnsi.
2155///
2156/// SPEC: PDF spec §9.6.6.1 defines `/Differences` as an alternating array of
2157/// starting code integers and glyph names applied on top of a base encoding.
2158///
2159/// LIMITATION: CID fonts (`/Type0`) use CMaps and `/W` arrays instead of this
2160/// simple-font encoding mechanism, so they intentionally return `None` here.
2161fn extract_font_encoding(doc: &Document, dict: &lopdf::Dictionary) -> Option<PdfSimpleEncoding> {
2162    let encoding_obj = resolve_object(doc, dict.get(b"Encoding").ok()?)?;
2163    let encoding_dict = encoding_obj.as_dict().ok()?;
2164    let differences_array = resolve_object(doc, encoding_dict.get(b"Differences").ok()?)?
2165        .as_array()
2166        .ok()?;
2167
2168    let base_encoding = encoding_dict
2169        .get(b"BaseEncoding")
2170        .ok()
2171        .and_then(|obj| resolve_object(doc, obj))
2172        .and_then(|obj| obj.as_name().ok())
2173        .and_then(PdfBaseEncoding::from_pdf_name)
2174        .unwrap_or(PdfBaseEncoding::WinAnsi);
2175
2176    let mut differences = Vec::new();
2177    let mut current_code: Option<u8> = None;
2178    for item in differences_array {
2179        let item = resolve_object(doc, item)?;
2180        if let Ok(code) = item.as_i64() {
2181            current_code = u8::try_from(code).ok();
2182            continue;
2183        }
2184
2185        let Some(name) = item.as_name().ok() else {
2186            continue;
2187        };
2188        let Some(code) = current_code else {
2189            continue;
2190        };
2191        let Some(glyph_name) = std::str::from_utf8(name).ok() else {
2192            continue;
2193        };
2194        if let Some(unicode) = pdf_glyph_name_to_unicode(glyph_name) {
2195            differences.push((code, unicode));
2196        }
2197        current_code = code.checked_add(1);
2198    }
2199
2200    if differences.is_empty() {
2201        return None;
2202    }
2203
2204    Some(PdfSimpleEncoding {
2205        base_encoding,
2206        differences,
2207    })
2208}
2209
2210fn extract_simple_pdf_source_font(
2211    doc: &Document,
2212    font_object_id: ObjectId,
2213    dict: &lopdf::Dictionary,
2214    pdf_widths: Option<&(u16, Vec<u16>)>,
2215) -> Option<PdfSourceFont> {
2216    pdf_widths?;
2217
2218    let subtype = dict.get(b"Subtype").ok().and_then(|obj| obj.as_name().ok());
2219    if subtype == Some(b"Type0".as_slice()) {
2220        return None;
2221    }
2222
2223    // fix(#811): only reuse simple fonts whose emitted PDF text can stay on
2224    // the current WinAnsi path in render_bridge. Fonts with custom encodings
2225    // need a dedicated byte encoder first; otherwise we would preserve widths
2226    // but emit the wrong character codes.
2227    //
2228    // PDF 1.7 §5.5 defines simple-font widths in the font's encoding space.
2229    // LIMITATION: CID/Type0 fonts use /W arrays and CMaps instead; they are
2230    // intentionally excluded here.
2231    let encoding_obj = dict
2232        .get(b"Encoding")
2233        .ok()
2234        .and_then(|obj| resolve_object(doc, obj));
2235    match encoding_obj {
2236        Some(obj) if obj.as_name().ok() == Some(b"WinAnsiEncoding".as_slice()) => {}
2237        Some(obj) => {
2238            let base = obj
2239                .as_dict()
2240                .ok()
2241                .and_then(|enc| enc.get(b"BaseEncoding").ok())
2242                .and_then(|base| resolve_object(doc, base))
2243                .and_then(|base| base.as_name().ok());
2244            if base != Some(b"WinAnsiEncoding".as_slice()) {
2245                return None;
2246            }
2247            if obj
2248                .as_dict()
2249                .ok()
2250                .and_then(|enc| enc.get(b"Differences").ok())
2251                .is_some()
2252            {
2253                return None;
2254            }
2255        }
2256        None => return None,
2257    }
2258
2259    Some(PdfSourceFont {
2260        object_id: font_object_id,
2261    })
2262}
2263
2264fn resolve_object<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Object> {
2265    match obj {
2266        Object::Reference(id) => doc.get_object(*id).ok(),
2267        other => Some(other),
2268    }
2269}
2270
2271/// Extract font data from a direct FontDescriptor (FontFile2/3/1 in FontDescriptor).
2272fn extract_font_from_direct_fd(
2273    doc: &Document,
2274    font_dict: &lopdf::Dictionary,
2275    _base_font: &str,
2276) -> Option<(lopdf::ObjectId, Vec<u8>)> {
2277    let fd_id = font_dict.get(b"FontDescriptor").ok()?.as_reference().ok()?;
2278    let fd = doc.get_dictionary(fd_id).ok()?;
2279
2280    let font_stream_id = fd
2281        .get(b"FontFile2")
2282        .or_else(|_| fd.get(b"FontFile3"))
2283        .or_else(|_| fd.get(b"FontFile"))
2284        .ok()?
2285        .as_reference()
2286        .ok()?;
2287
2288    let stream = doc
2289        .get_object(font_stream_id)
2290        .and_then(|o| o.as_stream())
2291        .ok()?;
2292
2293    let data = stream
2294        .get_plain_content()
2295        .unwrap_or_else(|_| stream.content.clone());
2296
2297    if data.is_empty() {
2298        return None;
2299    }
2300
2301    Some((font_stream_id, data))
2302}
2303
2304/// Extract font data from CIDFont Type0's DescendantFonts path.
2305///
2306/// CIDFont Type0 fonts have their font data in:
2307///   /DescendantFonts[n] /CIDFont /FontDescriptor /FontFile*
2308fn extract_cidfont_data(
2309    doc: &Document,
2310    font_dict: &lopdf::Dictionary,
2311    _base_font: &str,
2312    seen: &std::collections::HashSet<lopdf::ObjectId>,
2313) -> Option<(lopdf::ObjectId, Vec<u8>)> {
2314    // Check if this is a Type0 (composite) font by looking for DescendantFonts
2315    let descendants = font_dict.get(b"DescendantFonts").ok()?.as_array().ok()?;
2316
2317    // Iterate through descendant CIDFonts
2318    for desc_ref in descendants {
2319        let desc_id = desc_ref.as_reference().ok()?;
2320        let desc_dict = doc.get_dictionary(desc_id).ok()?;
2321
2322        // Check if this descendant is a CIDFont (has FontDescriptor)
2323        let fd_id = desc_dict.get(b"FontDescriptor").ok()?.as_reference().ok()?;
2324        let fd = doc.get_dictionary(fd_id).ok()?;
2325
2326        // Try FontFile3 first (CFF font for CIDFontType0C), then FontFile2 (TrueType)
2327        let font_stream_id = fd
2328            .get(b"FontFile3")
2329            .or_else(|_| fd.get(b"FontFile2"))
2330            .or_else(|_| fd.get(b"FontFile"))
2331            .ok()?
2332            .as_reference()
2333            .ok()?;
2334
2335        if seen.contains(&font_stream_id) {
2336            continue;
2337        }
2338
2339        let stream = doc
2340            .get_object(font_stream_id)
2341            .and_then(|o| o.as_stream())
2342            .ok()?;
2343
2344        let data = stream
2345            .get_plain_content()
2346            .unwrap_or_else(|_| stream.content.clone());
2347
2348        if !data.is_empty() {
2349            return Some((font_stream_id, data));
2350        }
2351    }
2352    None
2353}
2354
2355/// Store font data under multiple names (PostScript name, family name, normalized name).
2356fn store_font_data(
2357    fonts: &mut Vec<EmbeddedFontData>,
2358    base_font: &str,
2359    data: Vec<u8>,
2360    pdf_widths: Option<(u16, Vec<u16>)>,
2361    pdf_encoding: Option<PdfSimpleEncoding>,
2362    pdf_source_font: Option<PdfSourceFont>,
2363) {
2364    let clean_name = if let Some(pos) = base_font.find('+') {
2365        base_font[pos + 1..].to_string()
2366    } else {
2367        base_font.to_string()
2368    };
2369    let allow_family_alias = family_alias_is_regular_face(&clean_name, &data);
2370
2371    // Store under the PostScript name (subset prefix already stripped)
2372    fonts.push(EmbeddedFontData {
2373        name: clean_name.clone(),
2374        data: data.clone(),
2375        pdf_widths: pdf_widths.clone(),
2376        pdf_encoding: pdf_encoding.clone(),
2377        pdf_source_font,
2378    });
2379
2380    // Store additional aliases from the font name table. The bare family name
2381    // (e.g. "Arial") is only attached to the regular face so a normal-weight
2382    // XFA request does not get hijacked by a bold/italic variant.
2383    if let Ok(face) = ttf_parser::Face::parse(&data, 0) {
2384        for name_record in face.names() {
2385            let allow_alias = match name_record.name_id {
2386                ttf_parser::name_id::FAMILY => allow_family_alias,
2387                ttf_parser::name_id::FULL_NAME | ttf_parser::name_id::POST_SCRIPT_NAME => true,
2388                _ => false,
2389            };
2390            if !allow_alias {
2391                continue;
2392            }
2393            if let Some(alias) = name_record.to_string() {
2394                if alias != clean_name {
2395                    fonts.push(EmbeddedFontData {
2396                        name: alias,
2397                        data: data.clone(),
2398                        pdf_widths: pdf_widths.clone(),
2399                        pdf_encoding: pdf_encoding.clone(),
2400                        pdf_source_font,
2401                    });
2402                }
2403            }
2404        }
2405    }
2406
2407    // Common PostScript-to-family normalization as fallback. As with the name
2408    // table family alias above, reserve the bare family name for the regular
2409    // face so `Arial` resolves to `ArialMT` rather than `Arial-BoldMT`.
2410    let normalized = ps_name_to_family(&clean_name);
2411    if allow_family_alias && normalized != clean_name {
2412        fonts.push(EmbeddedFontData {
2413            name: normalized,
2414            data,
2415            pdf_widths,
2416            pdf_encoding,
2417            pdf_source_font,
2418        });
2419    }
2420}
2421
2422fn family_alias_is_regular_face(clean_name: &str, data: &[u8]) -> bool {
2423    if let Ok(face) = ttf_parser::Face::parse(data, 0) {
2424        if face.is_bold() || face.is_italic() {
2425            return false;
2426        }
2427    }
2428
2429    let lower = clean_name.to_ascii_lowercase();
2430    !lower.contains("bold") && !lower.contains("italic") && !lower.contains("oblique")
2431}
2432
2433/// Convert a PostScript font name to its likely family name.
2434///
2435/// Examples: `ArialMT` → `Arial`, `TimesNewRomanPSMT` → `Times New Roman`,
2436/// `MyriadPro-Regular` → `Myriad Pro`.
2437fn ps_name_to_family(ps_name: &str) -> String {
2438    // Strip weight/style suffixes first
2439    let base = ps_name
2440        .strip_suffix("PSMT")
2441        .or_else(|| ps_name.strip_suffix("PS-BoldItalicMT"))
2442        .or_else(|| ps_name.strip_suffix("PS-BoldMT"))
2443        .or_else(|| ps_name.strip_suffix("PS-ItalicMT"))
2444        .or_else(|| ps_name.strip_suffix("-BoldItalicMT"))
2445        .or_else(|| ps_name.strip_suffix("-BoldMT"))
2446        .or_else(|| ps_name.strip_suffix("-ItalicMT"))
2447        .or_else(|| ps_name.strip_suffix("MT"))
2448        .or_else(|| ps_name.strip_suffix("-Regular"))
2449        .or_else(|| ps_name.strip_suffix("-Bold"))
2450        .or_else(|| ps_name.strip_suffix("-Italic"))
2451        .or_else(|| ps_name.strip_suffix("-BoldItalic"))
2452        .unwrap_or(ps_name);
2453    // Insert spaces before uppercase letters that follow a lowercase letter
2454    // e.g. "TimesNewRoman" → "Times New Roman", "MyriadPro" → "Myriad Pro"
2455    let mut result = String::with_capacity(base.len() + 4);
2456    for (i, ch) in base.chars().enumerate() {
2457        if i > 0 && ch.is_uppercase() {
2458            let prev = base.as_bytes()[i - 1] as char;
2459            if prev.is_lowercase() {
2460                result.push(' ');
2461            }
2462        }
2463        result.push(ch);
2464    }
2465    result
2466}
2467
2468/// Collected font specification from the XFA template.
2469struct TemplateFontEntry {
2470    typeface: String,
2471    weight: Option<String>,
2472    posture: Option<String>,
2473    generic_family: Option<String>,
2474}
2475
2476fn collect_template_font_entries(template_xml: &str) -> Vec<TemplateFontEntry> {
2477    let mut entries = Vec::new();
2478    let mut seen = std::collections::HashSet::new();
2479    if let Ok(xml_doc) = roxmltree::Document::parse(template_xml) {
2480        for node in xml_doc.descendants() {
2481            if node.tag_name().name() == "font" {
2482                if let Some(typeface) = node.attribute("typeface") {
2483                    let name = typeface.to_string();
2484                    let weight = node.attribute("weight").map(|s| s.to_string());
2485                    let posture = node.attribute("posture").map(|s| s.to_string());
2486                    let generic_family = node.attribute("genericFamily").map(|s| s.to_string());
2487                    let key = font_variant_key(&name, weight.as_deref(), posture.as_deref());
2488                    if !name.is_empty() && seen.insert(key.to_lowercase()) {
2489                        entries.push(TemplateFontEntry {
2490                            typeface: name,
2491                            weight,
2492                            posture,
2493                            generic_family,
2494                        });
2495                    }
2496                }
2497            }
2498        }
2499    }
2500    entries
2501}
2502
2503fn embed_font_in_pdf(doc: &mut Document, font: &ResolvedFont) -> ObjectId {
2504    let font_stream = Stream::new(
2505        dictionary! {
2506            "Length" => Object::Integer(font.data.len() as i64),
2507            "Length1" => Object::Integer(font.data.len() as i64)
2508        },
2509        font.data.clone(),
2510    );
2511    let font_file_id = doc.add_object(Object::Stream(font_stream));
2512
2513    let upem = font.units_per_em as f64;
2514    let scale = 1000.0 / upem.max(1.0);
2515    let ascent = (font.ascender as f64 * scale) as i64;
2516    let descent = (font.descender as f64 * scale) as i64;
2517    let cap_height = (ascent as f64 * 0.7) as i64;
2518    let base_name = font.name.replace(' ', "-");
2519
2520    let fd = dictionary! {
2521        "Type" => Object::Name(b"FontDescriptor".to_vec()),
2522        "FontName" => Object::Name(base_name.as_bytes().to_vec()),
2523        "Flags" => Object::Integer(32),
2524        "FontBBox" => Object::Array(vec![
2525            Object::Integer(0),
2526            Object::Integer(descent),
2527            Object::Integer(1000),
2528            Object::Integer(ascent),
2529        ]),
2530        "ItalicAngle" => Object::Integer(0),
2531        "Ascent" => Object::Integer(ascent),
2532        "Descent" => Object::Integer(descent),
2533        "CapHeight" => Object::Integer(cap_height),
2534        "StemV" => Object::Integer(80),
2535        "FontFile2" => Object::Reference(font_file_id)
2536    };
2537    let fd_id = doc.add_object(Object::Dictionary(fd));
2538
2539    // Build CID font data for Identity-H encoding.
2540    let cid_info = font.cid_font_info().unwrap_or(CidFontInfo {
2541        widths: vec![500],
2542        gid_to_unicode: vec![],
2543    });
2544
2545    // /W array: [ 0 [w0 w1 w2 ... wN] ]
2546    let widths_inner: Vec<Object> = cid_info
2547        .widths
2548        .iter()
2549        .map(|&w| Object::Integer(w as i64))
2550        .collect();
2551    let w_array = vec![Object::Integer(0), Object::Array(widths_inner)];
2552
2553    let cid_font = dictionary! {
2554        "Type" => Object::Name(b"Font".to_vec()),
2555        "Subtype" => Object::Name(b"CIDFontType2".to_vec()),
2556        "BaseFont" => Object::Name(base_name.as_bytes().to_vec()),
2557        "CIDSystemInfo" => Object::Dictionary(dictionary! {
2558            "Registry" => Object::String(b"Adobe".to_vec(), StringFormat::Literal),
2559            "Ordering" => Object::String(b"Identity".to_vec(), StringFormat::Literal),
2560            "Supplement" => Object::Integer(0)
2561        }),
2562        "FontDescriptor" => Object::Reference(fd_id),
2563        "W" => Object::Array(w_array),
2564        "CIDToGIDMap" => Object::Name(b"Identity".to_vec())
2565    };
2566    let cid_font_id = doc.add_object(Object::Dictionary(cid_font));
2567
2568    // ToUnicode CMap for text extraction / copy-paste.
2569    let tounicode_data = generate_tounicode_cmap(&cid_info.gid_to_unicode);
2570    let tounicode_stream = Stream::new(
2571        dictionary! { "Length" => Object::Integer(tounicode_data.len() as i64) },
2572        tounicode_data,
2573    );
2574    let tounicode_id = doc.add_object(Object::Stream(tounicode_stream));
2575
2576    // Type0 (composite) font with Identity-H encoding.
2577    let type0_font = dictionary! {
2578        "Type" => Object::Name(b"Font".to_vec()),
2579        "Subtype" => Object::Name(b"Type0".to_vec()),
2580        "BaseFont" => Object::Name(base_name.as_bytes().to_vec()),
2581        "Encoding" => Object::Name(b"Identity-H".to_vec()),
2582        "DescendantFonts" => Object::Array(vec![Object::Reference(cid_font_id)]),
2583        "ToUnicode" => Object::Reference(tounicode_id)
2584    };
2585    doc.add_object(Object::Dictionary(type0_font))
2586}
2587
2588/// Generate a ToUnicode CMap stream mapping glyph IDs to Unicode codepoints.
2589fn generate_tounicode_cmap(gid_to_unicode: &[(u16, char)]) -> Vec<u8> {
2590    let mut cmap = String::with_capacity(gid_to_unicode.len() * 24 + 256);
2591    cmap.push_str("/CIDInit /ProcSet findresource begin\n");
2592    cmap.push_str("12 dict begin\n");
2593    cmap.push_str("begincmap\n");
2594    cmap.push_str("/CIDSystemInfo\n");
2595    cmap.push_str("<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n");
2596    cmap.push_str("/CMapName /Adobe-Identity-UCS def\n");
2597    cmap.push_str("/CMapType 2 def\n");
2598    cmap.push_str("1 begincodespacerange\n");
2599    cmap.push_str("<0000> <FFFF>\n");
2600    cmap.push_str("endcodespacerange\n");
2601    for chunk in gid_to_unicode.chunks(100) {
2602        let _ = writeln!(cmap, "{} beginbfchar", chunk.len());
2603        for &(gid, ch) in chunk {
2604            let _ = writeln!(cmap, "<{:04X}> <{:04X}>", gid, ch as u32);
2605        }
2606        cmap.push_str("endbfchar\n");
2607    }
2608    cmap.push_str("endcmap\n");
2609    cmap.push_str("CMapName currentdict /CMap defineresource pop\n");
2610    cmap.push_str("end\nend\n");
2611    cmap.into_bytes()
2612}
2613
2614/// Resolve all fonts referenced in the XFA template without embedding them.
2615///
2616/// Returns a map from variant key to `ResolvedFont`. The key encodes typeface,
2617/// weight, and posture so that "Arial bold" and "Arial regular" are resolved
2618/// separately. Called BEFORE layout so that resolved metrics can be injected
2619/// into the `FormTree`.
2620pub(crate) fn resolve_template_fonts(
2621    template_xml: &str,
2622    pdf_bytes: &[u8],
2623) -> HashMap<String, ResolvedFont> {
2624    let mut resolved = HashMap::new();
2625    let entries = collect_template_font_entries(template_xml);
2626    if entries.is_empty() {
2627        return resolved;
2628    }
2629    let source_doc = match Document::load_mem(pdf_bytes) {
2630        Ok(d) => d,
2631        Err(_) => return resolved,
2632    };
2633    let embedded_fonts = extract_embedded_fonts(&source_doc);
2634    let mut resolver = XfaFontResolver::new(embedded_fonts);
2635    for entry in &entries {
2636        let spec = XfaFontSpec::from_xfa_attrs(
2637            &entry.typeface,
2638            entry.weight.as_deref(),
2639            entry.posture.as_deref(),
2640            None,
2641            entry.generic_family.as_deref(),
2642        );
2643        let key = font_variant_key(
2644            &entry.typeface,
2645            entry.weight.as_deref(),
2646            entry.posture.as_deref(),
2647        );
2648        match resolver.resolve(&spec) {
2649            Ok(font) => {
2650                resolved.insert(key, font);
2651            }
2652            Err(e) => {
2653                eprintln!("Font resolution failed for '{}': {}", entry.typeface, e);
2654            }
2655        }
2656    }
2657    resolved
2658}
2659
2660/// Inject resolved font metrics into the FormTree before layout.
2661///
2662/// For each node whose style metadata carries a `font_family`, looks up the
2663/// matching `ResolvedFont` (using the variant key that includes weight/posture)
2664/// and populates the `resolved_widths`, `resolved_upem`, `resolved_ascender`,
2665/// and `resolved_descender` fields on the node's `FontMetrics`.
2666/// This makes `measure_width()` and `line_height_pt()` in the layout engine use
2667/// actual font data instead of generic AFM tables.
2668pub(crate) fn inject_resolved_metrics(
2669    tree: &mut xfa_layout_engine::form::FormTree,
2670    resolved: &HashMap<String, ResolvedFont>,
2671) {
2672    for i in 0..tree.nodes.len() {
2673        let id = xfa_layout_engine::form::FormNodeId(i);
2674        let style = &tree.meta(id).style;
2675        let font_family = style.font_family.clone();
2676        let font_weight = style.font_weight.clone();
2677        let font_style = style.font_style.clone();
2678        if let Some(ref family) = font_family {
2679            // Try variant-specific key first, then fall back to base key.
2680            let variant_key =
2681                font_variant_key(family, font_weight.as_deref(), font_style.as_deref());
2682            let base_key = font_variant_key(family, None, None);
2683            let font = resolved
2684                .get(&variant_key)
2685                .or_else(|| resolved.get(&base_key));
2686            if let Some(font) = font {
2687                let (_first_char, widths) = font.pdf_glyph_widths();
2688                let node = tree.get_mut(id);
2689                node.font.resolved_widths = Some(widths);
2690                node.font.resolved_upem = Some(font.units_per_em);
2691                node.font.resolved_ascender = Some(font.ascender);
2692                node.font.resolved_descender = Some(font.descender);
2693            }
2694        }
2695    }
2696}
2697
2698/// Embed already-resolved fonts into the PDF document.
2699///
2700/// Called AFTER layout. Returns the font_map (typeface -> PDF resource name),
2701/// the font objects for page resources, and the metrics data for render_bridge.
2702fn simple_encoding_unicode_to_code_map(encoding: &PdfSimpleEncoding) -> HashMap<u16, u8> {
2703    let mut map = HashMap::new();
2704    for (code, unicode) in encoding.code_to_unicode_table().into_iter().enumerate() {
2705        if let Some(cp) = unicode {
2706            map.entry(cp).or_insert(code as u8);
2707        }
2708    }
2709    map
2710}
2711
2712fn add_text_chars_for_font(
2713    chars_by_font: &mut HashMap<String, HashSet<char>>,
2714    font_family: Option<&str>,
2715    font_weight: Option<&str>,
2716    font_style: Option<&str>,
2717    text: &str,
2718) {
2719    let Some(family) = font_family else {
2720        return;
2721    };
2722    if text.is_empty() {
2723        return;
2724    }
2725    let chars: Vec<char> = text.chars().filter(|c| !c.is_control()).collect();
2726    if chars.is_empty() {
2727        return;
2728    }
2729
2730    let variant = font_variant_key(family, font_weight, font_style);
2731    chars_by_font
2732        .entry(variant)
2733        .or_default()
2734        .extend(chars.iter().copied());
2735    chars_by_font
2736        .entry(family.to_string())
2737        .or_default()
2738        .extend(chars);
2739}
2740
2741fn add_text_chars_for_style(
2742    chars_by_font: &mut HashMap<String, HashSet<char>>,
2743    style: &FormNodeStyle,
2744    text: &str,
2745) {
2746    add_text_chars_for_font(
2747        chars_by_font,
2748        style.font_family.as_deref(),
2749        style.font_weight.as_deref(),
2750        style.font_style.as_deref(),
2751        text,
2752    );
2753}
2754
2755fn collect_used_chars_from_layout_node(
2756    node: &LayoutNode,
2757    chars_by_font: &mut HashMap<String, HashSet<char>>,
2758) {
2759    match &node.content {
2760        LayoutContent::Text(t) => add_text_chars_for_style(chars_by_font, &node.style, t),
2761        LayoutContent::Field { value, .. } => {
2762            add_text_chars_for_style(chars_by_font, &node.style, value)
2763        }
2764        LayoutContent::WrappedText { lines, .. } => {
2765            for line in lines {
2766                add_text_chars_for_style(chars_by_font, &node.style, line);
2767            }
2768        }
2769        LayoutContent::Draw(DrawContent::Text(t)) => {
2770            add_text_chars_for_style(chars_by_font, &node.style, t)
2771        }
2772        _ => {}
2773    }
2774
2775    if let Some(caption) = &node.style.caption_text {
2776        add_text_chars_for_style(chars_by_font, &node.style, caption);
2777    }
2778
2779    if let Some(spans) = &node.style.rich_text_spans {
2780        for span in spans {
2781            add_text_chars_for_font(
2782                chars_by_font,
2783                span.font_family
2784                    .as_deref()
2785                    .or(node.style.font_family.as_deref()),
2786                span.font_weight
2787                    .as_deref()
2788                    .or(node.style.font_weight.as_deref()),
2789                span.font_style
2790                    .as_deref()
2791                    .or(node.style.font_style.as_deref()),
2792                &span.text,
2793            );
2794        }
2795    }
2796
2797    for child in &node.children {
2798        collect_used_chars_from_layout_node(child, chars_by_font);
2799    }
2800}
2801
2802fn collect_used_chars_by_font(layout: &LayoutDom) -> HashMap<String, HashSet<char>> {
2803    let mut chars_by_font = HashMap::new();
2804    for page in &layout.pages {
2805        for node in &page.nodes {
2806            collect_used_chars_from_layout_node(node, &mut chars_by_font);
2807        }
2808    }
2809    chars_by_font
2810}
2811
2812fn simple_font_can_encode_char(font: &ResolvedFont, ch: char) -> bool {
2813    if ch.is_ascii() {
2814        return true;
2815    }
2816    if let Some(encoding) = &font.pdf_encoding {
2817        let Ok(cp) = u16::try_from(ch as u32) else {
2818            return false;
2819        };
2820        return encoding
2821            .code_to_unicode_table()
2822            .into_iter()
2823            .flatten()
2824            .any(|u| u == cp);
2825    }
2826    unicode_to_winansi(ch).is_some()
2827}
2828
2829fn variant_key_base_name(key: &str) -> Option<&str> {
2830    key.strip_suffix("_Bold_Italic")
2831        .or_else(|| key.strip_suffix("_Bold_Normal"))
2832        .or_else(|| key.strip_suffix("_Normal_Italic"))
2833        .or_else(|| key.strip_suffix("_Normal_Normal"))
2834}
2835
2836#[allow(clippy::type_complexity)]
2837fn embed_resolved_fonts(
2838    doc: &mut Document,
2839    resolved: &HashMap<String, ResolvedFont>,
2840    layout: &LayoutDom,
2841) -> (
2842    HashMap<String, String>,
2843    Vec<(String, ObjectId)>,
2844    HashMap<String, FontMetricsData>,
2845) {
2846    let mut font_map = HashMap::new();
2847    let mut font_objects = Vec::new();
2848    let mut metrics_data = HashMap::new();
2849    let used_chars_by_font = collect_used_chars_by_font(layout);
2850    for (idx, (name, font)) in resolved.iter().enumerate() {
2851        let resource_name = format!("XFA_F{}", idx);
2852        // fix(#811): once a simple source font survives resolution, keep using
2853        // the original PDF object instead of emitting a synthetic Type0/system
2854        // fallback. That keeps field-fit behaviour aligned with the source PDF
2855        // and Acrobat's interpretation of the same /Widths table.
2856        //
2857        // WHY: custom encodings and non-ASCII content can require Unicode
2858        // shaping through Identity-H. If a simple source font cannot encode
2859        // the actual text in layout output, reusing it would produce '?'
2860        // substitutions in content streams.
2861        //
2862        // LIMITATION: CID source fonts (/Type0 with /W arrays) use a different
2863        // mechanism and are not covered by this simple-font encodeability gate.
2864        let used_chars = used_chars_by_font
2865            .get(name)
2866            .or_else(|| used_chars_by_font.get(&font.name))
2867            .or_else(|| variant_key_base_name(name).and_then(|base| used_chars_by_font.get(base)));
2868        let source_can_encode_all_text = used_chars.is_none_or(|chars| {
2869            chars
2870                .iter()
2871                .all(|ch| simple_font_can_encode_char(font, *ch))
2872        });
2873        let (obj_id, render_font_data) = if let Some(source_font) = font.pdf_source_font {
2874            if source_can_encode_all_text || font.data.is_empty() {
2875                (source_font.object_id, None)
2876            } else {
2877                (embed_font_in_pdf(doc, font), Some(font.data.clone()))
2878            }
2879        } else {
2880            (embed_font_in_pdf(doc, font), Some(font.data.clone()))
2881        };
2882        font_map.insert(name.clone(), format!("/{}", resource_name));
2883        font_objects.push((resource_name, obj_id));
2884        let (_first_char, widths) = font.pdf_glyph_widths();
2885        metrics_data.insert(
2886            name.clone(),
2887            FontMetricsData {
2888                widths,
2889                upem: font.units_per_em,
2890                ascender: font.ascender,
2891                descender: font.descender,
2892                font_data: render_font_data,
2893                face_index: font.face_index,
2894                simple_unicode_to_code: font
2895                    .pdf_encoding
2896                    .as_ref()
2897                    .map(simple_encoding_unicode_to_code_map),
2898            },
2899        );
2900    }
2901    (font_map, font_objects, metrics_data)
2902}
2903
2904/// Fallback: preserve existing page content, strip AcroForm/widgets only.
2905/// If lopdf can't parse the PDF (corrupt xref), return the original bytes
2906/// unchanged — the PDF is too corrupt for us to modify but still renderable.
2907///
2908/// This function ALWAYS returns Ok — errors are logged but the original bytes
2909/// are always returned as a last resort.
2910fn static_fallback(pdf_bytes: &[u8]) -> Result<Vec<u8>> {
2911    let mut doc = match Document::load_mem(pdf_bytes) {
2912        Ok(d) => d,
2913        Err(e) => {
2914            eprintln!("static_fallback: lopdf load failed ({e}), returning original bytes");
2915            return Ok(pdf_bytes.to_vec());
2916        }
2917    };
2918    strip_widgets_and_acroform(&mut doc);
2919    javascript_policy::strip_javascript_for_flatten(&mut doc);
2920    let mut out = Vec::new();
2921    if let Err(e) = doc.save_to(&mut out) {
2922        eprintln!("static_fallback: save failed ({e}), returning original bytes");
2923        return Ok(pdf_bytes.to_vec());
2924    }
2925    Ok(out)
2926}
2927
2928// Epic A E-5: thread-local for match-failure accumulation inside the local
2929// `apply_recursive` fn (which cannot capture outer state).
2930std::thread_local! {
2931    static FORM_DOM_MATCH_LOG: std::cell::RefCell<Option<Vec<FormDomMatchEntry>>> =
2932        const { std::cell::RefCell::new(None) };
2933}
2934
2935/// Apply presence overrides and repeating-instance expansion from the XFA form
2936/// DOM packet.
2937///
2938/// When an XFA PDF has been opened and saved by Adobe Reader, the form DOM
2939/// captures the runtime state of all nodes after scripts executed.  We walk
2940/// the form DOM and the FormTree in parallel (matching by subform/field name)
2941/// to:
2942///
2943/// 1. Transfer `presence="hidden"` attributes that our script interpreter
2944///    could not compute (e.g. Avoka framework's `sfcUtils.updateVisibility`).
2945/// 2. Replicate repeating subform instances (XFA §4.4.3): when `bind
2946///    match="none"` prevents data-driven expansion, the form DOM records the
2947///    correct instance count produced by the runtime's `instanceManager`.  We
2948///    deep-clone the template instance and populate field values from the form
2949///    DOM so the layout engine produces the right number of pages.
2950///
2951/// Return value: `(admitted_count, match_failures, match_log)`.
2952pub(crate) fn apply_form_dom_presence(
2953    tree: &mut FormTree,
2954    root_id: FormNodeId,
2955    form_xml: &str,
2956    policy: XfaRenderingPolicy,
2957    admit_databound_override: bool,
2958) -> (usize, usize, Vec<FormDomMatchEntry>) {
2959    use xfa_layout_engine::form::{FormNodeType, Presence};
2960
2961    let Ok(doc) = roxmltree::Document::parse(form_xml) else {
2962        return (0, 0, Vec::new());
2963    };
2964
2965    /// Deep-clone a subtree rooted at `src_id`, returning the new root id.
2966    fn clone_subtree(tree: &mut FormTree, src_id: FormNodeId) -> FormNodeId {
2967        let node = tree.get(src_id).clone();
2968        let meta = tree.meta(src_id).clone();
2969        // Temporarily take children out to avoid borrow issues
2970        let child_ids: Vec<FormNodeId> = node.children.clone();
2971        let mut new_node = node;
2972        new_node.children = Vec::new();
2973        // Clear xfa_id to avoid duplicate id conflicts
2974        let mut new_meta = meta;
2975        new_meta.xfa_id = None;
2976        let new_id = tree.add_node_with_meta(new_node, new_meta);
2977        // Recursively clone children
2978        for &child_id in &child_ids {
2979            let cloned_child = clone_subtree(tree, child_id);
2980            tree.get_mut(new_id).children.push(cloned_child);
2981        }
2982        new_id
2983    }
2984
2985    /// Extract the text content of the first `<value>` child's inner element
2986    /// (e.g. `<value><text>hello</text></value>` → `"hello"`).
2987    fn extract_field_value(xml_field: roxmltree::Node<'_, '_>) -> Option<String> {
2988        let value_el = xml_field
2989            .children()
2990            .find(|c| c.is_element() && c.tag_name().name() == "value")?;
2991        // The inner element may be <text>, <date>, <time>, <float>, etc.
2992        let inner = value_el.children().find(|c| c.is_element())?;
2993        inner.text().map(|t| t.to_string())
2994    }
2995
2996    /// Return true when a FormTree node semantically matches an XML form-DOM
2997    /// child.  Subform/field/draw match by name; pageSet matches by node type
2998    /// (unnamed in the XFA spec); pageArea matches by name within a pageSet.
2999    fn child_matches(tree: &FormTree, fid: FormNodeId, xml_tag: &str, xml_name: &str) -> bool {
3000        use xfa_layout_engine::form::FormNodeType;
3001        let node = tree.get(fid);
3002        match (xml_tag, &node.node_type) {
3003            ("pageSet", FormNodeType::PageSet) => true,
3004            ("pageArea", FormNodeType::PageArea { .. }) => node.name == xml_name,
3005            ("subform", FormNodeType::Subform | FormNodeType::Area | FormNodeType::ExclGroup) => {
3006                node.name == xml_name
3007            }
3008            ("field", FormNodeType::Field { .. }) => node.name == xml_name,
3009            ("draw", FormNodeType::Draw(_) | FormNodeType::Image { .. }) => node.name == xml_name,
3010            _ => false,
3011        }
3012    }
3013
3014    /// Apply presence, values, and child expansion from the form DOM to a
3015    /// FormTree node.
3016    fn apply_recursive(
3017        tree: &mut FormTree,
3018        form_node_id: FormNodeId,
3019        xml_node: roxmltree::Node<'_, '_>,
3020        policy: XfaRenderingPolicy,
3021        admit_databound_override: bool,
3022    ) -> usize {
3023        let mut admitted: usize = 0;
3024        let xml_tag = xml_node.tag_name().name();
3025        if !matches!(
3026            xml_tag,
3027            "subform" | "field" | "form" | "pageSet" | "pageArea"
3028        ) {
3029            return 0;
3030        }
3031
3032        // Apply presence override.
3033        if xml_tag == "subform" || xml_tag == "field" || xml_tag == "pageArea" {
3034            if let Some(pres) = xml_node.attribute("presence") {
3035                if pres == "hidden" {
3036                    // D9 (trace-only, env-gated): provenance of a form-DOM
3037                    // explicit `presence="hidden"` override. Behaviour-neutral.
3038                    if std::env::var("XFA_PRESENCE_PROV").ok().as_deref() == Some("1") {
3039                        eprintln!(
3040                            "XFA_PRESENCE_PROV site=formdom_explicit id={} name={:?} tag={}",
3041                            form_node_id.0,
3042                            tree.get(form_node_id).name,
3043                            xml_tag
3044                        );
3045                    }
3046                    tree.meta_mut(form_node_id).presence = Presence::Hidden;
3047                }
3048            }
3049        }
3050
3051        // Transfer field value from the form DOM when the FormTree node has no
3052        // value yet (empty string) or the form DOM has a computed value.
3053        if xml_tag == "field" {
3054            if let Some(val) = extract_field_value(xml_node) {
3055                if let FormNodeType::Field { ref value, .. } = tree.get(form_node_id).node_type {
3056                    if value.is_empty() {
3057                        tree.get_mut(form_node_id).node_type = FormNodeType::Field { value: val };
3058                    }
3059                }
3060            }
3061            return 0; // fields have no structural children to recurse into
3062        }
3063
3064        // Collect XML children we walk through.
3065        //
3066        // - `subform`, `field`, `draw` are the canonical content children.
3067        // - `pageSet` and `pageArea` carry runtime-allocated page instances
3068        //   that must be mirrored in the FormTree so the layout engine can
3069        //   emit one page per runtime instance (XFA 3.3 §8.6 / §3.1).
3070        let xml_children: Vec<roxmltree::Node<'_, '_>> = xml_node
3071            .children()
3072            .filter(|c| {
3073                c.is_element()
3074                    && matches!(
3075                        c.tag_name().name(),
3076                        "subform" | "field" | "draw" | "pageSet" | "pageArea"
3077                    )
3078            })
3079            .collect();
3080
3081        // Within a `pageSet`, only allow pageArea expansion when the form-DOM
3082        // enumerates a SINGLE pageArea name repeated multiple times — the
3083        // classic "uniform template" pattern recorded by Adobe's runtime when
3084        // it allocates more instances than the template declared (XFA 3.3
3085        // §8.6 / §3.1, e.g. 13275420c3c9afbb: 10× `<pageArea name="Page1">`).
3086        //
3087        // When the pageSet enumerates multiple distinct pageArea names
3088        // (e.g. IRCC forms with `Page1` + `OverFlowPage`), Adobe pre-allocates
3089        // pageAreas as a *menu* of available templates — not every instance is
3090        // actually rendered.  Expanding clones in that case over-paginates.
3091        //
3092        // Implementation: when inside a pageSet AND the pageArea-name set has
3093        // more than one distinct entry, suppress cloning by setting the
3094        // per-group `expansion_allowed` flag to false later.
3095        let inside_page_set = xml_tag == "pageSet";
3096        let uniform_page_area_template = if inside_page_set {
3097            let mut names: Vec<&str> = xml_children
3098                .iter()
3099                .filter(|c| c.tag_name().name() == "pageArea")
3100                .map(|c| c.attribute("name").unwrap_or(""))
3101                .collect();
3102            names.sort_unstable();
3103            names.dedup();
3104            names.len() == 1
3105        } else {
3106            false
3107        };
3108
3109        // Group consecutive XML children by (tag, name) to detect repeating
3110        // instances.  pageSet is unnamed in XFA, so a single `pageSet` group
3111        // is keyed by tag alone; pageArea siblings share `name="Page1"` for
3112        // runtime-allocated pages.
3113        let mut xml_groups: Vec<((&str, &str), Vec<roxmltree::Node<'_, '_>>)> = Vec::new();
3114        for &xc in &xml_children {
3115            let xtag = xc.tag_name().name();
3116            let xname = xc.attribute("name").unwrap_or("");
3117            let key = (xtag, xname);
3118            if let Some(last) = xml_groups.last_mut() {
3119                if last.0 == key {
3120                    last.1.push(xc);
3121                    continue;
3122                }
3123            }
3124            xml_groups.push((key, vec![xc]));
3125        }
3126
3127        // For each group, match against FormTree children, cloning when needed.
3128        let mut form_children = tree.get(form_node_id).children.clone();
3129        let mut used = vec![false; form_children.len()];
3130
3131        for (gkey, group_xml_nodes) in &xml_groups {
3132            let (gtag, gname) = *gkey;
3133            let xml_count = group_xml_nodes.len();
3134
3135            // Count existing FormTree children matching this XML group.
3136            let existing: Vec<(usize, FormNodeId)> = form_children
3137                .iter()
3138                .enumerate()
3139                .filter(|(i, &fid)| !used[*i] && child_matches(tree, fid, gtag, gname))
3140                .map(|(i, &fid)| (i, fid))
3141                .collect();
3142            let existing_count = existing.len();
3143
3144            // Gate per-group cloning:
3145            //
3146            // * pageArea clones may only be created inside a `pageSet` that
3147            //   enumerates a single uniform pageArea template (see comment
3148            //   above).  Otherwise pre-allocated "menu" pageAreas would be
3149            //   replicated and inflate the rendered page count.
3150            // * Other tags (subform/field/draw) follow the existing
3151            //   instance-replication behaviour.
3152            let expansion_allowed = if gtag == "pageArea" {
3153                inside_page_set && uniform_page_area_template
3154            } else {
3155                true
3156            };
3157
3158            // If the form DOM has more instances than the FormTree, clone to match.
3159            if expansion_allowed && xml_count > existing_count && existing_count > 0 {
3160                let template_id = existing[0].1;
3161                // Find insertion position: after the last existing sibling.
3162                // SAFETY: existing_count > 0 is guarded by the enclosing `if`.
3163                let last_existing_idx = existing.last().expect("existing_count > 0").0;
3164                let insert_pos = last_existing_idx + 1;
3165                let clones_needed = xml_count - existing_count;
3166                let mut new_ids = Vec::new();
3167                for _ in 0..clones_needed {
3168                    let cloned = clone_subtree(tree, template_id);
3169                    new_ids.push(cloned);
3170                }
3171                // Insert cloned nodes into the parent's children list
3172                for (offset, new_id) in new_ids.iter().enumerate() {
3173                    form_children.insert(insert_pos + offset, *new_id);
3174                    used.insert(insert_pos + offset, false);
3175                }
3176                // Persist the updated children list
3177                tree.get_mut(form_node_id).children = form_children.clone();
3178            }
3179
3180            // Mark expanded pageAreas as runtime-instantiated so the layout
3181            // engine emits a page per instance and the page-drop filter does
3182            // not discard them.  Only applies when the pageArea expansion is
3183            // active (uniform template inside a pageSet) AND clones were
3184            // actually created — single-template forms (no expansion) keep
3185            // their existing layout semantics.
3186            if gtag == "pageArea" && expansion_allowed && xml_count > existing_count {
3187                let to_mark: Vec<FormNodeId> = form_children
3188                    .iter()
3189                    .copied()
3190                    .filter(|&fid| child_matches(tree, fid, gtag, gname))
3191                    .collect();
3192                for fid in to_mark {
3193                    tree.meta_mut(fid).runtime_instantiated_page = true;
3194                }
3195            }
3196
3197            // Now match each XML node in the group to a FormTree child
3198            for (group_idx, &xc) in group_xml_nodes.iter().enumerate() {
3199                // Find next unmatched FormTree child with the same shape.
3200                let matched = form_children
3201                    .iter()
3202                    .enumerate()
3203                    .skip(if group_idx > 0 {
3204                        // Start searching after the last matched position
3205                        form_children
3206                            .iter()
3207                            .enumerate()
3208                            .rfind(|(i, &fid)| used[*i] && child_matches(tree, fid, gtag, gname))
3209                            .map(|(i, _)| i + 1)
3210                            .unwrap_or(0)
3211                    } else {
3212                        0
3213                    })
3214                    .find(|(i, &fid)| !used[*i] && child_matches(tree, fid, gtag, gname));
3215                if let Some((idx, &fid)) = matched {
3216                    used[idx] = true;
3217                    admitted += apply_recursive(tree, fid, xc, policy, admit_databound_override);
3218                }
3219            }
3220        }
3221
3222        // XFA §3.1: the form DOM represents the runtime-instantiated form.
3223        // Named template subforms NOT present in the form DOM were never
3224        // instantiated by Adobe's runtime (e.g. script-driven conditional
3225        // sections).  Hide them to prevent over-pagination from phantom
3226        // page-level subforms.
3227        //
3228        // Only suppress when the form DOM explicitly lists subform children;
3229        // a sparse form DOM with no structural children means it didn't
3230        // record child state and we should not infer absence.
3231        let has_subform_children = xml_children
3232            .iter()
3233            .any(|c| c.tag_name().name() == "subform");
3234        if has_subform_children {
3235            for (i, &fid) in form_children.iter().enumerate() {
3236                if used[i] {
3237                    continue;
3238                }
3239                let child_node = tree.get(fid);
3240                // Only suppress named subforms — skip pageSet, unnamed
3241                // transparent nodes, draws, fields, and structural elements.
3242                if matches!(child_node.node_type, FormNodeType::Subform)
3243                    && !child_node.name.is_empty()
3244                {
3245                    // Admit data-bound unmatched subforms instead of
3246                    // suppressing them. Two triggers share one guard:
3247                    //  - `FreshMergeExperimental` (experimental policy), or
3248                    //  - `XFA_FORMDOM_ADMIT_DATABOUND=1`, a default-off
3249                    //    production override that lets `SavedStateFaithful`
3250                    //    admit the same set WITHOUT flipping the policy default.
3251                    //
3252                    // Guard (admit only when):
3253                    //  - not template-hidden (`Presence::Hidden` / `Inactive`)
3254                    //  - not a zero-instance prototype placeholder
3255                    //  - has a data-node bound during merge
3256                    //  - did not opt out of binding via `<bind match="none">`
3257                    //
3258                    // The `bound_data_node.is_some()` clause is the
3259                    // over-pagination guard: truly-unmatched NON-data subforms
3260                    // (no bound data node) are never admitted, so the §3.1
3261                    // suppression that protects against phantom page-level
3262                    // subforms still holds. A data-bound subform only adds a
3263                    // page when its admitted content overflows — the layout
3264                    // engine self-regulates; this is not a page-count heuristic.
3265                    let meta = tree.meta(fid);
3266                    let admit_unmatched_databound = (policy
3267                        == XfaRenderingPolicy::FreshMergeExperimental
3268                        || admit_databound_override)
3269                        && !matches!(meta.presence, Presence::Hidden | Presence::Inactive)
3270                        && !meta.is_zero_instance_prototype
3271                        && meta.bound_data_node.is_some()
3272                        && !meta.data_bind_none;
3273
3274                    if std::env::var("XFA_PRESENCE_PROV").ok().as_deref() == Some("1") {
3275                        let site = if admit_unmatched_databound {
3276                            if policy == XfaRenderingPolicy::FreshMergeExperimental {
3277                                "formdom_unmatched_fresh_merge_admitted"
3278                            } else {
3279                                "formdom_unmatched_databound_admitted"
3280                            }
3281                        } else {
3282                            "formdom_unmatched"
3283                        };
3284                        eprintln!(
3285                            "XFA_PRESENCE_PROV site={site} id={} name={:?}",
3286                            fid.0, child_node.name
3287                        );
3288                    }
3289
3290                    if admit_unmatched_databound {
3291                        // Admit — leave presence as-is (Visible by default
3292                        // from the merger).
3293                        admitted += 1;
3294                    } else {
3295                        // SavedStateFaithful without override (or ineligible
3296                        // node): suppress.
3297                        // Epic A E-5: capture name before mutating (borrow order).
3298                        let suppressed_name = child_node.name.clone();
3299                        let suppressed_id = fid.0;
3300                        tree.meta_mut(fid).presence = Presence::Hidden;
3301                        crate::flatten::FORM_DOM_MATCH_LOG.with(|cell| {
3302                            if let Some(ref mut log) = *cell.borrow_mut() {
3303                                if log.len() < 200 {
3304                                    log.push(crate::dynamic::FormDomMatchEntry {
3305                                        template_node_id: suppressed_id,
3306                                        template_node_name: suppressed_name,
3307                                        reason: "formdom_unmatched_suppressed".to_string(),
3308                                    });
3309                                }
3310                            }
3311                        });
3312                    }
3313                }
3314            }
3315        }
3316        admitted
3317    }
3318
3319    // The form DOM root is <form><subform name="...">...</subform></form>
3320    let form_root = doc.root_element();
3321    let form_root_subform = form_root
3322        .children()
3323        .find(|c| c.is_element() && c.tag_name().name() == "subform");
3324
3325    // E-5: arm the thread-local if XFA_RUNTIME_DIAG or XFA_FLATTEN_TRACE.
3326    let diag_on = runtime_diag_enabled() || crate::flatten_trace::enabled();
3327    if diag_on {
3328        FORM_DOM_MATCH_LOG.with(|cell| {
3329            *cell.borrow_mut() = Some(Vec::new());
3330        });
3331    }
3332
3333    let mut total_admitted: usize = 0;
3334    if let Some(xml_root_sf) = form_root_subform {
3335        let root_children = tree.get(root_id).children.clone();
3336        let root_name = xml_root_sf.attribute("name").unwrap_or("");
3337        for &child_id in &root_children {
3338            if tree.get(child_id).name == root_name {
3339                total_admitted += apply_recursive(
3340                    tree,
3341                    child_id,
3342                    xml_root_sf,
3343                    policy,
3344                    admit_databound_override,
3345                );
3346                break;
3347            }
3348        }
3349    }
3350
3351    // E-5: drain.
3352    let match_log = if diag_on {
3353        FORM_DOM_MATCH_LOG.with(|cell| cell.borrow_mut().take().unwrap_or_default())
3354    } else {
3355        Vec::new()
3356    };
3357    let match_failures = match_log.len();
3358    (total_admitted, match_failures, match_log)
3359}
3360
3361/// Tiny PDFs (<1KB) with XFA templates that lack essential elements (subform,
3362/// pageSet) are corrupt stubs. Attempting to flatten these produces blank pages
3363/// instead of preserving the original page content.
3364fn is_corrupt_xfa_template(pdf_size: usize, template_xml: &str) -> bool {
3365    // Only apply to small PDFs — larger files may have legitimate sparse templates.
3366    if pdf_size >= 1024 {
3367        return false;
3368    }
3369    // A valid XFA template must parse and contain at least one subform or pageSet.
3370    match roxmltree::Document::parse(template_xml) {
3371        Ok(doc) => {
3372            let root = doc.root_element();
3373            !root.children().any(|c| {
3374                c.is_element()
3375                    && matches!(c.tag_name().name(), "subform" | "pageSet" | "subformSet")
3376            })
3377        }
3378        Err(_) => true, // Unparseable template is corrupt.
3379    }
3380}
3381
3382/// Strip undefined XML entity references from XFA template/datasets XML.
3383///
3384/// `roxmltree` only supports the five predefined XML entities (lt, gt, amp,
3385/// quot, apos). Some XFA PDFs contain custom entity references like `&xxe;`
3386/// that cause parse failures, so we drop only those references.
3387///
3388/// fixes #812: Adobe-generated XFA packets also contain raw `&` inside
3389/// processing instructions such as `<?renderCache.subset ... "#$%&'()+"?>`
3390/// and `<?renderCache.textRun ... "A. Adjustment & Location" ...?>`.
3391/// Those packets are valid XML because PI payload is opaque text. The old
3392/// implementation deleted everything between `&` and the next `;`, which
3393/// corrupted valid templates before merge and forced the flattener down the
3394/// 1-page static fallback path.
3395///
3396/// XFA Spec 3.3 §8.6 / §8.8 rely on the template reaching the merge/layout
3397/// pipeline intact. CID `/W` handling is unrelated and remains out of scope.
3398fn strip_undefined_xml_entities(xml: &str) -> String {
3399    let predefined = ["lt", "gt", "amp", "quot", "apos"];
3400    let mut result = String::with_capacity(xml.len());
3401    let bytes = xml.as_bytes();
3402    let mut pos = 0;
3403
3404    while let Some(rel_amp_pos) = xml[pos..].find('&') {
3405        let amp_pos = pos + rel_amp_pos;
3406        result.push_str(&xml[pos..amp_pos]);
3407
3408        if let Some((entity_name, next_pos)) = parse_xml_entity_reference(xml, amp_pos) {
3409            // Keep numeric character references (&#123; or &#x1F;) and the
3410            // predefined XML entities. Drop only true named entity references
3411            // that roxmltree cannot resolve.
3412            if entity_name.starts_with('#') || predefined.contains(&entity_name) {
3413                result.push_str(&xml[amp_pos..next_pos]);
3414            }
3415            pos = next_pos;
3416        } else {
3417            // Not an XML entity reference; preserve the raw ampersand.
3418            result.push('&');
3419            pos = amp_pos + 1;
3420        }
3421    }
3422
3423    if pos < bytes.len() {
3424        result.push_str(&xml[pos..]);
3425    }
3426    result
3427}
3428
3429fn parse_xml_entity_reference(xml: &str, amp_pos: usize) -> Option<(&str, usize)> {
3430    let bytes = xml.as_bytes();
3431    let start = amp_pos + 1;
3432    let first = *bytes.get(start)?;
3433
3434    // Numeric character references: &#123; or &#x1F;
3435    if first == b'#' {
3436        let mut idx = start + 1;
3437        if matches!(bytes.get(idx), Some(b'x' | b'X')) {
3438            idx += 1;
3439            let hex_start = idx;
3440            while matches!(
3441                bytes.get(idx),
3442                Some(b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F')
3443            ) {
3444                idx += 1;
3445            }
3446            if idx == hex_start || !matches!(bytes.get(idx), Some(b';')) {
3447                return None;
3448            }
3449        } else {
3450            let digits_start = idx;
3451            while matches!(bytes.get(idx), Some(b'0'..=b'9')) {
3452                idx += 1;
3453            }
3454            if idx == digits_start || !matches!(bytes.get(idx), Some(b';')) {
3455                return None;
3456            }
3457        }
3458        return Some((&xml[start..idx], idx + 1));
3459    }
3460
3461    // Named references: &name; where `name` follows XML Name syntax enough to
3462    // distinguish it from raw PI/script/text ampersands.
3463    if !is_xml_name_start(first) {
3464        return None;
3465    }
3466
3467    let mut idx = start + 1;
3468    while let Some(&b) = bytes.get(idx) {
3469        if b == b';' {
3470            return Some((&xml[start..idx], idx + 1));
3471        }
3472        if !is_xml_name_char(b) {
3473            return None;
3474        }
3475        idx += 1;
3476    }
3477    None
3478}
3479
3480fn is_xml_name_start(byte: u8) -> bool {
3481    matches!(byte, b':' | b'_' | b'A'..=b'Z' | b'a'..=b'z')
3482}
3483
3484fn is_xml_name_char(byte: u8) -> bool {
3485    is_xml_name_start(byte) || matches!(byte, b'-' | b'.' | b'0'..=b'9')
3486}
3487
3488// ---------------------------------------------------------------------------
3489// Helpers
3490// ---------------------------------------------------------------------------
3491
3492/// Returns `true` when the PDF's pages already carry substantial static content.
3493///
3494/// An array /Contents entry (multiple streams) or any individual stream larger
3495/// than 200 bytes indicates pre-flattened page content that should be preserved
3496/// rather than replaced by XFA re-rendering. Adobe's default XFA fallback
3497/// page ("Please wait..." / Adobe Reader upgrade text) is explicitly ignored:
3498/// those bytes are not real pre-rendered form content and must not suppress
3499/// XFA flattening.
3500fn pages_have_static_content(doc: &Document) -> bool {
3501    for page_id in doc.page_iter() {
3502        let streams = page_content_streams(doc, page_id);
3503        if streams.is_empty() {
3504            continue;
3505        }
3506
3507        // Count text-drawing operators (Tj/TJ) across all non-placeholder
3508        // content streams for this page. A real pre-rendered form page has
3509        // dozens of text operators; a watermark or evaluation overlay has
3510        // only 1–3. We require ≥5 non-placeholder text operators to
3511        // consider the page as having substantial static content.
3512        let mut text_op_count = 0usize;
3513        for stream in &streams {
3514            if is_xfa_placeholder_stream(stream) || is_watermark_stream(stream) {
3515                continue;
3516            }
3517            text_op_count += count_text_operators(stream);
3518        }
3519
3520        if text_op_count >= 5 {
3521            return true;
3522        }
3523    }
3524    false
3525}
3526
3527fn page_content_streams(doc: &Document, page_id: ObjectId) -> Vec<Vec<u8>> {
3528    let Ok(page_dict) = doc.get_dictionary(page_id) else {
3529        return Vec::new();
3530    };
3531
3532    match page_dict.get(b"Contents") {
3533        Ok(Object::Array(arr)) => arr
3534            .iter()
3535            .filter_map(|object| resolve_stream_content(doc, object))
3536            .collect(),
3537        Ok(Object::Reference(id)) => match doc.get_object(*id) {
3538            Ok(Object::Array(arr)) => arr
3539                .iter()
3540                .filter_map(|object| resolve_stream_content(doc, object))
3541                .collect(),
3542            Ok(object) => resolve_stream_content(doc, object).into_iter().collect(),
3543            Err(_) => Vec::new(),
3544        },
3545        Ok(object) => resolve_stream_content(doc, object).into_iter().collect(),
3546        Err(_) => Vec::new(),
3547    }
3548}
3549
3550fn resolve_stream_content(doc: &Document, object: &Object) -> Option<Vec<u8>> {
3551    let stream = match object {
3552        Object::Reference(id) => doc.get_object(*id).ok()?.as_stream().ok()?,
3553        Object::Stream(stream) => stream,
3554        _ => return None,
3555    };
3556
3557    stream
3558        .get_plain_content()
3559        .ok()
3560        .or_else(|| Some(stream.content.clone()))
3561}
3562
3563/// Count text-drawing operators (Tj / TJ) in a content stream.
3564fn count_text_operators(stream: &[u8]) -> usize {
3565    let mut count = 0;
3566    for window in stream.windows(3) {
3567        if (window[0] == b' ' || window[0] == b')' || window[0] == b']')
3568            && window[1] == b'T'
3569            && (window[2] == b'j' || window[2] == b'J')
3570        {
3571            count += 1;
3572        }
3573    }
3574    count
3575}
3576
3577/// Bake checkbox/radio button appearance marks from AcroForm widget AP streams
3578/// onto existing page content for dynamic XFA forms.
3579///
3580/// Hybrid XFA PDFs carry pre-rendered appearance streams in their widget `/AP/N`
3581/// dictionaries. For radio/checkbox widgets the Normal appearance dict often has
3582/// only the "on" state (filled circle / checkmark) with no "Off" entry. Only
3583/// widgets that are currently asserted should contribute that mark to the
3584/// flattened page; widgets explicitly in the `Off` state must not be stamped
3585/// with the on-mark just because `/AP/N` lacks an `Off` appearance.
3586fn bake_checkbox_radio_ap_marks(doc: &mut Document, page_id: ObjectId) -> usize {
3587    let annots = page_annotations(doc, page_id);
3588    if annots.is_empty() {
3589        return 0;
3590    }
3591
3592    let mut baked = 0usize;
3593    let mut overlay_ops = Vec::new();
3594
3595    for annot in &annots {
3596        let Some(annot_id) = annot.as_reference().ok() else {
3597            continue;
3598        };
3599        let Ok(annot_dict) = doc.get_dictionary(annot_id).cloned() else {
3600            continue;
3601        };
3602
3603        let is_widget = annot_dict
3604            .get(b"Subtype")
3605            .ok()
3606            .and_then(|obj| obj.as_name().ok())
3607            == Some(&b"Widget"[..]);
3608        if !is_widget {
3609            continue;
3610        }
3611
3612        // Radio/checkbox widgets have a dictionary of named states in /AP/N
3613        // (e.g. /N << /0 35 0 R >>).  Text fields and pushbuttons have /AP/N
3614        // as a single stream reference.  Use this to filter.
3615        let ap = match annot_dict.get(b"AP").ok().and_then(|o| o.as_dict().ok()) {
3616            Some(ap) => ap.clone(),
3617            None => continue,
3618        };
3619        let normal_obj = match ap.get(b"N").ok() {
3620            Some(obj) => obj.clone(),
3621            None => continue,
3622        };
3623
3624        // Resolve /N to a dictionary of appearance states.
3625        let states: Dictionary = match &normal_obj {
3626            Object::Reference(id) => match doc.get_object(*id).ok().cloned() {
3627                Some(Object::Dictionary(d)) => d,
3628                _ => continue, // direct stream → not radio/checkbox
3629            },
3630            Object::Dictionary(d) => d.clone(),
3631            _ => continue,
3632        };
3633
3634        if matches!(selected_widget_state(&annot_dict), Some(state) if state == b"Off") {
3635            continue;
3636        }
3637
3638        // Find the first non-"Off" state (the "on" mark appearance).
3639        let on_id = states
3640            .iter()
3641            .filter(|(name, _)| name.as_slice() != b"Off")
3642            .find_map(|(_, obj)| match obj {
3643                Object::Reference(id) => Some(*id),
3644                _ => None,
3645            });
3646        let Some(ap_id) = on_id else { continue };
3647
3648        // Verify the referenced object is a Form XObject stream.
3649        match doc.get_object(ap_id).ok() {
3650            Some(Object::Stream(_)) => {}
3651            _ => continue,
3652        }
3653
3654        let Some(rect) = annotation_rect(&annot_dict) else {
3655            continue;
3656        };
3657
3658        let xobject_name = format!("XfaCbAp{}", baked);
3659        add_xobject_to_page_resources(doc, page_id, &xobject_name, ap_id);
3660        write_ops(
3661            &mut overlay_ops,
3662            format_args!(
3663                "q 1 0 0 1 {:.3} {:.3} cm /{} Do Q\n",
3664                rect[0], rect[1], xobject_name
3665            ),
3666        );
3667        baked += 1;
3668    }
3669
3670    if !overlay_ops.is_empty() {
3671        append_to_page_content(doc, page_id, &overlay_ops);
3672    }
3673
3674    baked
3675}
3676
3677fn is_xfa_placeholder_stream(stream: &[u8]) -> bool {
3678    const PLACEHOLDER_MARKERS: [&[u8]; 5] = [
3679        b"Please wait",
3680        b"Adobe Reader",
3681        b"reader_download",
3682        b"display this type of document",
3683        b"To view the full contents",
3684    ];
3685
3686    PLACEHOLDER_MARKERS
3687        .iter()
3688        .any(|marker| contains_ascii_case_insensitive(stream, marker))
3689}
3690
3691/// Detect evaluation-software watermark overlays (e.g. "Qoppa Software",
3692/// "For Evaluation Only"). These are short streams with ≤3 Tj operators
3693/// that should not count as real pre-rendered form content.
3694fn is_watermark_stream(stream: &[u8]) -> bool {
3695    const WATERMARK_MARKERS: [&[u8]; 3] =
3696        [b"Evaluation Only", b"Qoppa Software", b"For Evaluation"];
3697    WATERMARK_MARKERS
3698        .iter()
3699        .any(|marker| contains_ascii_case_insensitive(stream, marker))
3700}
3701
3702fn contains_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool {
3703    haystack
3704        .windows(needle.len())
3705        .any(|window| window.eq_ignore_ascii_case(needle))
3706}
3707
3708fn write_ops(buf: &mut Vec<u8>, args: std::fmt::Arguments<'_>) {
3709    use std::fmt::Write as _;
3710
3711    let mut text = String::new();
3712    let _ = text.write_fmt(args);
3713    buf.extend_from_slice(text.as_bytes());
3714}
3715
3716/// Flatten Widget annotation appearances onto their pages.
3717///
3718/// Hybrid XFA PDFs often already contain the correct visual representation in
3719/// widget `/AP` streams. Stripping those widgets outright drops borders, text,
3720/// checkboxes, and image buttons. This helper bakes the normal appearance onto
3721/// the page content and removes only the widgets that were successfully
3722/// flattened. Returns the number of widgets flattened.
3723fn flatten_widget_appearances(doc: &mut Document) -> usize {
3724    let page_ids: Vec<ObjectId> = doc.page_iter().collect();
3725    let mut flattened = 0usize;
3726
3727    for page_id in page_ids {
3728        let annots = page_annotations(doc, page_id);
3729        if annots.is_empty() {
3730            continue;
3731        }
3732
3733        let mut retained = Vec::new();
3734        let mut overlay_ops = Vec::new();
3735
3736        for annot in annots {
3737            let Some(annot_id) = annot.as_reference().ok() else {
3738                retained.push(annot);
3739                continue;
3740            };
3741
3742            let Ok(annot_dict) = doc.get_dictionary(annot_id).cloned() else {
3743                retained.push(annot);
3744                continue;
3745            };
3746
3747            let is_widget = annot_dict
3748                .get(b"Subtype")
3749                .ok()
3750                .and_then(|obj| obj.as_name().ok())
3751                == Some(&b"Widget"[..]);
3752            if !is_widget {
3753                retained.push(annot);
3754                continue;
3755            }
3756
3757            let Some(rect) = annotation_rect(&annot_dict) else {
3758                retained.push(Object::Reference(annot_id));
3759                continue;
3760            };
3761            let Some(ap_id) = resolve_widget_normal_appearance(doc, &annot_dict) else {
3762                retained.push(Object::Reference(annot_id));
3763                continue;
3764            };
3765
3766            let xobject_name = format!("XfaAp{}", flattened);
3767            add_xobject_to_page_resources(doc, page_id, &xobject_name, ap_id);
3768            write_ops(
3769                &mut overlay_ops,
3770                format_args!(
3771                    "q 1 0 0 1 {:.3} {:.3} cm /{} Do Q\n",
3772                    rect[0], rect[1], xobject_name
3773                ),
3774            );
3775            flattened += 1;
3776        }
3777
3778        if overlay_ops.is_empty() {
3779            continue;
3780        }
3781
3782        append_to_page_content(doc, page_id, &overlay_ops);
3783        set_page_annotations(doc, page_id, retained);
3784    }
3785
3786    flattened
3787}
3788
3789/// Remove Widget annotations from a page, keeping non-Widget annotations.
3790fn strip_widget_annotations(doc: &mut Document, page_id: ObjectId) {
3791    let annots = page_annotations(doc, page_id);
3792    if annots.is_empty() {
3793        return;
3794    }
3795    let mut retained = Vec::new();
3796    for annot in &annots {
3797        let is_widget = annot
3798            .as_reference()
3799            .ok()
3800            .and_then(|id| doc.get_dictionary(id).ok())
3801            .and_then(|d| d.get(b"Subtype").ok())
3802            .and_then(|obj| obj.as_name().ok())
3803            == Some(&b"Widget"[..]);
3804        if !is_widget {
3805            retained.push(annot.clone());
3806        }
3807    }
3808    set_page_annotations(doc, page_id, retained);
3809}
3810
3811fn page_annotations(doc: &Document, page_id: ObjectId) -> Vec<Object> {
3812    let Ok(page_dict) = doc.get_dictionary(page_id) else {
3813        return Vec::new();
3814    };
3815
3816    match page_dict.get(b"Annots") {
3817        Ok(Object::Array(arr)) => arr.clone(),
3818        Ok(Object::Reference(id)) => doc
3819            .get_object(*id)
3820            .ok()
3821            .and_then(|obj| obj.as_array().ok().cloned())
3822            .unwrap_or_default(),
3823        _ => Vec::new(),
3824    }
3825}
3826
3827fn set_page_annotations(doc: &mut Document, page_id: ObjectId, annots: Vec<Object>) {
3828    if let Ok(Object::Dictionary(ref mut page_dict)) = doc.get_object_mut(page_id) {
3829        if annots.is_empty() {
3830            page_dict.remove(b"Annots");
3831        } else {
3832            page_dict.set("Annots", Object::Array(annots));
3833        }
3834    }
3835}
3836
3837fn annotation_rect(dict: &Dictionary) -> Option<[f32; 4]> {
3838    let rect = dict.get(b"Rect").ok()?.as_array().ok()?;
3839    if rect.len() != 4 {
3840        return None;
3841    }
3842    Some([
3843        rect[0].as_float().ok()?,
3844        rect[1].as_float().ok()?,
3845        rect[2].as_float().ok()?,
3846        rect[3].as_float().ok()?,
3847    ])
3848}
3849
3850fn resolve_widget_normal_appearance(
3851    doc: &mut Document,
3852    annot_dict: &Dictionary,
3853) -> Option<ObjectId> {
3854    let ap = annot_dict.get(b"AP").ok()?.as_dict().ok()?;
3855    let normal = ap.get(b"N").ok()?;
3856    resolve_appearance_object(doc, annot_dict, normal)
3857}
3858
3859fn resolve_appearance_object(
3860    doc: &mut Document,
3861    annot_dict: &Dictionary,
3862    object: &Object,
3863) -> Option<ObjectId> {
3864    match object {
3865        Object::Reference(id) => match doc.get_object(*id).ok()?.clone() {
3866            Object::Stream(_) => Some(*id),
3867            Object::Dictionary(states) => resolve_appearance_state(doc, annot_dict, &states),
3868            _ => None,
3869        },
3870        Object::Stream(stream) => Some(doc.add_object(Object::Stream(stream.clone()))),
3871        Object::Dictionary(states) => resolve_appearance_state(doc, annot_dict, states),
3872        _ => None,
3873    }
3874}
3875
3876fn resolve_appearance_state(
3877    doc: &mut Document,
3878    annot_dict: &Dictionary,
3879    states: &Dictionary,
3880) -> Option<ObjectId> {
3881    if let Some(state) = selected_widget_state(annot_dict) {
3882        if let Ok(object) = states.get(state) {
3883            if let Some(id) = resolve_appearance_object(doc, annot_dict, object) {
3884                return Some(id);
3885            }
3886        }
3887        if state == b"Off" {
3888            // Previously assumed oracle always stamps on-mark for hybrid XFA widgets.
3889            // Corrected per GL-WF-01/M#55: /AS state from source data is authoritative.
3890            return None;
3891        }
3892    }
3893
3894    for fallback in [b"Yes".as_slice(), b"On".as_slice(), b"Off".as_slice()] {
3895        if let Ok(object) = states.get(fallback) {
3896            if let Some(id) = resolve_appearance_object(doc, annot_dict, object) {
3897                return Some(id);
3898            }
3899        }
3900    }
3901
3902    for (_name, object) in states.iter() {
3903        if let Some(id) = resolve_appearance_object(doc, annot_dict, object) {
3904            return Some(id);
3905        }
3906    }
3907
3908    None
3909}
3910
3911fn selected_widget_state(annot_dict: &Dictionary) -> Option<&[u8]> {
3912    annot_dict
3913        .get(b"AS")
3914        .ok()
3915        .and_then(|obj| obj.as_name().ok())
3916        .or_else(|| annot_dict.get(b"V").ok().and_then(|obj| obj.as_name().ok()))
3917}
3918
3919fn add_xobject_to_page_resources(
3920    doc: &mut Document,
3921    page_id: ObjectId,
3922    name: &str,
3923    xobject_id: ObjectId,
3924) {
3925    let resources_ref = doc.get_dictionary(page_id).ok().and_then(|page_dict| {
3926        page_dict
3927            .get(b"Resources")
3928            .ok()
3929            .and_then(|obj| obj.as_reference().ok())
3930    });
3931
3932    if let Some(resources_id) = resources_ref {
3933        let xobject_ref = doc.get_dictionary(resources_id).ok().and_then(|resources| {
3934            resources
3935                .get(b"XObject")
3936                .ok()
3937                .and_then(|obj| obj.as_reference().ok())
3938        });
3939
3940        if let Some(xobject_dict_id) = xobject_ref {
3941            if let Ok(Object::Dictionary(ref mut xobjects)) = doc.get_object_mut(xobject_dict_id) {
3942                xobjects.set(name, Object::Reference(xobject_id));
3943                return;
3944            }
3945        }
3946
3947        if let Ok(Object::Dictionary(ref mut resources)) = doc.get_object_mut(resources_id) {
3948            add_xobject_to_resources_dict(resources, name, xobject_id);
3949            return;
3950        }
3951    }
3952
3953    let inline_xobject_ref = doc.get_dictionary(page_id).ok().and_then(|page_dict| {
3954        page_dict
3955            .get(b"Resources")
3956            .ok()
3957            .and_then(|obj| obj.as_dict().ok())
3958            .and_then(|resources| {
3959                resources
3960                    .get(b"XObject")
3961                    .ok()
3962                    .and_then(|obj| obj.as_reference().ok())
3963            })
3964    });
3965
3966    if let Some(xobject_dict_id) = inline_xobject_ref {
3967        if let Ok(Object::Dictionary(ref mut xobjects)) = doc.get_object_mut(xobject_dict_id) {
3968            xobjects.set(name, Object::Reference(xobject_id));
3969            return;
3970        }
3971    }
3972
3973    if let Ok(Object::Dictionary(ref mut page_dict)) = doc.get_object_mut(page_id) {
3974        if let Ok(Object::Dictionary(ref mut resources)) = page_dict.get_mut(b"Resources") {
3975            add_xobject_to_resources_dict(resources, name, xobject_id);
3976            return;
3977        }
3978
3979        let mut resources = Dictionary::new();
3980        add_xobject_to_resources_dict(&mut resources, name, xobject_id);
3981        page_dict.set("Resources", Object::Dictionary(resources));
3982    }
3983}
3984
3985fn add_xobject_to_resources_dict(resources: &mut Dictionary, name: &str, xobject_id: ObjectId) {
3986    if let Ok(Object::Dictionary(ref mut xobjects)) = resources.get_mut(b"XObject") {
3987        xobjects.set(name, Object::Reference(xobject_id));
3988    } else {
3989        let mut xobjects = Dictionary::new();
3990        xobjects.set(name, Object::Reference(xobject_id));
3991        resources.set("XObject", Object::Dictionary(xobjects));
3992    }
3993}
3994
3995fn append_to_page_content(doc: &mut Document, page_id: ObjectId, data: &[u8]) {
3996    let new_stream_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, data.to_vec())));
3997
3998    let contents = doc
3999        .get_dictionary(page_id)
4000        .ok()
4001        .and_then(|page_dict| page_dict.get(b"Contents").ok().cloned());
4002
4003    // Some PDFs store page /Contents as an indirect array of streams. Appending
4004    // by wrapping that array reference in another array creates nested content
4005    // arrays (`[ 1510 0 R 1574 0 R ]` where `1510 0 R` is itself an array),
4006    // which Poppler treats as "Weird page contents" and can blank the page.
4007    // Flatten the existing /Contents tree first so preserve-static/widget bake
4008    // paths remain valid on Adobe-generated forms like 697eeb9f.
4009    let new_contents = match contents {
4010        Some(existing) => {
4011            let mut flattened = Vec::new();
4012            flatten_page_contents_entries(doc, existing, &mut flattened);
4013            flattened.push(Object::Reference(new_stream_id));
4014            if flattened.len() == 1 {
4015                // SAFETY: len == 1 is checked on the line above.
4016                flattened.pop().expect("flattened.len() == 1")
4017            } else {
4018                Object::Array(flattened)
4019            }
4020        }
4021        None => Object::Reference(new_stream_id),
4022    };
4023
4024    if let Ok(Object::Dictionary(ref mut page_dict)) = doc.get_object_mut(page_id) {
4025        page_dict.set("Contents", new_contents);
4026    }
4027}
4028
4029fn flatten_page_contents_entries(doc: &mut Document, object: Object, out: &mut Vec<Object>) {
4030    match object {
4031        Object::Reference(id) => match doc.get_object(id).cloned() {
4032            Ok(Object::Array(items)) => {
4033                for item in items {
4034                    flatten_page_contents_entries(doc, item, out);
4035                }
4036            }
4037            _ => out.push(Object::Reference(id)),
4038        },
4039        Object::Array(items) => {
4040            for item in items {
4041                flatten_page_contents_entries(doc, item, out);
4042            }
4043        }
4044        Object::Stream(stream) => {
4045            let stream_id = doc.add_object(Object::Stream(stream));
4046            out.push(Object::Reference(stream_id));
4047        }
4048        other => out.push(other),
4049    }
4050}
4051
4052/// Remove Widget annotations from all pages and strip /AcroForm from the catalog.
4053///
4054/// This is the "static-strip" flatten path used for hybrid XFA+static PDFs:
4055/// the original page content is preserved and only the interactive XFA/AcroForm
4056/// layer is removed.
4057fn strip_widgets_and_acroform(doc: &mut Document) {
4058    remove_acroform(doc);
4059}
4060
4061/// Replace a page's /Contents stream with XFA overlay bytes and add font resource.
4062fn write_page_content(
4063    doc: &mut Document,
4064    page_id: ObjectId,
4065    overlay: &PageOverlay,
4066    font_ids: &[ObjectId; 3],
4067    embedded_fonts: &[(String, ObjectId)],
4068    page_width: Option<f64>,
4069    page_height: Option<f64>,
4070) -> Result<()> {
4071    let mut resources = make_resources_dict(font_ids, embedded_fonts);
4072
4073    let mut xobjects = Dictionary::new();
4074    for img in &overlay.images {
4075        match embed_image(doc, &img.data, &img.mime_type) {
4076            Ok(result) => {
4077                xobjects.set(img.name.as_str(), Object::Reference(result.object_id));
4078            }
4079            Err(e) => {
4080                eprintln!("failed to embed image {}: {}", img.name, e);
4081            }
4082        }
4083    }
4084    if !xobjects.is_empty() {
4085        resources.set("XObject", Object::Dictionary(xobjects));
4086    }
4087
4088    let stream = Stream::new(
4089        dictionary! { "Length" => Object::Integer(overlay.content_stream.len() as i64) },
4090        overlay.content_stream.clone(),
4091    );
4092    let stream_id = doc.add_object(Object::Stream(stream));
4093
4094    if let Ok(Object::Dictionary(ref mut page_dict)) = doc.get_object_mut(page_id) {
4095        page_dict.set("Contents", Object::Reference(stream_id));
4096        page_dict.set("Resources", Object::Dictionary(resources));
4097        // Update MediaBox to match the XFA layout page dimensions.
4098        // Dynamic XFA forms often have a placeholder page with different
4099        // dimensions than the template's pageArea (e.g. letter vs A4).
4100        if let (Some(w), Some(h)) = (page_width, page_height) {
4101            page_dict.set(
4102                "MediaBox",
4103                Object::Array(vec![
4104                    Object::Real(0.0),
4105                    Object::Real(0.0),
4106                    Object::Real(w as f32),
4107                    Object::Real(h as f32),
4108                ]),
4109            );
4110        }
4111    }
4112    Ok(())
4113}
4114
4115/// Overlay XFA content on top of existing page content (for static XFA forms).
4116///
4117/// Unlike `write_page_content` which replaces the page content entirely, this
4118/// preserves the original content stream and appends the XFA overlay on top.
4119/// The original resources are preserved and XFA font resources are merged in.
4120fn overlay_page_content(
4121    doc: &mut Document,
4122    page_id: ObjectId,
4123    overlay: &PageOverlay,
4124    font_ids: &[ObjectId; 3],
4125    embedded_fonts: &[(String, ObjectId)],
4126) -> Result<()> {
4127    let xfa_resources = make_resources_dict(font_ids, embedded_fonts);
4128
4129    let mut xfa_xobjects = Dictionary::new();
4130    for img in &overlay.images {
4131        match embed_image(doc, &img.data, &img.mime_type) {
4132            Ok(result) => {
4133                xfa_xobjects.set(img.name.as_str(), Object::Reference(result.object_id));
4134            }
4135            Err(e) => {
4136                eprintln!("failed to embed image {}: {}", img.name, e);
4137            }
4138        }
4139    }
4140
4141    merge_xfa_resources_into_page(doc, page_id, &xfa_resources, &xfa_xobjects);
4142
4143    if !overlay.content_stream.is_empty() {
4144        append_to_page_content(doc, page_id, &overlay.content_stream);
4145    }
4146
4147    Ok(())
4148}
4149
4150/// Merge XFA font/xobject resources into the existing page resources without
4151/// overwriting original entries.
4152fn merge_xfa_resources_into_page(
4153    doc: &mut Document,
4154    page_id: ObjectId,
4155    xfa_resources: &Dictionary,
4156    xfa_xobjects: &Dictionary,
4157) {
4158    let existing_resources = doc
4159        .get_dictionary(page_id)
4160        .ok()
4161        .and_then(|page_dict| {
4162            page_dict.get(b"Resources").ok().and_then(|obj| match obj {
4163                Object::Reference(id) => doc.get_dictionary(*id).ok().cloned(),
4164                Object::Dictionary(d) => Some(d.clone()),
4165                _ => None,
4166            })
4167        })
4168        .unwrap_or_default();
4169
4170    let mut merged = existing_resources;
4171
4172    // Merge Font entries: add XFA fonts (F1, F2, F3, embedded) without
4173    // overwriting the page's own fonts.
4174    if let Ok(xfa_font_dict) = xfa_resources.get(b"Font").and_then(|o| o.as_dict()) {
4175        let existing_font = merged
4176            .get(b"Font")
4177            .ok()
4178            .and_then(|obj| match obj {
4179                Object::Dictionary(d) => Some(d.clone()),
4180                Object::Reference(id) => doc.get_dictionary(*id).ok().cloned(),
4181                _ => None,
4182            })
4183            .unwrap_or_default();
4184
4185        let mut font_merged = existing_font;
4186        for (key, val) in xfa_font_dict.iter() {
4187            if font_merged.get(key).is_err() {
4188                font_merged.set(key.clone(), val.clone());
4189            }
4190        }
4191        merged.set("Font", Object::Dictionary(font_merged));
4192    }
4193
4194    // Merge XObject entries.
4195    if !xfa_xobjects.is_empty() {
4196        let existing_xobj = merged
4197            .get(b"XObject")
4198            .ok()
4199            .and_then(|obj| match obj {
4200                Object::Dictionary(d) => Some(d.clone()),
4201                Object::Reference(id) => doc.get_dictionary(*id).ok().cloned(),
4202                _ => None,
4203            })
4204            .unwrap_or_default();
4205
4206        let mut xobj_merged = existing_xobj;
4207        for (key, val) in xfa_xobjects.iter() {
4208            xobj_merged.set(key.clone(), val.clone());
4209        }
4210        merged.set("XObject", Object::Dictionary(xobj_merged));
4211    }
4212
4213    if let Ok(Object::Dictionary(ref mut page_dict)) = doc.get_object_mut(page_id) {
4214        page_dict.set("Resources", Object::Dictionary(merged));
4215    }
4216}
4217
4218/// Add a new page to the document's /Pages tree.
4219fn add_new_page(
4220    doc: &mut Document,
4221    w: f64,
4222    h: f64,
4223    overlay: &PageOverlay,
4224    font_ids: &[ObjectId; 3],
4225    embedded_fonts: &[(String, ObjectId)],
4226) -> Result<()> {
4227    let mut resources = make_resources_dict(font_ids, embedded_fonts);
4228
4229    let mut xobjects = Dictionary::new();
4230    for img in &overlay.images {
4231        match embed_image(doc, &img.data, &img.mime_type) {
4232            Ok(result) => {
4233                xobjects.set(img.name.as_str(), Object::Reference(result.object_id));
4234            }
4235            Err(e) => {
4236                eprintln!("failed to embed image {}: {}", img.name, e);
4237            }
4238        }
4239    }
4240    if !xobjects.is_empty() {
4241        resources.set("XObject", Object::Dictionary(xobjects));
4242    }
4243
4244    let stream = Stream::new(
4245        dictionary! { "Length" => Object::Integer(overlay.content_stream.len() as i64) },
4246        overlay.content_stream.clone(),
4247    );
4248    let stream_id = doc.add_object(Object::Stream(stream));
4249
4250    // Find the /Pages root to append to.
4251    let pages_id = find_pages_root(doc)?;
4252
4253    let page_id = doc.add_object(Object::Dictionary(dictionary! {
4254        "Type"      => Object::Name(b"Page".to_vec()),
4255        "Parent"    => Object::Reference(pages_id),
4256        "MediaBox"  => Object::Array(vec![
4257            Object::Integer(0), Object::Integer(0),
4258            Object::Real(w as f32), Object::Real(h as f32),
4259        ]),
4260        "Contents"  => Object::Reference(stream_id),
4261        "Resources" => Object::Dictionary(resources)
4262    }));
4263
4264    // Append to /Kids and increment /Count.
4265    if let Ok(Object::Dictionary(ref mut pages_dict)) = doc.get_object_mut(pages_id) {
4266        if let Ok(Object::Array(ref mut kids)) = pages_dict.get_mut(b"Kids") {
4267            kids.push(Object::Reference(page_id));
4268        }
4269        if let Ok(Object::Integer(ref mut count)) = pages_dict.get_mut(b"Count") {
4270            *count += 1;
4271        }
4272    }
4273    Ok(())
4274}
4275
4276fn make_resources_dict(
4277    font_ids: &[ObjectId; 3],
4278    embedded_fonts: &[(String, ObjectId)],
4279) -> Dictionary {
4280    let mut fonts = Dictionary::new();
4281    fonts.set("F1", Object::Reference(font_ids[0]));
4282    fonts.set("F2", Object::Reference(font_ids[1]));
4283    fonts.set("F3", Object::Reference(font_ids[2]));
4284    for (name, obj_id) in embedded_fonts {
4285        fonts.set(name.as_str(), Object::Reference(*obj_id));
4286    }
4287    let mut resources = Dictionary::new();
4288    resources.set("Font", Object::Dictionary(fonts));
4289    resources
4290}
4291
4292fn find_pages_root(doc: &Document) -> Result<ObjectId> {
4293    let root_id = doc
4294        .trailer
4295        .get(b"Root")
4296        .ok()
4297        .and_then(|o: &Object| o.as_reference().ok())
4298        .ok_or_else(|| XfaError::LoadFailed("no /Root in trailer".to_string()))?;
4299    let catalog = doc
4300        .get_dictionary(root_id)
4301        .map_err(|e| XfaError::LoadFailed(format!("catalog: {e}")))?;
4302    catalog
4303        .get(b"Pages")
4304        .ok()
4305        .and_then(|o: &Object| o.as_reference().ok())
4306        .ok_or_else(|| XfaError::LoadFailed("no /Pages in catalog".to_string()))
4307}
4308
4309/// Remove all interactive XFA/AcroForm artifacts from the PDF document.
4310///
4311/// XFA-F6-02 (#1110): this function ensures the output is a clean static PDF
4312/// with no residual interactive form markers. Steps performed:
4313///
4314/// 1. Remove `/AcroForm` from the catalog.
4315/// 2. Remove `/NeedsRendering` from the catalog.
4316/// 3. Remove `/XFA` from the AcroForm dictionary (if it was an indirect object
4317///    whose dict still exists in the object table).
4318/// 4. Remove orphaned XFA packet objects and the unreachable AcroForm object
4319///    from the lopdf object table.
4320/// 5. Remove widget annotations from all page `/Annots` arrays.
4321/// 6. Remove empty `/Annots` arrays left behind after widget removal.
4322fn remove_acroform(doc: &mut Document) {
4323    let root_id = match doc.trailer.get(b"Root") {
4324        Ok(Object::Reference(id)) => *id,
4325        _ => return,
4326    };
4327
4328    // Step 1 & 2: remove /AcroForm and /NeedsRendering from catalog.
4329    // Also capture the AcroForm object ID so we can clean up /XFA inside it.
4330    let acroform_id: Option<ObjectId> = {
4331        if let Ok(Object::Dictionary(ref mut dict)) = doc.get_object_mut(root_id) {
4332            let acroform_ref = dict.get(b"AcroForm").ok().and_then(|o| {
4333                if let Object::Reference(id) = o {
4334                    Some(*id)
4335                } else {
4336                    None
4337                }
4338            });
4339            dict.remove(b"AcroForm");
4340            dict.remove(b"NeedsRendering");
4341            acroform_ref
4342        } else {
4343            None
4344        }
4345    };
4346
4347    // Step 3: collect /XFA stream object IDs, then remove /XFA from the
4348    // AcroForm dictionary object.
4349    let xfa_stream_ids: Vec<ObjectId> = acroform_id
4350        .and_then(|af_id| doc.get_dictionary(af_id).ok())
4351        .map(|af_dict| match af_dict.get(b"XFA") {
4352            Ok(Object::Array(arr)) => arr
4353                .iter()
4354                .filter_map(|o| {
4355                    if let Object::Reference(id) = o {
4356                        Some(*id)
4357                    } else {
4358                        None
4359                    }
4360                })
4361                .collect(),
4362            Ok(Object::Reference(id)) => vec![*id],
4363            _ => Vec::new(),
4364        })
4365        .unwrap_or_default();
4366
4367    if let Some(af_id) = acroform_id {
4368        if let Ok(Object::Dictionary(ref mut af_dict)) = doc.get_object_mut(af_id) {
4369            af_dict.remove(b"XFA");
4370        }
4371    }
4372
4373    // Step 4 (FSC-05): purge orphaned XFA packet objects and the unreachable
4374    // AcroForm dictionary from the object table. lopdf serializes every object
4375    // still present in doc.objects, even if the catalog no longer references it.
4376    for stream_id in xfa_stream_ids {
4377        doc.objects.remove(&stream_id);
4378    }
4379    if let Some(af_id) = acroform_id {
4380        doc.objects.remove(&af_id);
4381    }
4382
4383    // Step 5 & 6: remove widget annotations from every page's /Annots array,
4384    // then drop empty /Annots arrays entirely.
4385    let page_ids: Vec<ObjectId> = doc.page_iter().collect();
4386    for page_id in page_ids {
4387        strip_widget_annotations(doc, page_id);
4388    }
4389}
4390
4391// ---------------------------------------------------------------------------
4392// XFA-F6-03 (#1111): Post-flatten validation
4393// ---------------------------------------------------------------------------
4394
4395/// Result of a post-flatten validation pass.
4396///
4397/// All `has_no_*` fields should be `true` for a clean flat PDF. Any
4398/// remaining XFA artifacts are reported in `warnings`.
4399pub struct FlattenValidation {
4400    /// True when the catalog contains no `/XFA` entry (directly or via AcroForm).
4401    pub has_no_xfa: bool,
4402    /// True when the catalog contains no `/NeedsRendering` entry.
4403    pub has_no_needs_rendering: bool,
4404    /// True when the catalog contains no `/AcroForm` entry.
4405    pub has_no_acroform: bool,
4406    /// Number of pages in the output PDF.
4407    pub page_count: usize,
4408    /// Human-readable warnings for each detected XFA artifact.
4409    pub warnings: Vec<String>,
4410}
4411
4412/// Validate that a PDF has been fully flattened (no XFA/AcroForm artifacts remain).
4413///
4414/// Returns a [`FlattenValidation`] summary. Call after [`flatten_xfa_to_pdf`] to
4415/// confirm the output is clean.
4416///
4417/// This function never panics — parse failures produce a validation result with
4418/// all `has_no_*` fields set to `false` and a warning explaining the parse error.
4419pub fn validate_flattened_pdf(pdf_bytes: &[u8]) -> Result<FlattenValidation> {
4420    if pdf_bytes.is_empty() {
4421        return Ok(FlattenValidation {
4422            has_no_xfa: true,
4423            has_no_needs_rendering: true,
4424            has_no_acroform: true,
4425            page_count: 0,
4426            warnings: vec!["empty input — no PDF to validate".into()],
4427        });
4428    }
4429
4430    let doc = match Document::load_mem(pdf_bytes) {
4431        Ok(d) => d,
4432        Err(e) => {
4433            return Ok(FlattenValidation {
4434                has_no_xfa: false,
4435                has_no_needs_rendering: false,
4436                has_no_acroform: false,
4437                page_count: 0,
4438                warnings: vec![format!("could not parse PDF: {e}")],
4439            });
4440        }
4441    };
4442
4443    let mut warnings = Vec::new();
4444    let mut has_no_xfa = true;
4445    let mut has_no_needs_rendering = true;
4446    let mut has_no_acroform = true;
4447
4448    // Check catalog for AcroForm, NeedsRendering, and XFA.
4449    let root_id = doc.trailer.get(b"Root").ok().and_then(|o| {
4450        if let Object::Reference(id) = o {
4451            Some(*id)
4452        } else {
4453            None
4454        }
4455    });
4456
4457    if let Some(rid) = root_id {
4458        if let Ok(catalog) = doc.get_dictionary(rid) {
4459            if catalog.get(b"AcroForm").is_ok() {
4460                has_no_acroform = false;
4461                warnings.push("/AcroForm still present in catalog".into());
4462
4463                // Check whether the AcroForm dict contains /XFA.
4464                let acroform_has_xfa = catalog
4465                    .get(b"AcroForm")
4466                    .ok()
4467                    .and_then(|o| match o {
4468                        Object::Reference(id) => doc.get_dictionary(*id).ok(),
4469                        Object::Dictionary(d) => Some(d),
4470                        _ => None,
4471                    })
4472                    .map(|d| d.get(b"XFA").is_ok())
4473                    .unwrap_or(false);
4474
4475                if acroform_has_xfa {
4476                    has_no_xfa = false;
4477                    warnings.push("/XFA still present in AcroForm dictionary".into());
4478                }
4479            }
4480
4481            if catalog.get(b"NeedsRendering").is_ok() {
4482                has_no_needs_rendering = false;
4483                warnings.push("/NeedsRendering still present in catalog".into());
4484            }
4485
4486            // Direct /XFA on catalog (non-standard but possible).
4487            if catalog.get(b"XFA").is_ok() {
4488                has_no_xfa = false;
4489                warnings.push("/XFA still present directly in catalog".into());
4490            }
4491        }
4492    }
4493
4494    // Check page annotations for widget annotations.
4495    let page_ids: Vec<ObjectId> = doc.page_iter().collect();
4496    let page_count = page_ids.len();
4497    for page_id in page_ids {
4498        for annot_obj in page_annotations(&doc, page_id) {
4499            let is_widget = annot_obj
4500                .as_reference()
4501                .ok()
4502                .and_then(|id| doc.get_dictionary(id).ok())
4503                .and_then(|d| {
4504                    d.get(b"Subtype")
4505                        .ok()
4506                        .map(|st| st == &Object::Name(b"Widget".to_vec()))
4507                })
4508                .unwrap_or(false);
4509            if is_widget {
4510                warnings.push(format!(
4511                    "widget annotation found on page (object {:?})",
4512                    annot_obj
4513                ));
4514            }
4515        }
4516    }
4517
4518    Ok(FlattenValidation {
4519        has_no_xfa,
4520        has_no_needs_rendering,
4521        has_no_acroform,
4522        page_count,
4523        warnings,
4524    })
4525}
4526
4527// ---------------------------------------------------------------------------
4528// XFA-F6-04 (#1112): Flatten quality metrics
4529// ---------------------------------------------------------------------------
4530
4531/// Metrics comparing a PDF before and after flattening.
4532///
4533/// Used by [`compare_flatten_quality`] and the `flatten-check` CLI subcommand.
4534pub struct FlattenQualityMetrics {
4535    /// Number of pages in the original (pre-flatten) PDF.
4536    pub page_count_before: usize,
4537    /// Number of pages in the flattened (post-flatten) PDF.
4538    pub page_count_after: usize,
4539    /// True when `page_count_before == page_count_after`.
4540    pub page_count_match: bool,
4541    /// Total byte length of all content streams in the original PDF.
4542    pub content_stream_bytes_before: usize,
4543    /// Total byte length of all content streams in the flattened PDF.
4544    pub content_stream_bytes_after: usize,
4545    /// Ratio of after/before content stream bytes. 1.0 = same size, <1.0 = smaller.
4546    /// Returns 1.0 when `content_stream_bytes_before == 0` to avoid division by zero.
4547    pub content_ratio: f64,
4548}
4549
4550/// Compute quality metrics comparing the original PDF to its flattened version.
4551///
4552/// Parses both byte slices and compares page count and total content stream size.
4553/// Returns an error only if both PDFs fail to parse.
4554pub fn compare_flatten_quality(
4555    original_bytes: &[u8],
4556    flattened_bytes: &[u8],
4557) -> Result<FlattenQualityMetrics> {
4558    fn count_pages_and_stream_bytes(pdf_bytes: &[u8]) -> (usize, usize) {
4559        let doc = match Document::load_mem(pdf_bytes) {
4560            Ok(d) => d,
4561            Err(_) => return (0, 0),
4562        };
4563        let page_count = doc.page_iter().count();
4564        let stream_bytes: usize = doc
4565            .objects
4566            .values()
4567            .filter_map(|obj| {
4568                if let Object::Stream(s) = obj {
4569                    // Use decompressed content length when available.
4570                    s.content.len().into()
4571                } else {
4572                    None
4573                }
4574            })
4575            .sum();
4576        (page_count, stream_bytes)
4577    }
4578
4579    let (page_count_before, content_stream_bytes_before) =
4580        count_pages_and_stream_bytes(original_bytes);
4581    let (page_count_after, content_stream_bytes_after) =
4582        count_pages_and_stream_bytes(flattened_bytes);
4583
4584    let content_ratio = if content_stream_bytes_before == 0 {
4585        1.0_f64
4586    } else {
4587        content_stream_bytes_after as f64 / content_stream_bytes_before as f64
4588    };
4589
4590    Ok(FlattenQualityMetrics {
4591        page_count_before,
4592        page_count_after,
4593        page_count_match: page_count_before == page_count_after,
4594        content_stream_bytes_before,
4595        content_stream_bytes_after,
4596        content_ratio,
4597    })
4598}
4599
4600// ---------------------------------------------------------------------------
4601// XFA-F7-02 (#1114): Text completeness validation
4602// ---------------------------------------------------------------------------
4603
4604/// Result of a text completeness validation pass.
4605///
4606/// Compares the data values bound in the original XFA datasets against the
4607/// text content extracted from the flattened PDF to verify all field values
4608/// appear in the output.
4609pub struct TextValidation {
4610    /// Data values extracted from the original XFA datasets XML.
4611    pub expected_values: Vec<String>,
4612    /// Values from `expected_values` that were found in the output text.
4613    pub found_values: Vec<String>,
4614    /// Values from `expected_values` that were NOT found in the output text.
4615    pub missing_values: Vec<String>,
4616    /// Ratio of found/expected. 1.0 means all expected values are present.
4617    /// Returns 1.0 when `expected_values` is empty (nothing to check).
4618    pub completeness_ratio: f64,
4619}
4620
4621/// Extract all non-empty text node values from XFA `<field>` elements in the
4622/// datasets XML packet.
4623fn extract_field_values_from_datasets(datasets_xml: &str) -> Vec<String> {
4624    // Minimal parser: locate every <field …> … </field> block and grab direct
4625    // text content (the value node inside).  We keep this dependency-free by
4626    // doing a simple byte-scan rather than pulling in an XML parser.
4627    let mut values = Vec::new();
4628    let mut remaining = datasets_xml;
4629
4630    while let Some(open_pos) = remaining.find("<field") {
4631        // Advance past the opening tag itself (up to the closing `>`).
4632        let tag_end = match remaining[open_pos..].find('>') {
4633            Some(p) => open_pos + p + 1,
4634            None => break,
4635        };
4636
4637        // Self-closing tag (<field … />) — no value.
4638        if remaining[open_pos..tag_end].ends_with("/>") {
4639            remaining = &remaining[tag_end..];
4640            continue;
4641        }
4642
4643        // Find the matching </field>.
4644        let close_tag = "</field>";
4645        match remaining[tag_end..].find(close_tag) {
4646            Some(close_pos) => {
4647                let inner = &remaining[tag_end..tag_end + close_pos];
4648                // Extract text from a nested <value><text>…</text></value> or
4649                // just plain text between the tags.
4650                let text = extract_innermost_text(inner);
4651                if !text.is_empty() {
4652                    values.push(text);
4653                }
4654                remaining = &remaining[tag_end + close_pos + close_tag.len()..];
4655            }
4656            None => break,
4657        }
4658    }
4659    values
4660}
4661
4662/// Given the inner content of a `<field>` element, return the first non-empty
4663/// text value found (handles `<value><text>…</text></value>` nesting).
4664fn extract_innermost_text(inner: &str) -> String {
4665    // Try <text>…</text> first.
4666    if let Some(start) = inner.find("<text>") {
4667        let content_start = start + "<text>".len();
4668        if let Some(end) = inner[content_start..].find("</text>") {
4669            let s = inner[content_start..content_start + end].trim().to_string();
4670            if !s.is_empty() {
4671                return s;
4672            }
4673        }
4674    }
4675    // Fall back to stripping all XML tags and returning the trimmed text.
4676    let stripped = strip_xml_tags(inner);
4677    stripped.trim().to_string()
4678}
4679
4680/// Remove XML/HTML tags from a string, returning only the text content.
4681fn strip_xml_tags(s: &str) -> String {
4682    let mut out = String::with_capacity(s.len());
4683    let mut in_tag = false;
4684    for ch in s.chars() {
4685        match ch {
4686            '<' => in_tag = true,
4687            '>' => in_tag = false,
4688            _ if !in_tag => out.push(ch),
4689            _ => {}
4690        }
4691    }
4692    out
4693}
4694
4695/// Extract visible text from a PDF content stream by scanning for the `Tj`,
4696/// `TJ`, `'`, and `"` text-showing operators.
4697///
4698/// This is a best-effort scan of the raw (potentially un-decoded) bytes.
4699/// It does not handle all encodings or compressed streams but is sufficient
4700/// for validating that literal ASCII/Latin text values are present.
4701fn extract_text_from_pdf_bytes(pdf_bytes: &[u8]) -> String {
4702    let doc = match Document::load_mem(pdf_bytes) {
4703        Ok(d) => d,
4704        Err(_) => return String::new(),
4705    };
4706
4707    let mut text = String::new();
4708
4709    for obj in doc.objects.values() {
4710        if let Object::Stream(ref stream) = obj {
4711            // Read raw stream content (decompression may fail silently).
4712            let content = match stream.decompressed_content() {
4713                Ok(c) => c,
4714                Err(_) => stream.content.clone(),
4715            };
4716            let fragment = extract_text_from_content_stream(&content);
4717            if !fragment.is_empty() {
4718                text.push(' ');
4719                text.push_str(&fragment);
4720            }
4721        }
4722    }
4723    text
4724}
4725
4726/// Scan a PDF content stream byte slice for string operands attached to
4727/// text-showing operators (Tj, TJ, ', ").
4728fn extract_text_from_content_stream(content: &[u8]) -> String {
4729    let s = String::from_utf8_lossy(content);
4730    let mut result = String::new();
4731
4732    // Find parenthesis-delimited strings: (…) followed optionally by whitespace
4733    // and then one of the text operators.
4734    for (i, ch) in s.char_indices() {
4735        if ch == '(' {
4736            // Collect until matching ')'.
4737            let start = i + 1;
4738            let mut depth: i32 = 1;
4739            let mut end = start;
4740            let bytes = s.as_bytes();
4741            while end < bytes.len() && depth > 0 {
4742                match bytes[end] {
4743                    b'(' => depth += 1,
4744                    b')' => depth -= 1,
4745                    b'\\' => {
4746                        end += 1; // skip escaped char
4747                    }
4748                    _ => {}
4749                }
4750                end += 1;
4751            }
4752            if depth == 0 {
4753                let literal = &s[start..end - 1];
4754                // Only collect printable ASCII — skip binary font strings.
4755                if literal.chars().all(|c| {
4756                    c.is_ascii()
4757                        && (c.is_alphanumeric() || c.is_whitespace() || c.is_ascii_punctuation())
4758                }) {
4759                    let trimmed = literal.trim();
4760                    if !trimmed.is_empty() {
4761                        result.push(' ');
4762                        result.push_str(trimmed);
4763                    }
4764                }
4765            }
4766        }
4767    }
4768    result
4769}
4770
4771/// Validate that all data values bound in the original XFA form appear in the
4772/// text content of the flattened PDF.
4773///
4774/// Steps:
4775/// 1. Extract the XFA `datasets` packet from `original_xfa_bytes`.
4776/// 2. Parse all `<field>` values from the datasets XML.
4777/// 3. Scan the flattened PDF's content streams for those strings.
4778/// 4. Return a [`TextValidation`] with completeness metrics.
4779///
4780/// Returns `Ok` even when the datasets packet is absent or the XFA cannot be
4781/// parsed — in that case `expected_values` will be empty and
4782/// `completeness_ratio` will be `1.0`.
4783pub fn validate_text_completeness(
4784    original_xfa_bytes: &[u8],
4785    flattened_bytes: &[u8],
4786) -> crate::error::Result<TextValidation> {
4787    // Step 1: extract the datasets packet from the original XFA PDF.
4788    let packets = match crate::extract::extract_xfa_from_bytes(original_xfa_bytes.to_vec()) {
4789        Ok(p) => p,
4790        Err(_) => {
4791            // Cannot extract XFA — nothing to validate.
4792            return Ok(TextValidation {
4793                expected_values: vec![],
4794                found_values: vec![],
4795                missing_values: vec![],
4796                completeness_ratio: 1.0,
4797            });
4798        }
4799    };
4800
4801    let datasets_xml = match packets.datasets() {
4802        Some(ds) => ds.to_string(),
4803        None => {
4804            return Ok(TextValidation {
4805                expected_values: vec![],
4806                found_values: vec![],
4807                missing_values: vec![],
4808                completeness_ratio: 1.0,
4809            });
4810        }
4811    };
4812
4813    // Step 2: extract field values.
4814    let expected_values = extract_field_values_from_datasets(&datasets_xml);
4815
4816    if expected_values.is_empty() {
4817        return Ok(TextValidation {
4818            expected_values: vec![],
4819            found_values: vec![],
4820            missing_values: vec![],
4821            completeness_ratio: 1.0,
4822        });
4823    }
4824
4825    // Step 3: extract text from the flattened PDF.
4826    let output_text = extract_text_from_pdf_bytes(flattened_bytes);
4827
4828    // Step 4: check which expected values appear in the output.
4829    let mut found_values = Vec::new();
4830    let mut missing_values = Vec::new();
4831
4832    for value in &expected_values {
4833        if output_text.contains(value.as_str()) {
4834            found_values.push(value.clone());
4835        } else {
4836            missing_values.push(value.clone());
4837        }
4838    }
4839
4840    let completeness_ratio = if expected_values.is_empty() {
4841        1.0
4842    } else {
4843        found_values.len() as f64 / expected_values.len() as f64
4844    };
4845
4846    Ok(TextValidation {
4847        expected_values,
4848        found_values,
4849        missing_values,
4850        completeness_ratio,
4851    })
4852}
4853
4854// ---------------------------------------------------------------------------
4855// Tests
4856// ---------------------------------------------------------------------------
4857
4858/// Test helper (GL-QA36): simulate a re-entrant call to flatten_xfa_to_pdf by
4859/// pre-setting FLATTEN_DEPTH to 1 before calling.  This is used to verify the
4860/// recursion guard without exposing the thread-local to the test sub-module.
4861///
4862/// IMPORTANT: This function resets FLATTEN_DEPTH to 0 before returning so that
4863/// subsequent calls on the same thread are not affected.
4864#[cfg(test)]
4865fn flatten_xfa_to_pdf_simulate_reentrant(pdf_bytes: &[u8]) -> Result<Vec<u8>> {
4866    FLATTEN_DEPTH.with(|d| d.set(1));
4867    let result = flatten_xfa_to_pdf(pdf_bytes);
4868    // Reset — the guard will have left depth at 1 because it detected depth>=1
4869    // and returned early before the DepthGuard could decrement.
4870    FLATTEN_DEPTH.with(|d| d.set(0));
4871    result
4872}
4873
4874#[cfg(test)]
4875mod tests {
4876    use super::*;
4877
4878    /// 63 nested FormCalc user functions, each carrying a 60-term expression:
4879    /// legal by every bound on its own, ~3900 evaluation levels together.
4880    const HOSTILE_FORMCALC: &[u8] =
4881        include_bytes!("../../../fixtures/formcalc/hostile_deep_call_chain.pdf");
4882
4883    /// The same kind of form with the script it shipped with.
4884    const BENIGN_FORMCALC: &[u8] =
4885        include_bytes!("../../../fixtures/formcalc/fc_01_arithmetic.pdf");
4886
4887    /// `xfa_flatten_inner` as the wasm32 build runs it: no thread of its own,
4888    /// on whatever stack the caller has.
4889    fn flatten_inline(pdf_bytes: &'static [u8]) -> bool {
4890        let packets = extract_xfa_from_bytes(pdf_bytes.to_vec()).expect("fixture carries XFA");
4891        let template_xml = strip_undefined_xml_entities(packets.template().expect("template"));
4892        let datasets_xml = packets.datasets().map(strip_undefined_xml_entities);
4893        let form_xml = packets.get_packet("form").map(|s| s.to_string());
4894        xfa_flatten_inner(
4895            pdf_bytes,
4896            &template_xml,
4897            datasets_xml.as_deref(),
4898            form_xml.as_deref(),
4899            false,
4900            XfaRenderingPolicy::default(),
4901        )
4902        .is_ok()
4903    }
4904
4905    /// Run `f` on a thread with exactly `bytes` of stack. An overflow does not
4906    /// come back through `join`; it aborts the process.
4907    fn on_a_stack_of(bytes: usize, f: impl FnOnce() -> bool + Send + 'static) -> bool {
4908        std::thread::Builder::new()
4909            .stack_size(bytes)
4910            .spawn(f)
4911            .expect("spawn")
4912            .join()
4913            .expect("the pipeline must return, not overflow the stack")
4914    }
4915
4916    /// On wasm32 there is no 32 MB worker: `flatten` runs the whole pipeline
4917    /// inline on the host's stack, which is 1 MiB by default and set nowhere
4918    /// in this tree. The native tests cannot see that path, because on native
4919    /// the worker thread's stack hides whatever the evaluator costs. This runs
4920    /// the same inner pipeline on a 1 MiB thread -- the shipped wasm32 shape,
4921    /// on native -- against the form that descended ~3900 levels (#305).
4922    #[test]
4923    fn the_inline_pipeline_returns_for_the_hostile_form_on_a_1_mib_stack() {
4924        // "Any answer but an abort" was the whole assertion here, and an abort
4925        // is not something an assertion catches -- it takes the test binary
4926        // with it. That made this test, which carries the claim about the
4927        // wasm32 shape, indistinguishable from one where `xfa_flatten_inner`
4928        // never reaches the evaluator at all. (review of #1677)
4929        //
4930        // What the fix actually claims is stronger and is assertable: the
4931        // evaluator's budget is measured inside the pipeline, so the answer does
4932        // not depend on how much stack the caller happened to have. A 1 MiB
4933        // inline run -- the shipped wasm32 shape -- must therefore agree with a
4934        // roomy one. If the budget were inherited from the caller, or if 1 MiB
4935        // silently took a different path, these two would differ.
4936        let roomy = on_a_stack_of(8 << 20, || flatten_inline(HOSTILE_FORMCALC));
4937        let wasm_shape = on_a_stack_of(1 << 20, || flatten_inline(HOSTILE_FORMCALC));
4938        assert_eq!(
4939            wasm_shape, roomy,
4940            "the hostile form answered differently on a 1 MiB stack ({wasm_shape}) \
4941             than on 8 MiB ({roomy}); the budget is supposed to make the caller's \
4942             stack irrelevant"
4943        );
4944    }
4945
4946    /// The acceptance side of the same path: an ordinary FormCalc form still
4947    /// flattens on the smallest stack the pipeline ships on.
4948    #[test]
4949    fn the_inline_pipeline_flattens_an_ordinary_form_on_a_1_mib_stack() {
4950        assert!(
4951            on_a_stack_of(1 << 20, || flatten_inline(BENIGN_FORMCALC)),
4952            "the benign control stopped flattening on a 1 MiB stack"
4953        );
4954    }
4955
4956    /// Build a minimal XFA PDF in memory (same as generate_xfa_layout_fixtures).
4957    fn build_xfa_pdf_with_content(xdp: &str, page_content: Vec<u8>) -> Vec<u8> {
4958        use lopdf::{dictionary, Document, Object, Stream};
4959        let mut doc = Document::with_version("1.4");
4960        let xdp_bytes = xdp.as_bytes().to_vec();
4961        let xfa_stream = Stream::new(
4962            dictionary! { "Length" => Object::Integer(xdp_bytes.len() as i64) },
4963            xdp_bytes,
4964        );
4965        let xfa_id = doc.add_object(Object::Stream(xfa_stream));
4966        let pages_id = doc.new_object_id();
4967        let content_stream = Stream::new(
4968            dictionary! { "Length" => Object::Integer(page_content.len() as i64) },
4969            page_content,
4970        );
4971        let content_id = doc.add_object(Object::Stream(content_stream));
4972        let page_id = doc.add_object(Object::Dictionary(dictionary! {
4973            "Type"     => Object::Name(b"Page".to_vec()),
4974            "Parent"   => Object::Reference(pages_id),
4975            "MediaBox" => Object::Array(vec![
4976                Object::Integer(0), Object::Integer(0),
4977                Object::Integer(612), Object::Integer(792),
4978            ]),
4979            "Contents" => Object::Reference(content_id)
4980        }));
4981        doc.objects.insert(
4982            pages_id,
4983            Object::Dictionary(dictionary! {
4984                "Type"  => Object::Name(b"Pages".to_vec()),
4985                "Kids"  => Object::Array(vec![Object::Reference(page_id)]),
4986                "Count" => Object::Integer(1)
4987            }),
4988        );
4989        let acroform_id = doc.add_object(Object::Dictionary(dictionary! {
4990            "XFA"    => Object::Reference(xfa_id),
4991            "Fields" => Object::Array(vec![])
4992        }));
4993        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
4994            "Type"     => Object::Name(b"Catalog".to_vec()),
4995            "Pages"    => Object::Reference(pages_id),
4996            "AcroForm" => Object::Reference(acroform_id)
4997        }));
4998        doc.trailer.set("Root", Object::Reference(catalog_id));
4999        let mut out = Vec::new();
5000        doc.save_to(&mut out).unwrap();
5001        out
5002    }
5003
5004    fn build_xfa_pdf(xdp: &str) -> Vec<u8> {
5005        build_xfa_pdf_with_content(xdp, Vec::new())
5006    }
5007
5008    /// A `<draw><value><line/></value></draw>` cell-grid rule (the `FFLineN`
5009    /// draws in table-style gov forms) must flatten into a stroked line that
5010    /// spans the draw's laid-out box. Guards the full pipeline
5011    /// (merge → layout → render): XFA `<line>` values carry no x1/y1/x2/y2, so
5012    /// the parser yields `Line{0,0,0,0}`; the legacy path stroked a zero-length
5013    /// point and the whole cell grid vanished. (XFA 3.3 §2.6.)
5014    #[test]
5015    fn flatten_line_draw_renders_spanning_rule() {
5016        const LINE_XDP: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
5017<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
5018<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
5019  <subform name="form1" layout="paginate">
5020    <pageSet>
5021      <pageArea name="Page1">
5022        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
5023        <medium stock="default" short="8.5in" long="11in"/>
5024      </pageArea>
5025    </pageSet>
5026    <subform name="grid" layout="positioned" w="7.5in" h="10in">
5027      <draw name="FFLine1" w="100mm" h="0mm" x="10mm" y="50mm">
5028        <value>
5029          <line hand="right">
5030            <edge cap="butt" thickness="0.5mm"/>
5031          </line>
5032        </value>
5033        <ui><defaultUi/></ui>
5034      </draw>
5035    </subform>
5036  </subform>
5037</template>
5038</xdp:xdp>"#;
5039        let pdf = build_xfa_pdf(LINE_XDP);
5040        let out = flatten_xfa_to_pdf(&pdf).expect("flatten line");
5041        let _ = extract_text_from_pdf_bytes(&out); // force decode path
5042
5043        let doc = Document::load_mem(&out).expect("load flattened line PDF");
5044        let mut all_ops = String::new();
5045        for page_id in doc.page_iter() {
5046            if let Ok(page_dict) = doc.get_dictionary(page_id) {
5047                if let Ok(Object::Reference(sid)) = page_dict.get(b"Contents") {
5048                    if let Ok(stream) = doc.get_object(*sid).and_then(|o| o.as_stream().cloned()) {
5049                        let bytes = stream.decompressed_content().unwrap_or(stream.content);
5050                        all_ops.push_str(&String::from_utf8_lossy(&bytes));
5051                    }
5052                }
5053            }
5054        }
5055        // The horizontal grid rule must span its 100mm (~283pt) box: find an
5056        // "sx sy m / ex ey l" pair whose horizontal extent is large (not the
5057        // legacy zero-length point), and confirm the authored 0.5mm weight.
5058        let mut max_span = 0.0_f64;
5059        let mut last_mx: Option<f64> = None;
5060        for line in all_ops.lines() {
5061            let parts: Vec<&str> = line.split_whitespace().collect();
5062            if parts.len() == 3 && parts[2] == "m" {
5063                last_mx = parts[0].parse::<f64>().ok();
5064            } else if parts.len() == 3 && parts[2] == "l" {
5065                if let (Some(mx), Ok(ex)) = (last_mx, parts[0].parse::<f64>()) {
5066                    max_span = max_span.max((ex - mx).abs());
5067                }
5068            }
5069        }
5070        assert!(
5071            max_span > 100.0,
5072            "grid rule must span its box (~283pt), got max horizontal span {max_span:.1}:\n{all_ops}"
5073        );
5074        assert!(
5075            all_ops.contains(" w\n"),
5076            "line must stroke at the authored edge weight:\n{all_ops}"
5077        );
5078    }
5079
5080    fn build_xfa_doc_with_xfa_array() -> (Document, ObjectId, Vec<ObjectId>) {
5081        use lopdf::{dictionary, Document, Object, Stream};
5082
5083        let mut doc = Document::with_version("1.4");
5084        let pages_id = doc.new_object_id();
5085        let content_id = doc.add_object(Object::Stream(Stream::new(
5086            dictionary! { "Length" => Object::Integer(0) },
5087            Vec::new(),
5088        )));
5089        let page_id = doc.add_object(Object::Dictionary(dictionary! {
5090            "Type"     => Object::Name(b"Page".to_vec()),
5091            "Parent"   => Object::Reference(pages_id),
5092            "MediaBox" => Object::Array(vec![
5093                Object::Integer(0), Object::Integer(0),
5094                Object::Integer(612), Object::Integer(792),
5095            ]),
5096            "Contents" => Object::Reference(content_id)
5097        }));
5098        doc.objects.insert(
5099            pages_id,
5100            Object::Dictionary(dictionary! {
5101                "Type"  => Object::Name(b"Pages".to_vec()),
5102                "Kids"  => Object::Array(vec![Object::Reference(page_id)]),
5103                "Count" => Object::Integer(1)
5104            }),
5105        );
5106
5107        let packet_payloads = [
5108            (
5109                b"xdp:xdp".to_vec(),
5110                br#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"></xdp:xdp>"#.to_vec(),
5111            ),
5112            (
5113                b"template".to_vec(),
5114                br#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform/></template>"#
5115                    .to_vec(),
5116            ),
5117            (
5118                b"datasets".to_vec(),
5119                br#"<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"></xfa:datasets>"#
5120                    .to_vec(),
5121            ),
5122        ];
5123
5124        let mut xfa_array = Vec::new();
5125        let mut xfa_ids = Vec::new();
5126        for (packet_name, payload) in packet_payloads {
5127            let stream_id = doc.add_object(Object::Stream(Stream::new(
5128                dictionary! { "Length" => Object::Integer(payload.len() as i64) },
5129                payload,
5130            )));
5131            xfa_array.push(Object::Name(packet_name));
5132            xfa_array.push(Object::Reference(stream_id));
5133            xfa_ids.push(stream_id);
5134        }
5135
5136        let acroform_id = doc.add_object(Object::Dictionary(dictionary! {
5137            "XFA"    => Object::Array(xfa_array),
5138            "Fields" => Object::Array(vec![])
5139        }));
5140        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
5141            "Type"     => Object::Name(b"Catalog".to_vec()),
5142            "Pages"    => Object::Reference(pages_id),
5143            "AcroForm" => Object::Reference(acroform_id)
5144        }));
5145        doc.trailer.set("Root", Object::Reference(catalog_id));
5146        (doc, acroform_id, xfa_ids)
5147    }
5148
5149    fn build_xfa_pdf_with_widget_appearance(
5150        page_content: Vec<u8>,
5151        normal_appearance: Object,
5152        widget_extra: Dictionary,
5153    ) -> Vec<u8> {
5154        use lopdf::{dictionary, Document, Object, Stream};
5155
5156        let mut doc = Document::with_version("1.4");
5157        let xdp_bytes = SIMPLE_XDP.as_bytes().to_vec();
5158        let xfa_stream = Stream::new(
5159            dictionary! { "Length" => Object::Integer(xdp_bytes.len() as i64) },
5160            xdp_bytes,
5161        );
5162        let xfa_id = doc.add_object(Object::Stream(xfa_stream));
5163
5164        let pages_id = doc.new_object_id();
5165        let content_id = doc.add_object(Object::Stream(Stream::new(
5166            dictionary! { "Length" => Object::Integer(page_content.len() as i64) },
5167            page_content,
5168        )));
5169
5170        let appearance_id = match normal_appearance {
5171            Object::Reference(id) => id,
5172            other => doc.add_object(other),
5173        };
5174
5175        let widget_id = doc.new_object_id();
5176        let page_id = doc.add_object(Object::Dictionary(dictionary! {
5177            "Type"     => Object::Name(b"Page".to_vec()),
5178            "Parent"   => Object::Reference(pages_id),
5179            "MediaBox" => Object::Array(vec![
5180                Object::Integer(0), Object::Integer(0),
5181                Object::Integer(612), Object::Integer(792),
5182            ]),
5183            "Contents" => Object::Reference(content_id),
5184            "Annots"   => Object::Array(vec![Object::Reference(widget_id)]),
5185            "Resources" => Object::Dictionary(dictionary! {})
5186        }));
5187
5188        let mut widget = dictionary! {
5189            "Type"    => Object::Name(b"Annot".to_vec()),
5190            "Subtype" => Object::Name(b"Widget".to_vec()),
5191            "Rect"    => Object::Array(vec![
5192                Object::Integer(100), Object::Integer(700),
5193                Object::Integer(220), Object::Integer(730),
5194            ]),
5195            "AP"      => Object::Dictionary(dictionary! {
5196                "N" => Object::Reference(appearance_id)
5197            }),
5198            "P"       => Object::Reference(page_id)
5199        };
5200        for (key, value) in widget_extra {
5201            widget.set(key, value);
5202        }
5203        doc.objects.insert(widget_id, Object::Dictionary(widget));
5204
5205        doc.objects.insert(
5206            pages_id,
5207            Object::Dictionary(dictionary! {
5208                "Type"  => Object::Name(b"Pages".to_vec()),
5209                "Kids"  => Object::Array(vec![Object::Reference(page_id)]),
5210                "Count" => Object::Integer(1)
5211            }),
5212        );
5213
5214        let acroform_id = doc.add_object(Object::Dictionary(dictionary! {
5215            "XFA"    => Object::Reference(xfa_id),
5216            "Fields" => Object::Array(vec![Object::Reference(widget_id)])
5217        }));
5218        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
5219            "Type"     => Object::Name(b"Catalog".to_vec()),
5220            "Pages"    => Object::Reference(pages_id),
5221            "AcroForm" => Object::Reference(acroform_id)
5222        }));
5223        doc.trailer.set("Root", Object::Reference(catalog_id));
5224
5225        let mut out = Vec::new();
5226        doc.save_to(&mut out).unwrap();
5227        out
5228    }
5229
5230    #[allow(dead_code)]
5231    fn find_last_content_stream(doc: &Document, page_id: ObjectId) -> &Stream {
5232        let page_dict = doc.get_dictionary(page_id).expect("page dict");
5233        match page_dict.get(b"Contents").expect("contents") {
5234            Object::Reference(id) => doc
5235                .get_object(*id)
5236                .expect("contents object")
5237                .as_stream()
5238                .expect("contents stream"),
5239            Object::Array(arr) => {
5240                let last = arr.last().expect("last content stream");
5241                let id = last.as_reference().expect("contents ref");
5242                doc.get_object(id)
5243                    .expect("contents object")
5244                    .as_stream()
5245                    .expect("contents stream")
5246            }
5247            other => other.as_stream().expect("contents stream"),
5248        }
5249    }
5250
5251    #[allow(dead_code)]
5252    fn page_xobjects(doc: &Document, page_id: ObjectId) -> Dictionary {
5253        let page_dict = doc.get_dictionary(page_id).expect("page dict");
5254        let resources = page_dict
5255            .get(b"Resources")
5256            .expect("resources")
5257            .as_dict()
5258            .expect("resources dict");
5259        resources
5260            .get(b"XObject")
5261            .expect("xobjects")
5262            .as_dict()
5263            .expect("xobject dict")
5264            .clone()
5265    }
5266
5267    #[test]
5268    fn append_to_page_content_flattens_indirect_contents_arrays() {
5269        let mut doc = Document::with_version("1.4");
5270        let pages_id = doc.new_object_id();
5271        let first_stream_id = doc.add_object(Stream::new(dictionary! {}, b"q\n".to_vec()));
5272        let second_stream_id = doc.add_object(Stream::new(dictionary! {}, b"Q\n".to_vec()));
5273        let contents_array_id = doc.add_object(Object::Array(vec![
5274            Object::Reference(first_stream_id),
5275            Object::Reference(second_stream_id),
5276        ]));
5277        let page_id = doc.add_object(Object::Dictionary(dictionary! {
5278            "Type" => Object::Name(b"Page".to_vec()),
5279            "Parent" => Object::Reference(pages_id),
5280            "MediaBox" => Object::Array(vec![
5281                Object::Integer(0), Object::Integer(0),
5282                Object::Integer(612), Object::Integer(792),
5283            ]),
5284            "Contents" => Object::Reference(contents_array_id),
5285        }));
5286        doc.objects.insert(
5287            pages_id,
5288            Object::Dictionary(dictionary! {
5289                "Type" => Object::Name(b"Pages".to_vec()),
5290                "Kids" => Object::Array(vec![Object::Reference(page_id)]),
5291                "Count" => Object::Integer(1),
5292            }),
5293        );
5294
5295        append_to_page_content(&mut doc, page_id, b"BT\nET\n");
5296
5297        let page_dict = doc.get_dictionary(page_id).expect("page dict");
5298        let contents = page_dict.get(b"Contents").expect("contents");
5299        let items = contents.as_array().expect("flattened contents array");
5300
5301        assert_eq!(items.len(), 3, "existing streams + appended stream");
5302        assert!(
5303            items.iter().all(|obj| obj.as_reference().is_ok()),
5304            "contents array must stay flat and reference only streams"
5305        );
5306        for object in items {
5307            let stream_id = object.as_reference().expect("stream ref");
5308            assert!(
5309                doc.get_object(stream_id)
5310                    .expect("stream object")
5311                    .as_stream()
5312                    .is_ok(),
5313                "nested arrays must not survive in page contents"
5314            );
5315        }
5316    }
5317
5318    const SIMPLE_XDP: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
5319<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
5320<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
5321  <subform name="form1" layout="paginate">
5322    <pageSet>
5323      <pageArea name="Page1">
5324        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
5325        <medium stock="default" short="8.5in" long="11in"/>
5326      </pageArea>
5327    </pageSet>
5328    <subform name="section" layout="tb" w="7.5in">
5329      <field name="firstName" w="3.5in" h="0.3in">
5330        <caption><value><text>First Name</text></value></caption>
5331        <ui><textEdit/></ui>
5332        <value><text>John</text></value>
5333      </field>
5334    </subform>
5335  </subform>
5336</template>
5337</xdp:xdp>"#;
5338
5339    const JS_EVENT_XDP: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
5340<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
5341<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
5342  <subform name="form1" layout="paginate">
5343    <pageSet>
5344      <pageArea name="Page1">
5345        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
5346        <medium stock="default" short="8.5in" long="11in"/>
5347      </pageArea>
5348    </pageSet>
5349    <subform name="section" layout="tb" w="7.5in">
5350      <event activity="initialize">
5351        <script contentType="application/x-javascript">app.alert('blocked');</script>
5352      </event>
5353      <field name="firstName" w="3.5in" h="0.3in">
5354        <caption><value><text>First Name</text></value></caption>
5355        <ui><textEdit/></ui>
5356        <value><text>John</text></value>
5357      </field>
5358    </subform>
5359  </subform>
5360</template>
5361</xdp:xdp>"#;
5362
5363    fn overflowing_paginate_xdp(base_profile: Option<&str>) -> String {
5364        let mut fields = String::new();
5365        for i in 0..40 {
5366            fields.push_str(&format!(
5367                r#"
5368      <field name="line{i}" w="7.0in" h="0.3in">
5369        <ui><textEdit/></ui>
5370        <value><text>Line {i}</text></value>
5371      </field>"#
5372            ));
5373        }
5374
5375        let base_profile_attr = base_profile
5376            .map(|value| format!(r#" baseProfile="{value}""#))
5377            .unwrap_or_default();
5378
5379        format!(
5380            r#"<?xml version="1.0" encoding="UTF-8"?>
5381<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
5382<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"{base_profile_attr}>
5383  <subform name="form1" layout="paginate">
5384    <pageSet>
5385      <pageArea name="Page1">
5386        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
5387        <medium stock="default" short="8.5in" long="11in"/>
5388      </pageArea>
5389    </pageSet>
5390    <subform name="section" layout="tb" w="7.5in">{fields}
5391    </subform>
5392  </subform>
5393</template>
5394</xdp:xdp>"#
5395        )
5396    }
5397
5398    #[test]
5399    fn flatten_simple_form_produces_non_empty_content() {
5400        let pdf_bytes = build_xfa_pdf(SIMPLE_XDP);
5401        let result = flatten_xfa_to_pdf(&pdf_bytes).expect("flatten failed");
5402
5403        // Load the result and check the content stream is non-empty.
5404        let doc = Document::load_mem(&result).expect("load flattened PDF");
5405        let pages: Vec<ObjectId> = doc.page_iter().collect();
5406        assert!(!pages.is_empty(), "flattened PDF has no pages");
5407
5408        // At least one page should have a non-empty content stream.
5409        let mut found_content = false;
5410        for page_id in &pages {
5411            if let Ok(page_dict) = doc.get_dictionary(*page_id) {
5412                if let Ok(Object::Reference(stream_id)) = page_dict.get(b"Contents") {
5413                    if let Ok(obj) = doc.get_object(*stream_id) {
5414                        if let Ok(stream) = obj.as_stream() {
5415                            if !stream.content.is_empty() {
5416                                found_content = true;
5417                            }
5418                        }
5419                    }
5420                }
5421            }
5422        }
5423        assert!(found_content, "all content streams are empty after flatten");
5424    }
5425
5426    #[test]
5427    fn flatten_reports_best_effort_for_xfa_javascript_event() {
5428        let pdf_bytes = build_xfa_pdf(JS_EVENT_XDP);
5429
5430        let (flattened, metadata) =
5431            flatten_xfa_to_pdf_with_metadata(&pdf_bytes).expect("flatten should skip JS");
5432
5433        assert!(!flattened.is_empty());
5434        assert_eq!(metadata.output_quality, OutputQuality::BestEffort);
5435        assert!(metadata.dynamic_scripts.js_present);
5436        assert_eq!(metadata.dynamic_scripts.js_skipped, 1);
5437    }
5438
5439    #[test]
5440    fn flatten_strips_catalog_open_action_javascript() {
5441        let mut pdf_bytes = build_xfa_pdf(SIMPLE_XDP);
5442        {
5443            let mut doc = Document::load_mem(&pdf_bytes).expect("parse test PDF");
5444            let root_id = match doc.trailer.get(b"Root") {
5445                Ok(Object::Reference(id)) => *id,
5446                _ => panic!("no Root in test PDF"),
5447            };
5448            if let Ok(Object::Dictionary(catalog)) = doc.get_object_mut(root_id) {
5449                catalog.set(
5450                    "OpenAction",
5451                    Object::Dictionary(dictionary! {
5452                        "S" => Object::Name(b"JavaScript".to_vec()),
5453                        "JS" => Object::String(
5454                            b"app.alert('blocked')".to_vec(),
5455                            lopdf::StringFormat::Literal,
5456                        ),
5457                    }),
5458                );
5459            }
5460            let mut out = Vec::new();
5461            doc.save_to(&mut out).expect("save test PDF");
5462            pdf_bytes = out;
5463        }
5464
5465        let flattened = flatten_xfa_to_pdf(&pdf_bytes).expect("flatten failed");
5466        let doc = Document::load_mem(&flattened).expect("load flattened PDF");
5467        let root_id = match doc.trailer.get(b"Root") {
5468            Ok(Object::Reference(id)) => *id,
5469            _ => panic!("no Root in flattened PDF"),
5470        };
5471        let catalog = doc.get_dictionary(root_id).expect("catalog dict");
5472        assert!(
5473            catalog.get(b"OpenAction").is_err(),
5474            "/OpenAction JavaScript must be stripped from flattened output"
5475        );
5476    }
5477
5478    /// Tests the canonical XFA nesting: <subform layout="paginate"> wraps
5479    /// <pageSet> + lr-tb content rows.  Verifies the flatten produces a single
5480    /// page with visible field content (border operators in the content stream).
5481    /// Before the extract_page_structure fix this produced 2 pages: page 1
5482    /// was blank (pageSet occupied 792pt) and page 2 had the actual fields.
5483    #[test]
5484    fn flatten_paginate_subform_with_nested_pageset_produces_visible_content() {
5485        const LR_TB_XDP: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
5486<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
5487<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
5488  <subform name="form1" layout="paginate" locale="en_US">
5489    <pageSet>
5490      <pageArea name="Page1" id="Page1">
5491        <contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/>
5492        <medium stock="default" short="8.5in" long="11in"/>
5493      </pageArea>
5494    </pageSet>
5495    <subform name="row1" layout="lr-tb" w="7.5in" h="0.4in">
5496      <field name="firstName" w="3.5in" h="0.4in">
5497        <caption><value><text>First</text></value></caption>
5498        <ui><textEdit/></ui>
5499        <value><text>John</text></value>
5500      </field>
5501      <field name="lastName" w="3.5in" h="0.4in">
5502        <caption><value><text>Last</text></value></caption>
5503        <ui><textEdit/></ui>
5504        <value><text>Doe</text></value>
5505      </field>
5506    </subform>
5507  </subform>
5508</template>
5509</xdp:xdp>"#;
5510
5511        let pdf_bytes = build_xfa_pdf(LR_TB_XDP);
5512        let result = flatten_xfa_to_pdf(&pdf_bytes).expect("flatten failed");
5513
5514        let doc = Document::load_mem(&result).expect("load flattened PDF");
5515        let pages: Vec<ObjectId> = doc.page_iter().collect();
5516
5517        // Must produce exactly 1 page (not 2 as with the blank-first-page bug).
5518        assert_eq!(pages.len(), 1, "expected 1 page, got {}", pages.len());
5519
5520        // Page 1 must contain visible text operators from the field values.
5521        // (Fields with non-empty values produce WrappedText → BT/ET operators.)
5522        if let Ok(page_dict) = doc.get_dictionary(pages[0]) {
5523            if let Ok(lopdf::Object::Reference(stream_id)) = page_dict.get(b"Contents") {
5524                if let Ok(obj) = doc.get_object(*stream_id) {
5525                    if let Ok(stream) = obj.as_stream() {
5526                        let content = String::from_utf8_lossy(&stream.content);
5527                        assert!(
5528                            content.contains("BT\n"),
5529                            "no text operators in page 1 content stream (should have BT from field values)"
5530                        );
5531                        assert!(
5532                            content.contains("Tj\n"),
5533                            "no text show operators in page 1 content stream"
5534                        );
5535                    }
5536                }
5537            }
5538        }
5539    }
5540
5541    #[test]
5542    fn static_single_page_pdf_does_not_append_xfa_overflow_pages() {
5543        let xdp = overflowing_paginate_xdp(Some("interactiveForms"));
5544        let pdf_bytes = build_xfa_pdf(&xdp);
5545        let result = flatten_xfa_to_pdf(&pdf_bytes).expect("flatten failed");
5546
5547        let doc = Document::load_mem(&result).expect("load flattened PDF");
5548        let pages: Vec<ObjectId> = doc.page_iter().collect();
5549
5550        assert_eq!(
5551            pages.len(),
5552            1,
5553            "static 1-page PDFs should preserve the original page when XFA layout over-paginates"
5554        );
5555    }
5556
5557    #[test]
5558    fn dynamic_single_page_pdf_can_expand_beyond_original_page_count() {
5559        // Dynamic XFA forms may ship with a single placeholder PDF page while
5560        // Adobe lays out multiple pages from the XFA data/template at runtime.
5561        // Flattening must therefore preserve the layout engine's page count
5562        // instead of clamping to the original PDF page count.
5563        let xdp = overflowing_paginate_xdp(None);
5564        let pdf_bytes = build_xfa_pdf(&xdp);
5565        let result = flatten_xfa_to_pdf(&pdf_bytes).expect("flatten failed");
5566
5567        let doc = Document::load_mem(&result).expect("load flattened PDF");
5568        let pages: Vec<ObjectId> = doc.page_iter().collect();
5569
5570        assert_eq!(
5571            pages.len(),
5572            2,
5573            "dynamic 1-page PDFs should be allowed to grow when XFA layout paginates"
5574        );
5575    }
5576
5577    #[test]
5578    fn flatten_removes_acroform() {
5579        let pdf_bytes = build_xfa_pdf(SIMPLE_XDP);
5580        let result = flatten_xfa_to_pdf(&pdf_bytes).expect("flatten failed");
5581        let doc = Document::load_mem(&result).expect("load flattened PDF");
5582        let root_id = doc.trailer.get(b"Root").unwrap().as_reference().unwrap();
5583        let catalog = doc.get_dictionary(root_id).unwrap();
5584        assert!(
5585            catalog.get(b"AcroForm").is_err(),
5586            "/AcroForm still present after flatten"
5587        );
5588    }
5589
5590    #[test]
5591    fn flatten_non_xfa_pdf_unchanged() {
5592        // A PDF with no XFA should be returned as-is (no error).
5593        let mut doc = Document::with_version("1.4");
5594        let pages_id = doc.new_object_id();
5595        let page_id = doc.add_object(Object::Dictionary(dictionary! {
5596            "Type"   => Object::Name(b"Page".to_vec()),
5597            "Parent" => Object::Reference(pages_id),
5598            "MediaBox" => Object::Array(vec![
5599                Object::Integer(0), Object::Integer(0),
5600                Object::Integer(612), Object::Integer(792),
5601            ])
5602        }));
5603        doc.objects.insert(
5604            pages_id,
5605            Object::Dictionary(dictionary! {
5606                "Type"  => Object::Name(b"Pages".to_vec()),
5607                "Kids"  => Object::Array(vec![Object::Reference(page_id)]),
5608                "Count" => Object::Integer(1)
5609            }),
5610        );
5611        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
5612            "Type"  => Object::Name(b"Catalog".to_vec()),
5613            "Pages" => Object::Reference(pages_id)
5614        }));
5615        doc.trailer.set("Root", Object::Reference(catalog_id));
5616        let mut raw = Vec::new();
5617        doc.save_to(&mut raw).unwrap();
5618
5619        // flatten_xfa_to_pdf should return Ok (with the same bytes).
5620        let result = flatten_xfa_to_pdf(&raw).expect("flatten non-XFA failed");
5621        assert!(!result.is_empty());
5622    }
5623
5624    #[test]
5625    fn placeholder_only_page_does_not_trigger_static_strip_path() {
5626        const PLACEHOLDER_STREAM: &str = r#"BT
5627/Helv 24 Tf
562872 720 Td
5629(Please wait...) Tj
56300 -32 Td
5631(If this message is not eventually replaced by the proper contents of the document,) Tj
56320 -32 Td
5633(your PDF viewer may not be able to display this type of document.) Tj
56340 -32 Td
5635(You can upgrade to the latest version of Adobe Reader by visiting reader_download.) Tj
5636ET
5637"#;
5638
5639        let pdf_bytes =
5640            build_xfa_pdf_with_content(SIMPLE_XDP, PLACEHOLDER_STREAM.as_bytes().to_vec());
5641        let result = flatten_xfa_to_pdf(&pdf_bytes).expect("flatten failed");
5642
5643        let doc = Document::load_mem(&result).expect("load flattened PDF");
5644        let page_id = doc.page_iter().next().expect("flattened page");
5645        let page_dict = doc.get_dictionary(page_id).expect("page dict");
5646        let contents_id = page_dict
5647            .get(b"Contents")
5648            .ok()
5649            .and_then(|object| object.as_reference().ok())
5650            .expect("contents ref");
5651        let stream = doc
5652            .get_object(contents_id)
5653            .expect("contents object")
5654            .as_stream()
5655            .expect("contents stream");
5656        let content = String::from_utf8_lossy(&stream.content);
5657
5658        assert!(
5659            content.contains("John"),
5660            "flattened page should contain XFA-rendered field content"
5661        );
5662        assert!(
5663            !content.contains("Please wait"),
5664            "placeholder text should not survive XFA flattening"
5665        );
5666    }
5667
5668    #[test]
5669    fn hybrid_static_pdf_uses_xfa_layout_over_static_content() {
5670        // When a PDF has both XFA template and static page content,
5671        // XFA layout should always take priority — the static content
5672        // may be a pre-rendered preview with wrong page count (#744).
5673        let appearance = Object::Stream(Stream::new(
5674            dictionary! {
5675                "Type" => Object::Name(b"XObject".to_vec()),
5676                "Subtype" => Object::Name(b"Form".to_vec()),
5677                "BBox" => Object::Array(vec![
5678                    Object::Integer(0), Object::Integer(0),
5679                    Object::Integer(120), Object::Integer(30),
5680                ]),
5681                "Matrix" => Object::Array(vec![
5682                    Object::Integer(1), Object::Integer(0),
5683                    Object::Integer(0), Object::Integer(1),
5684                    Object::Integer(0), Object::Integer(0),
5685                ]),
5686                "Resources" => Object::Dictionary(dictionary! {}),
5687            },
5688            b"0 G\n0.5 0.5 119 29 re\ns\n".to_vec(),
5689        ));
5690        // Enough Tj operators (≥5) to exceed the old static content threshold.
5691        let page_content = b"BT /F1 12 Tf 72 720 Td (Line 1) Tj 0 -14 Td (Line 2) Tj 0 -14 Td (Line 3) Tj 0 -14 Td (Line 4) Tj 0 -14 Td (Line 5) Tj ET\n".to_vec();
5692        let pdf_bytes = build_xfa_pdf_with_widget_appearance(
5693            page_content,
5694            appearance,
5695            dictionary! {
5696                "FT" => Object::Name(b"Tx".to_vec()),
5697                "T" => Object::string_literal("field[0]"),
5698            },
5699        );
5700
5701        let result = flatten_xfa_to_pdf(&pdf_bytes).expect("flatten failed");
5702        let doc = Document::load_mem(&result).expect("load flattened PDF");
5703        let page_id = doc.page_iter().next().expect("page");
5704        let page_dict = doc.get_dictionary(page_id).expect("page dict");
5705
5706        // XFA layout produces pages without widget annotations.
5707        assert!(
5708            page_dict.get(b"Annots").is_err(),
5709            "XFA-flattened page should have no annotations"
5710        );
5711    }
5712
5713    #[test]
5714    fn hybrid_static_pdf_uses_selected_button_appearance_state() {
5715        let yes_stream = Object::Stream(Stream::new(
5716            dictionary! {
5717                "Type" => Object::Name(b"XObject".to_vec()),
5718                "Subtype" => Object::Name(b"Form".to_vec()),
5719                "BBox" => Object::Array(vec![
5720                    Object::Integer(0), Object::Integer(0),
5721                    Object::Integer(20), Object::Integer(20),
5722                ]),
5723                "Matrix" => Object::Array(vec![
5724                    Object::Integer(1), Object::Integer(0),
5725                    Object::Integer(0), Object::Integer(1),
5726                    Object::Integer(0), Object::Integer(0),
5727                ]),
5728                "Resources" => Object::Dictionary(dictionary! {}),
5729            },
5730            b"BT /F1 8 Tf 1 1 Td (YES) Tj ET\n".to_vec(),
5731        ));
5732        let off_stream = Object::Stream(Stream::new(
5733            dictionary! {
5734                "Type" => Object::Name(b"XObject".to_vec()),
5735                "Subtype" => Object::Name(b"Form".to_vec()),
5736                "BBox" => Object::Array(vec![
5737                    Object::Integer(0), Object::Integer(0),
5738                    Object::Integer(20), Object::Integer(20),
5739                ]),
5740                "Matrix" => Object::Array(vec![
5741                    Object::Integer(1), Object::Integer(0),
5742                    Object::Integer(0), Object::Integer(1),
5743                    Object::Integer(0), Object::Integer(0),
5744                ]),
5745                "Resources" => Object::Dictionary(dictionary! {}),
5746            },
5747            b"BT /F1 8 Tf 1 1 Td (OFF) Tj ET\n".to_vec(),
5748        ));
5749
5750        let mut doc = Document::with_version("1.4");
5751        let state_id = doc.add_object(Object::Dictionary(dictionary! {
5752            "Yes" => yes_stream,
5753            "Off" => off_stream,
5754        }));
5755        let annot = dictionary! {
5756            "Subtype" => Object::Name(b"Widget".to_vec()),
5757            "Rect" => Object::Array(vec![
5758                Object::Integer(100), Object::Integer(700),
5759                Object::Integer(120), Object::Integer(720),
5760            ]),
5761            "AP" => Object::Dictionary(dictionary! {
5762                "N" => Object::Reference(state_id),
5763            }),
5764            "AS" => Object::Name(b"Yes".to_vec()),
5765            "FT" => Object::Name(b"Btn".to_vec()),
5766        };
5767        let ap_id =
5768            resolve_widget_normal_appearance(&mut doc, &annot).expect("selected normal appearance");
5769        let stream = doc
5770            .get_object(ap_id)
5771            .expect("appearance stream")
5772            .as_stream()
5773            .expect("appearance stream");
5774        let content = String::from_utf8_lossy(&stream.content);
5775
5776        assert!(
5777            content.contains("YES"),
5778            "flatten should choose the selected normal appearance state"
5779        );
5780    }
5781
5782    #[test]
5783    fn widget_as_off_without_off_appearance_returns_none() {
5784        // When /AS is "Off" but the Normal appearance dict has no "Off" key,
5785        // the widget is deselected. Returning None avoids baking a checked
5786        // mark from the only remaining on-state appearance.
5787        let yes_stream = Object::Stream(Stream::new(
5788            dictionary! {
5789                "Type" => Object::Name(b"XObject".to_vec()),
5790                "Subtype" => Object::Name(b"Form".to_vec()),
5791                "BBox" => Object::Array(vec![
5792                    Object::Integer(0), Object::Integer(0),
5793                    Object::Integer(10), Object::Integer(10),
5794                ]),
5795            },
5796            b"q 5 5 m 5 5 l S Q\n".to_vec(),
5797        ));
5798
5799        let mut doc = Document::with_version("1.4");
5800        // Normal appearance has only a "0" key (checked state), no "Off" key.
5801        let state_id = doc.add_object(Object::Dictionary(dictionary! {
5802            "0" => yes_stream,
5803        }));
5804        let annot = dictionary! {
5805            "Subtype" => Object::Name(b"Widget".to_vec()),
5806            "Rect" => Object::Array(vec![
5807                Object::Integer(100), Object::Integer(700),
5808                Object::Integer(110), Object::Integer(710),
5809            ]),
5810            "AP" => Object::Dictionary(dictionary! {
5811                "N" => Object::Reference(state_id),
5812            }),
5813            "AS" => Object::Name(b"Off".to_vec()),
5814            "FT" => Object::Name(b"Btn".to_vec()),
5815        };
5816        assert!(
5817            resolve_widget_normal_appearance(&mut doc, &annot).is_none(),
5818            "Off state with no Off appearance should not resolve to the on-state stream"
5819        );
5820    }
5821
5822    #[test]
5823    fn bake_checkbox_radio_ap_marks_skips_off_widgets_without_off_normal_appearance() {
5824        let pdf_bytes = build_xfa_pdf_with_widget_appearance(
5825            Vec::new(),
5826            Object::Dictionary(dictionary! {
5827                "1" => Object::Stream(Stream::new(
5828                    dictionary! {
5829                        "Type" => Object::Name(b"XObject".to_vec()),
5830                        "Subtype" => Object::Name(b"Form".to_vec()),
5831                        "BBox" => Object::Array(vec![
5832                            Object::Integer(0), Object::Integer(0),
5833                            Object::Integer(10), Object::Integer(10),
5834                        ]),
5835                        "Resources" => Object::Dictionary(dictionary! {}),
5836                    },
5837                    b"q 1 1 8 8 re W n 2 8 m 8 2 l 8 8 m 2 2 l s Q\n".to_vec(),
5838                )),
5839            }),
5840            dictionary! {
5841                "FT" => Object::Name(b"Btn".to_vec()),
5842                "AS" => Object::Name(b"Off".to_vec()),
5843                "T" => Object::string_literal("checkbox[0]"),
5844            },
5845        );
5846
5847        let mut doc = Document::load_mem(&pdf_bytes).expect("parse test PDF");
5848        let page_id = doc.page_iter().next().expect("page");
5849        let baked = bake_checkbox_radio_ap_marks(&mut doc, page_id);
5850
5851        assert_eq!(baked, 0, "Off-state widget must not stamp the on-mark");
5852    }
5853
5854    #[test]
5855    fn adding_widget_xobject_preserves_indirect_inline_page_xobjects() {
5856        let mut doc = Document::with_version("1.4");
5857        let existing_xobject_id = doc.add_object(Object::Stream(Stream::new(
5858            dictionary! {
5859                "Type" => Object::Name(b"XObject".to_vec()),
5860                "Subtype" => Object::Name(b"Form".to_vec()),
5861                "BBox" => Object::Array(vec![
5862                    Object::Integer(0), Object::Integer(0),
5863                    Object::Integer(10), Object::Integer(10),
5864                ]),
5865            },
5866            b"q Q\n".to_vec(),
5867        )));
5868        let xobject_dict_id = doc.add_object(Object::Dictionary(dictionary! {
5869            "R11" => Object::Reference(existing_xobject_id),
5870        }));
5871
5872        let pages_id = doc.new_object_id();
5873        let page_id = doc.add_object(Object::Dictionary(dictionary! {
5874            "Type" => Object::Name(b"Page".to_vec()),
5875            "Parent" => Object::Reference(pages_id),
5876            "MediaBox" => Object::Array(vec![
5877                Object::Integer(0), Object::Integer(0),
5878                Object::Integer(612), Object::Integer(792),
5879            ]),
5880            "Resources" => Object::Dictionary(dictionary! {
5881                "XObject" => Object::Reference(xobject_dict_id),
5882            }),
5883        }));
5884        doc.objects.insert(
5885            pages_id,
5886            Object::Dictionary(dictionary! {
5887                "Type"  => Object::Name(b"Pages".to_vec()),
5888                "Kids"  => Object::Array(vec![Object::Reference(page_id)]),
5889                "Count" => Object::Integer(1)
5890            }),
5891        );
5892
5893        let new_xobject_id = doc.add_object(Object::Stream(Stream::new(
5894            dictionary! {
5895                "Type" => Object::Name(b"XObject".to_vec()),
5896                "Subtype" => Object::Name(b"Form".to_vec()),
5897                "BBox" => Object::Array(vec![
5898                    Object::Integer(0), Object::Integer(0),
5899                    Object::Integer(10), Object::Integer(10),
5900                ]),
5901            },
5902            b"0 0 10 10 re S\n".to_vec(),
5903        )));
5904
5905        add_xobject_to_page_resources(&mut doc, page_id, "XfaAp0", new_xobject_id);
5906
5907        let xobjects = doc
5908            .get_object(xobject_dict_id)
5909            .expect("xobject dict")
5910            .as_dict()
5911            .expect("xobject dict");
5912        assert!(
5913            xobjects.get(b"R11").is_ok(),
5914            "existing page XObject was lost"
5915        );
5916        assert!(
5917            xobjects.get(b"XfaAp0").is_ok(),
5918            "new flattened widget XObject was not added"
5919        );
5920    }
5921
5922    #[test]
5923    fn encrypted_pdf_without_xfa_returns_ok() {
5924        // Encrypted PDF without AcroForm/XFA → returned as-is (no XFA to flatten).
5925        let mut doc = Document::with_version("1.4");
5926        let pages_id = doc.new_object_id();
5927        let page_id = doc.add_object(Object::Dictionary(dictionary! {
5928            "Type"     => Object::Name(b"Page".to_vec()),
5929            "Parent"   => Object::Reference(pages_id),
5930            "MediaBox" => Object::Array(vec![
5931                Object::Integer(0), Object::Integer(0),
5932                Object::Integer(612), Object::Integer(792),
5933            ]),
5934        }));
5935        doc.objects.insert(
5936            pages_id,
5937            Object::Dictionary(dictionary! {
5938                "Type"  => Object::Name(b"Pages".to_vec()),
5939                "Kids"  => Object::Array(vec![Object::Reference(page_id)]),
5940                "Count" => Object::Integer(1),
5941            }),
5942        );
5943        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
5944            "Type"  => Object::Name(b"Catalog".to_vec()),
5945            "Pages" => Object::Reference(pages_id),
5946        }));
5947        doc.trailer.set("Root", Object::Reference(catalog_id));
5948
5949        let encrypt_id = doc.add_object(Object::Dictionary(dictionary! {
5950            "Filter" => Object::Name(b"Standard".to_vec()),
5951            "V"      => Object::Integer(2),
5952            "Length"  => Object::Integer(128),
5953        }));
5954        doc.trailer.set("Encrypt", Object::Reference(encrypt_id));
5955
5956        let mut buf = Vec::new();
5957        doc.save_to(&mut buf).expect("save test PDF");
5958
5959        let result = flatten_xfa_to_pdf(&buf);
5960        assert!(result.is_ok(), "non-XFA encrypted PDF should return Ok");
5961    }
5962
5963    #[test]
5964    fn encrypted_xfa_pdf_returns_encrypted_error() {
5965        // Encrypted PDF WITH AcroForm/XFA → should reach the decrypt check
5966        // and return Err(Encrypted) when the password is required.
5967        let mut doc = Document::with_version("1.4");
5968        let pages_id = doc.new_object_id();
5969        let page_id = doc.add_object(Object::Dictionary(dictionary! {
5970            "Type"     => Object::Name(b"Page".to_vec()),
5971            "Parent"   => Object::Reference(pages_id),
5972            "MediaBox" => Object::Array(vec![
5973                Object::Integer(0), Object::Integer(0),
5974                Object::Integer(612), Object::Integer(792),
5975            ]),
5976        }));
5977        doc.objects.insert(
5978            pages_id,
5979            Object::Dictionary(dictionary! {
5980                "Type"  => Object::Name(b"Pages".to_vec()),
5981                "Kids"  => Object::Array(vec![Object::Reference(page_id)]),
5982                "Count" => Object::Integer(1),
5983            }),
5984        );
5985        // Add AcroForm with XFA key so the byte-level pre-check passes.
5986        let xfa_stream_id = doc.add_object(Object::Stream(lopdf::Stream::new(
5987            dictionary! {},
5988            b"<xdp:xdp></xdp:xdp>".to_vec(),
5989        )));
5990        let acroform_id = doc.add_object(Object::Dictionary(dictionary! {
5991            "XFA" => Object::Reference(xfa_stream_id),
5992        }));
5993        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
5994            "Type"     => Object::Name(b"Catalog".to_vec()),
5995            "Pages"    => Object::Reference(pages_id),
5996            "AcroForm" => Object::Reference(acroform_id),
5997        }));
5998        doc.trailer.set("Root", Object::Reference(catalog_id));
5999
6000        let encrypt_id = doc.add_object(Object::Dictionary(dictionary! {
6001            "Filter" => Object::Name(b"Standard".to_vec()),
6002            "V"      => Object::Integer(2),
6003            "Length"  => Object::Integer(128),
6004        }));
6005        doc.trailer.set("Encrypt", Object::Reference(encrypt_id));
6006
6007        let mut buf = Vec::new();
6008        doc.save_to(&mut buf).expect("save encrypted PDF");
6009
6010        let result = flatten_xfa_to_pdf(&buf);
6011        assert!(result.is_err(), "expected Encrypted error");
6012        let err = result.unwrap_err();
6013        assert!(
6014            matches!(err, XfaError::Encrypted(_)),
6015            "expected XfaError::Encrypted, got: {err:?}"
6016        );
6017    }
6018
6019    #[test]
6020    fn owner_only_encrypted_pdf_is_handled_transparently() {
6021        // Owner-only encrypted PDFs (empty user password) are auto-decrypted by lopdf.
6022        // Verify that flatten_xfa_to_pdf processes them without error.
6023        let mut doc = Document::with_version("2.0");
6024        let pages_id = doc.new_object_id();
6025        let page_id = doc.add_object(Object::Dictionary(dictionary! {
6026            "Type"     => Object::Name(b"Page".to_vec()),
6027            "Parent"   => Object::Reference(pages_id),
6028            "MediaBox" => Object::Array(vec![
6029                Object::Integer(0), Object::Integer(0),
6030                Object::Integer(612), Object::Integer(792),
6031            ]),
6032        }));
6033        doc.objects.insert(
6034            pages_id,
6035            Object::Dictionary(dictionary! {
6036                "Type"  => Object::Name(b"Pages".to_vec()),
6037                "Kids"  => Object::Array(vec![Object::Reference(page_id)]),
6038                "Count" => Object::Integer(1),
6039            }),
6040        );
6041        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
6042            "Type"  => Object::Name(b"Catalog".to_vec()),
6043            "Pages" => Object::Reference(pages_id),
6044        }));
6045        doc.trailer.set("Root", Object::Reference(catalog_id));
6046
6047        // Encrypt with owner password "secret", empty user password.
6048        let state = lopdf::aes256_encryption_state("secret", "", lopdf::Permissions::default())
6049            .expect("create encryption state");
6050        doc.encrypt(&state).expect("encrypt document");
6051
6052        let mut buf = Vec::new();
6053        doc.save_to(&mut buf).expect("save encrypted PDF");
6054
6055        // lopdf auto-decrypts owner-only encrypted PDFs, so is_pdf_encrypted returns false.
6056        assert!(
6057            !is_pdf_encrypted(&buf),
6058            "lopdf should auto-decrypt owner-only PDFs"
6059        );
6060
6061        // flatten_xfa_to_pdf should succeed — no XFA content, returns input as-is.
6062        let result = flatten_xfa_to_pdf(&buf);
6063        assert!(
6064            result.is_ok(),
6065            "owner-only encrypted PDF should be handled, got: {result:?}"
6066        );
6067    }
6068
6069    /// Build a minimal PDF with a Type0 (CID) font that has a /W array.
6070    fn build_pdf_with_cid_font(w_array: Vec<Object>, dw: Option<i64>) -> Document {
6071        let mut doc = Document::with_version("1.4");
6072
6073        // Minimal CIDFont descendant dictionary with /W
6074        let mut cid_dict = dictionary! {
6075            "Type"    => Object::Name(b"Font".to_vec()),
6076            "Subtype" => Object::Name(b"CIDFontType2".to_vec()),
6077            "BaseFont" => Object::Name(b"TestFont".to_vec()),
6078            "W"       => Object::Array(w_array)
6079        };
6080        if let Some(dw_val) = dw {
6081            cid_dict.set("DW", Object::Integer(dw_val));
6082        }
6083        let cid_id = doc.add_object(Object::Dictionary(cid_dict));
6084
6085        // Type0 composite font pointing to the CIDFont
6086        let type0_dict = dictionary! {
6087            "Type"            => Object::Name(b"Font".to_vec()),
6088            "Subtype"         => Object::Name(b"Type0".to_vec()),
6089            "BaseFont"        => Object::Name(b"TestFont".to_vec()),
6090            "DescendantFonts" => Object::Array(vec![Object::Reference(cid_id)])
6091        };
6092        doc.add_object(Object::Dictionary(type0_dict));
6093        doc
6094    }
6095
6096    /// Test CID /W array parsing: consecutive widths format.
6097    /// /W [120 [500 600 700]] → CID 120=500, CID 121=600, CID 122=700
6098    #[test]
6099    fn cid_w_array_consecutive() {
6100        let w = vec![
6101            Object::Integer(120),
6102            Object::Array(vec![
6103                Object::Integer(500),
6104                Object::Integer(600),
6105                Object::Integer(700),
6106            ]),
6107        ];
6108        let doc = build_pdf_with_cid_font(w, None);
6109        let _fonts = extract_embedded_fonts(&doc);
6110
6111        // No font stream embedded, so extract_embedded_fonts won't find data.
6112        // Test the parser directly via the Type0 dict.
6113        for obj in doc.objects.values() {
6114            let dict = match obj.as_dict() {
6115                Ok(d) => d,
6116                Err(_) => continue,
6117            };
6118            let subtype = dict.get(b"Subtype").ok().and_then(|o| o.as_name().ok());
6119            if subtype == Some(b"Type0".as_slice()) {
6120                let result = extract_cid_font_widths(&doc, dict);
6121                let (first, widths) = result.expect("should parse /W array");
6122                assert_eq!(first, 120);
6123                assert_eq!(widths.len(), 3);
6124                assert_eq!(widths[0], 500); // CID 120
6125                assert_eq!(widths[1], 600); // CID 121
6126                assert_eq!(widths[2], 700); // CID 122
6127                return;
6128            }
6129        }
6130        panic!("Type0 font not found in test document");
6131    }
6132
6133    /// Test CID /W array parsing: range format.
6134    /// /W [200 300 250] → CIDs 200-300 all have width 250
6135    #[test]
6136    fn cid_w_array_range() {
6137        let w = vec![
6138            Object::Integer(200),
6139            Object::Integer(300),
6140            Object::Integer(250),
6141        ];
6142        let doc = build_pdf_with_cid_font(w, None);
6143
6144        for obj in doc.objects.values() {
6145            let dict = match obj.as_dict() {
6146                Ok(d) => d,
6147                Err(_) => continue,
6148            };
6149            let subtype = dict.get(b"Subtype").ok().and_then(|o| o.as_name().ok());
6150            if subtype == Some(b"Type0".as_slice()) {
6151                let (first, widths) =
6152                    extract_cid_font_widths(&doc, dict).expect("should parse /W range");
6153                assert_eq!(first, 200);
6154                assert_eq!(widths.len(), 101); // 200..=300
6155                assert!(widths.iter().all(|&w| w == 250));
6156                return;
6157            }
6158        }
6159        panic!("Type0 font not found");
6160    }
6161
6162    /// Test CID /W array parsing: mixed consecutive + range formats.
6163    /// /W [120 [500 600 700] 200 300 250]
6164    /// CID 120=500, 121=600, 122=700, CIDs 200-300=250
6165    /// Default width (/DW) fills gaps (CIDs 123-199).
6166    #[test]
6167    fn cid_w_array_mixed() {
6168        let w = vec![
6169            Object::Integer(120),
6170            Object::Array(vec![
6171                Object::Integer(500),
6172                Object::Integer(600),
6173                Object::Integer(700),
6174            ]),
6175            Object::Integer(200),
6176            Object::Integer(300),
6177            Object::Integer(250),
6178        ];
6179        let doc = build_pdf_with_cid_font(w, Some(1000));
6180
6181        for obj in doc.objects.values() {
6182            let dict = match obj.as_dict() {
6183                Ok(d) => d,
6184                Err(_) => continue,
6185            };
6186            let subtype = dict.get(b"Subtype").ok().and_then(|o| o.as_name().ok());
6187            if subtype == Some(b"Type0".as_slice()) {
6188                let (first, widths) =
6189                    extract_cid_font_widths(&doc, dict).expect("should parse mixed /W");
6190                assert_eq!(first, 120);
6191                assert_eq!(widths.len(), 181); // 120..=300
6192                                               // Consecutive part
6193                assert_eq!(widths[0], 500); // CID 120
6194                assert_eq!(widths[1], 600); // CID 121
6195                assert_eq!(widths[2], 700); // CID 122
6196                                            // Gap filled with /DW=1000
6197                assert_eq!(widths[3], 1000); // CID 123
6198                assert_eq!(widths[79], 1000); // CID 199
6199                                              // Range part
6200                assert_eq!(widths[80], 250); // CID 200
6201                assert_eq!(widths[180], 250); // CID 300
6202                return;
6203            }
6204        }
6205        panic!("Type0 font not found");
6206    }
6207
6208    /// Test that /DW defaults to 1000 when not specified.
6209    #[test]
6210    fn cid_w_array_default_width() {
6211        let w = vec![
6212            Object::Integer(10),
6213            Object::Array(vec![Object::Integer(400)]),
6214            Object::Integer(20),
6215            Object::Array(vec![Object::Integer(600)]),
6216        ];
6217        let doc = build_pdf_with_cid_font(w, None); // no /DW → defaults to 1000
6218
6219        for obj in doc.objects.values() {
6220            let dict = match obj.as_dict() {
6221                Ok(d) => d,
6222                Err(_) => continue,
6223            };
6224            let subtype = dict.get(b"Subtype").ok().and_then(|o| o.as_name().ok());
6225            if subtype == Some(b"Type0".as_slice()) {
6226                let (first, widths) = extract_cid_font_widths(&doc, dict).expect("should parse /W");
6227                assert_eq!(first, 10);
6228                assert_eq!(widths[0], 400); // CID 10
6229                assert_eq!(widths[5], 1000); // CID 15 — default
6230                assert_eq!(widths[10], 600); // CID 20
6231                return;
6232            }
6233        }
6234        panic!("Type0 font not found");
6235    }
6236
6237    #[test]
6238    fn extract_embedded_fonts_keeps_simple_pdf_fonts_without_fontfile() {
6239        let mut doc = Document::new();
6240        let font_id = doc.add_object(Object::Dictionary(dictionary! {
6241            "Type" => Object::Name(b"Font".to_vec()),
6242            "Subtype" => Object::Name(b"Type1".to_vec()),
6243            "BaseFont" => Object::Name(b"MyriadPro-Regular".to_vec()),
6244            "FirstChar" => Object::Integer(32),
6245            "LastChar" => Object::Integer(34),
6246            "Widths" => Object::Array(vec![
6247                Object::Integer(278),
6248                Object::Integer(333),
6249                Object::Integer(612),
6250            ]),
6251            "Encoding" => Object::Name(b"WinAnsiEncoding".to_vec()),
6252        }));
6253
6254        let fonts = extract_embedded_fonts(&doc);
6255        let font = fonts
6256            .iter()
6257            .find(|font| font.name == "MyriadPro-Regular")
6258            .expect("expected reusable simple font");
6259
6260        assert!(font.data.is_empty(), "no FontFile* should keep data empty");
6261        assert_eq!(font.pdf_widths, Some((32, vec![278, 333, 612])));
6262        assert_eq!(
6263            font.pdf_source_font,
6264            Some(PdfSourceFont { object_id: font_id })
6265        );
6266    }
6267
6268    #[test]
6269    fn store_font_data_reserves_family_alias_for_regular_face() {
6270        let mut fonts = Vec::new();
6271        store_font_data(
6272            &mut fonts,
6273            "ArialMT",
6274            Vec::new(),
6275            Some((32, vec![278, 333, 611])),
6276            None,
6277            Some(PdfSourceFont { object_id: (1, 0) }),
6278        );
6279        store_font_data(
6280            &mut fonts,
6281            "Arial-BoldMT",
6282            Vec::new(),
6283            Some((32, vec![278, 333, 611])),
6284            None,
6285            Some(PdfSourceFont { object_id: (2, 0) }),
6286        );
6287        store_font_data(
6288            &mut fonts,
6289            "Arial-ItalicMT",
6290            Vec::new(),
6291            Some((32, vec![278, 333, 611])),
6292            None,
6293            Some(PdfSourceFont { object_id: (3, 0) }),
6294        );
6295
6296        let aliases: Vec<_> = fonts.iter().map(|font| font.name.as_str()).collect();
6297        assert!(aliases.contains(&"ArialMT"));
6298        assert!(aliases.contains(&"Arial-BoldMT"));
6299        assert!(aliases.contains(&"Arial-ItalicMT"));
6300        assert_eq!(
6301            aliases.iter().filter(|name| **name == "Arial").count(),
6302            1,
6303            "only the regular face should claim the bare family alias"
6304        );
6305    }
6306
6307    #[test]
6308    fn store_font_data_keeps_regular_ps_family_alias() {
6309        let mut fonts = Vec::new();
6310        store_font_data(
6311            &mut fonts,
6312            "MyriadPro-Regular",
6313            Vec::new(),
6314            Some((32, vec![278, 333, 612])),
6315            None,
6316            Some(PdfSourceFont { object_id: (4, 0) }),
6317        );
6318
6319        assert!(
6320            fonts.iter().any(|font| font.name == "Myriad Pro"),
6321            "regular PostScript names should still expose their family alias"
6322        );
6323    }
6324
6325    #[test]
6326    fn page_content_streams_resolves_indirect_contents_arrays() {
6327        let mut doc = Document::new();
6328        let stream_a = doc.add_object(Stream::new(
6329            dictionary! {"Length" => Object::Integer(8)},
6330            b"(A) Tj\n".to_vec(),
6331        ));
6332        let stream_b = doc.add_object(Stream::new(
6333            dictionary! {"Length" => Object::Integer(8)},
6334            b"(B) Tj\n".to_vec(),
6335        ));
6336        let contents_array = doc.add_object(Object::Array(vec![
6337            Object::Reference(stream_a),
6338            Object::Reference(stream_b),
6339        ]));
6340        let page_id = doc.add_object(Object::Dictionary(dictionary! {
6341            "Type" => Object::Name(b"Page".to_vec()),
6342            "Contents" => Object::Reference(contents_array),
6343        }));
6344
6345        let streams = page_content_streams(&doc, page_id);
6346
6347        assert_eq!(
6348            streams.len(),
6349            2,
6350            "indirect /Contents arrays must be traversed"
6351        );
6352        assert!(streams[0].windows(2).any(|w| w == b"Tj"));
6353        assert!(streams[1].windows(2).any(|w| w == b"Tj"));
6354    }
6355
6356    #[test]
6357    fn embed_resolved_fonts_reuses_existing_pdf_font_object() {
6358        let mut doc = Document::new();
6359        let source_font_id = doc.add_object(Object::Dictionary(dictionary! {
6360            "Type" => Object::Name(b"Font".to_vec()),
6361            "Subtype" => Object::Name(b"Type1".to_vec()),
6362            "BaseFont" => Object::Name(b"MyriadPro-Regular".to_vec()),
6363            "Encoding" => Object::Name(b"WinAnsiEncoding".to_vec()),
6364        }));
6365        let before = doc.objects.len();
6366
6367        let mut resolved = HashMap::new();
6368        resolved.insert(
6369            "Myriad Pro_Normal_Normal".to_string(),
6370            ResolvedFont {
6371                name: "Myriad Pro".to_string(),
6372                data: Vec::new(),
6373                face_index: 0,
6374                units_per_em: 1000,
6375                ascender: 800,
6376                descender: -200,
6377                pdf_widths: Some((32, vec![278, 333, 612])),
6378                pdf_encoding: None,
6379                pdf_source_font: Some(PdfSourceFont {
6380                    object_id: source_font_id,
6381                }),
6382            },
6383        );
6384
6385        let empty_layout = LayoutDom { pages: vec![] };
6386        let (_font_map, font_objects, metrics_data) =
6387            embed_resolved_fonts(&mut doc, &resolved, &empty_layout);
6388
6389        assert_eq!(
6390            doc.objects.len(),
6391            before,
6392            "should not embed a new font object"
6393        );
6394        assert_eq!(font_objects.len(), 1);
6395        assert_eq!(font_objects[0].1, source_font_id);
6396        assert!(
6397            metrics_data["Myriad Pro_Normal_Normal"].font_data.is_none(),
6398            "reused simple fonts must keep WinAnsi text encoding"
6399        );
6400    }
6401
6402    #[test]
6403    fn strip_undefined_entities_preserves_raw_ampersands_in_processing_instructions() {
6404        let xml = r##"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><?renderCache.textRun 24 A. Adjustment & Location 0 1417 14917 0 0 0 "Myriad Pro" 0 0 18000 ISO-8859-1?><?renderCache.subset "Arial" 0 0 ISO-8859-1 "#$%&'()+,-./" ?><subform name="form1"><field name="A"/></subform></template>"##;
6405
6406        let stripped = strip_undefined_xml_entities(xml);
6407
6408        assert_eq!(
6409            stripped, xml,
6410            "raw ampersands inside processing instructions are valid and must survive sanitization"
6411        );
6412        roxmltree::Document::parse(&stripped)
6413            .expect("processing instructions must remain parseable");
6414    }
6415
6416    #[test]
6417    fn strip_undefined_entities_drops_only_true_named_entity_references() {
6418        let xml = r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form1"><draw name="D"><value><text>alpha &bogus; beta &#169; &amp; gamma</text></value></draw></subform></template>"#;
6419
6420        let stripped = strip_undefined_xml_entities(xml);
6421
6422        assert!(
6423            !stripped.contains("&bogus;"),
6424            "unknown named entities should still be removed for roxmltree compatibility"
6425        );
6426        assert!(stripped.contains("&#169;"));
6427        assert!(stripped.contains("&amp;"));
6428        roxmltree::Document::parse(&stripped).expect("sanitized XML should parse");
6429    }
6430
6431    /// Form DOM with more repeating instances than the template must expand
6432    /// the FormTree and populate field values.
6433    #[test]
6434    fn form_dom_expands_repeating_subform_instances() {
6435        use xfa_layout_engine::form::FormNodeType;
6436
6437        // Template: one Activity subform with bind=none, occur max=-1
6438        let template = r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
6439          <subform name="root" layout="tb">
6440            <pageSet><pageArea name="P1">
6441              <contentArea w="200mm" h="280mm"/>
6442              <medium short="210mm" long="297mm"/>
6443            </pageArea></pageSet>
6444            <subform name="body" layout="tb">
6445              <subform name="Items" layout="tb">
6446                <bind match="none"/>
6447                <subform name="Row" layout="tb">
6448                  <bind match="none"/>
6449                  <occur max="-1"/>
6450                  <field name="Label"><ui><textEdit/></ui></field>
6451                </subform>
6452              </subform>
6453            </subform>
6454          </subform>
6455        </template>"#;
6456
6457        // Form DOM: 3 Row instances with values
6458        let form_xml = r#"<form xmlns="http://www.xfa.org/schema/xfa-form/2.8/">
6459          <subform name="root">
6460            <subform name="body">
6461              <subform name="Items">
6462                <instanceManager name="_Row"/>
6463                <subform name="Row">
6464                  <field name="Label"><value><text>Alpha</text></value></field>
6465                </subform>
6466                <subform name="Row">
6467                  <field name="Label"><value><text>Beta</text></value></field>
6468                </subform>
6469                <subform name="Row">
6470                  <field name="Label"><value><text>Gamma</text></value></field>
6471                </subform>
6472              </subform>
6473            </subform>
6474          </subform>
6475        </form>"#;
6476
6477        let data_dom = xfa_dom_resolver::data_dom::DataDom::new();
6478        let merger = crate::merger::FormMerger::new(&data_dom);
6479        let (mut tree, root_id) = merger.merge(template).unwrap();
6480
6481        // Before form DOM: only 1 Row instance
6482        // Dump tree to understand structure
6483        fn find_by_name(tree: &FormTree, parent: FormNodeId, name: &str) -> Option<FormNodeId> {
6484            for &c in &tree.get(parent).children {
6485                if tree.get(c).name == name {
6486                    return Some(c);
6487                }
6488                if let Some(found) = find_by_name(tree, c, name) {
6489                    return Some(found);
6490                }
6491            }
6492            None
6493        }
6494        let items_id =
6495            find_by_name(&tree, root_id, "Items").expect("Items subform not found in tree");
6496        let rows_before = tree
6497            .get(items_id)
6498            .children
6499            .iter()
6500            .filter(|&&c| tree.get(c).name == "Row")
6501            .count();
6502        assert_eq!(
6503            rows_before, 1,
6504            "template merge should produce 1 Row (bind=none)"
6505        );
6506
6507        // Apply form DOM
6508        let _ = apply_form_dom_presence(
6509            &mut tree,
6510            root_id,
6511            form_xml,
6512            XfaRenderingPolicy::SavedStateFaithful,
6513            false,
6514        );
6515
6516        // After form DOM: 3 Row instances with correct values
6517        let rows_after: Vec<FormNodeId> = tree
6518            .get(items_id)
6519            .children
6520            .iter()
6521            .filter(|&&c| tree.get(c).name == "Row")
6522            .copied()
6523            .collect();
6524        assert_eq!(
6525            rows_after.len(),
6526            3,
6527            "form DOM should expand to 3 Row instances"
6528        );
6529
6530        let values: Vec<String> = rows_after
6531            .iter()
6532            .map(|&row_id| {
6533                let label_id = tree.get(row_id).children[0];
6534                match &tree.get(label_id).node_type {
6535                    FormNodeType::Field { value } => value.clone(),
6536                    _ => String::new(),
6537                }
6538            })
6539            .collect();
6540        assert_eq!(values, vec!["Alpha", "Beta", "Gamma"]);
6541    }
6542
6543    /// XFA 3.3 §8.6 / §3.1 — pageArea expansion from form-DOM.
6544    ///
6545    /// When the form-DOM packet records multiple instances of a *single*
6546    /// pageArea template, the FormTree must clone the template once per
6547    /// recorded instance and tag the clones as `runtime_instantiated_page`.
6548    /// This guards against the regression that produced 5 pages instead of
6549    /// 10 on corpus doc 13275420c3c9afbb.
6550    #[test]
6551    fn form_dom_expands_uniform_page_area_template() {
6552        use xfa_layout_engine::form::FormNodeType;
6553
6554        let template = r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
6555          <subform name="root" layout="tb">
6556            <pageSet>
6557              <pageArea name="Page1">
6558                <contentArea w="200mm" h="280mm"/>
6559                <medium short="210mm" long="297mm"/>
6560              </pageArea>
6561            </pageSet>
6562          </subform>
6563        </template>"#;
6564
6565        let form_xml = r#"<form xmlns="http://www.xfa.org/schema/xfa-form/2.8/">
6566          <subform name="root">
6567            <pageSet>
6568              <pageArea name="Page1"/>
6569              <pageArea name="Page1"/>
6570              <pageArea name="Page1"/>
6571              <pageArea name="Page1"/>
6572              <pageArea name="Page1"/>
6573            </pageSet>
6574          </subform>
6575        </form>"#;
6576
6577        let data_dom = xfa_dom_resolver::data_dom::DataDom::new();
6578        let merger = crate::merger::FormMerger::new(&data_dom);
6579        let (mut tree, root_id) = merger.merge(template).unwrap();
6580
6581        let _ = apply_form_dom_presence(
6582            &mut tree,
6583            root_id,
6584            form_xml,
6585            XfaRenderingPolicy::SavedStateFaithful,
6586            false,
6587        );
6588
6589        fn collect_page_areas(tree: &FormTree, id: FormNodeId, out: &mut Vec<FormNodeId>) {
6590            if matches!(tree.get(id).node_type, FormNodeType::PageArea { .. }) {
6591                out.push(id);
6592            }
6593            for &c in &tree.get(id).children {
6594                collect_page_areas(tree, c, out);
6595            }
6596        }
6597
6598        let mut page_areas = Vec::new();
6599        collect_page_areas(&tree, root_id, &mut page_areas);
6600        assert_eq!(
6601            page_areas.len(),
6602            5,
6603            "uniform pageArea expansion: 5 form-DOM instances must clone the template"
6604        );
6605        for &pa_id in &page_areas {
6606            assert!(
6607                tree.meta(pa_id).runtime_instantiated_page,
6608                "every expanded pageArea must be flagged as runtime-instantiated"
6609            );
6610        }
6611    }
6612
6613    /// Multi-template pageSets (e.g. `Page1` + `OverFlowPage`) MUST NOT
6614    /// trigger pageArea expansion — those pageAreas are a pre-allocated
6615    /// menu, not a uniform repetition pattern, and replicating them would
6616    /// over-paginate.
6617    #[test]
6618    fn form_dom_skips_multi_template_page_area_expansion() {
6619        use xfa_layout_engine::form::FormNodeType;
6620
6621        let template = r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
6622          <subform name="root" layout="tb">
6623            <pageSet>
6624              <pageArea name="Page1">
6625                <contentArea w="200mm" h="280mm"/>
6626                <medium short="210mm" long="297mm"/>
6627              </pageArea>
6628              <pageArea name="OverFlowPage">
6629                <contentArea w="200mm" h="280mm"/>
6630                <medium short="210mm" long="297mm"/>
6631              </pageArea>
6632            </pageSet>
6633          </subform>
6634        </template>"#;
6635
6636        let form_xml = r#"<form xmlns="http://www.xfa.org/schema/xfa-form/2.8/">
6637          <subform name="root">
6638            <pageSet>
6639              <pageArea name="Page1"/>
6640              <pageArea name="OverFlowPage"/>
6641              <pageArea name="OverFlowPage"/>
6642              <pageArea name="OverFlowPage"/>
6643              <pageArea name="OverFlowPage"/>
6644            </pageSet>
6645          </subform>
6646        </form>"#;
6647
6648        let data_dom = xfa_dom_resolver::data_dom::DataDom::new();
6649        let merger = crate::merger::FormMerger::new(&data_dom);
6650        let (mut tree, root_id) = merger.merge(template).unwrap();
6651
6652        let _ = apply_form_dom_presence(
6653            &mut tree,
6654            root_id,
6655            form_xml,
6656            XfaRenderingPolicy::SavedStateFaithful,
6657            false,
6658        );
6659
6660        fn collect_page_areas(tree: &FormTree, id: FormNodeId, out: &mut Vec<FormNodeId>) {
6661            if matches!(tree.get(id).node_type, FormNodeType::PageArea { .. }) {
6662                out.push(id);
6663            }
6664            for &c in &tree.get(id).children {
6665                collect_page_areas(tree, c, out);
6666            }
6667        }
6668
6669        let mut page_areas = Vec::new();
6670        collect_page_areas(&tree, root_id, &mut page_areas);
6671        assert_eq!(
6672            page_areas.len(),
6673            2,
6674            "multi-template pageSet must not clone pageAreas (kept original 2)"
6675        );
6676        for &pa_id in &page_areas {
6677            assert!(
6678                !tree.meta(pa_id).runtime_instantiated_page,
6679                "non-expansion case must not set runtime_instantiated_page flag"
6680            );
6681        }
6682    }
6683
6684    /// `XFA_FORMDOM_ADMIT_DATABOUND` (default-off) admit gate.
6685    ///
6686    /// The §3.1 unmatched-subform suppression hides named template subforms
6687    /// the saved form DOM did not enumerate. For DATA-BOUND unmatched
6688    /// subforms that is wrong — Adobe renders them. The default-off
6689    /// production override routes those through the same guarded admit branch
6690    /// as `FreshMergeExperimental`, WITHOUT flipping the policy default and
6691    /// WITHOUT loosening the guard: a truly-unmatched NON-data subform (no
6692    /// `bound_data_node`) must stay suppressed in every mode — that is the
6693    /// over-pagination guard.
6694    ///
6695    /// The override is passed as an explicit bool (the env read happens at the
6696    /// pipeline boundary), so this test is deterministic and touches no env.
6697    #[test]
6698    fn formdom_admit_databound_override_admits_only_data_bound() {
6699        use xfa_layout_engine::form::{FormNodeType, Presence};
6700
6701        // root > body > { Bound (data-bound), Unbound (no data), Present }.
6702        let template = r#"<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
6703          <subform name="root" layout="tb">
6704            <subform name="body" layout="tb">
6705              <subform name="Bound" layout="tb">
6706                <field name="A"><ui><textEdit/></ui></field>
6707              </subform>
6708              <subform name="Unbound" layout="tb">
6709                <field name="B"><ui><textEdit/></ui></field>
6710              </subform>
6711              <subform name="Present" layout="tb">
6712                <field name="C"><ui><textEdit/></ui></field>
6713              </subform>
6714            </subform>
6715          </subform>
6716        </template>"#;
6717
6718        // Saved form DOM enumerates only `Present` under `body`, so `Bound`
6719        // and `Unbound` are unmatched §3.1 suppression candidates; `body`
6720        // lists a subform child, so `has_subform_children` is true.
6721        let form_xml = r#"<form xmlns="http://www.xfa.org/schema/xfa-form/2.8/">
6722          <subform name="root">
6723            <subform name="body">
6724              <subform name="Present">
6725                <field name="C"><value><text>x</text></value></field>
6726              </subform>
6727            </subform>
6728          </subform>
6729        </form>"#;
6730
6731        fn find(tree: &FormTree, parent: FormNodeId, name: &str) -> Option<FormNodeId> {
6732            for &c in &tree.get(parent).children {
6733                if tree.get(c).name == name {
6734                    return Some(c);
6735                }
6736                if let Some(f) = find(tree, c, name) {
6737                    return Some(f);
6738                }
6739            }
6740            None
6741        }
6742
6743        // Build a fresh tree, install the data-binding scenario explicitly
6744        // (independent of merge defaults), then run `apply_form_dom_presence`.
6745        // Returns (admitted_count, Bound.presence, Unbound.presence).
6746        let scenario = |policy: XfaRenderingPolicy, override_on: bool| {
6747            let data_dom = xfa_dom_resolver::data_dom::DataDom::new();
6748            let merger = crate::merger::FormMerger::new(&data_dom);
6749            let (mut tree, root_id) = merger.merge(template).unwrap();
6750
6751            let bound = find(&tree, root_id, "Bound").expect("Bound subform in tree");
6752            let unbound = find(&tree, root_id, "Unbound").expect("Unbound subform in tree");
6753            assert!(matches!(tree.get(bound).node_type, FormNodeType::Subform));
6754            assert!(matches!(tree.get(unbound).node_type, FormNodeType::Subform));
6755
6756            for &id in &[bound, unbound] {
6757                let m = tree.meta_mut(id);
6758                m.presence = Presence::Visible;
6759                m.is_zero_instance_prototype = false;
6760                m.data_bind_none = false;
6761            }
6762            tree.meta_mut(bound).bound_data_node = Some(0); // data-bound
6763            tree.meta_mut(unbound).bound_data_node = None; // no data node
6764
6765            let (admitted, _, _) =
6766                apply_form_dom_presence(&mut tree, root_id, form_xml, policy, override_on);
6767            (
6768                admitted,
6769                tree.meta(bound).presence,
6770                tree.meta(unbound).presence,
6771            )
6772        };
6773
6774        // 1. Production default (SavedStateFaithful, override OFF): both the
6775        //    data-bound and the non-data unmatched subform are suppressed —
6776        //    unchanged baseline behaviour.
6777        let (adm, bound_p, unbound_p) = scenario(XfaRenderingPolicy::SavedStateFaithful, false);
6778        assert_eq!(
6779            adm, 0,
6780            "override off admits nothing under SavedStateFaithful"
6781        );
6782        assert_eq!(
6783            bound_p,
6784            Presence::Hidden,
6785            "data-bound suppressed when override off"
6786        );
6787        assert_eq!(
6788            unbound_p,
6789            Presence::Hidden,
6790            "non-data suppressed when override off"
6791        );
6792
6793        // 2. Override ON under SavedStateFaithful: the data-bound subform is
6794        //    admitted (presence preserved); the non-data subform stays
6795        //    suppressed — the over-pagination guard holds.
6796        let (adm, bound_p, unbound_p) = scenario(XfaRenderingPolicy::SavedStateFaithful, true);
6797        assert_eq!(adm, 1, "override on admits exactly the data-bound subform");
6798        assert_eq!(
6799            bound_p,
6800            Presence::Visible,
6801            "data-bound admitted when override on"
6802        );
6803        assert_eq!(
6804            unbound_p,
6805            Presence::Hidden,
6806            "non-data subform must stay suppressed with override on (over-pagination guard)"
6807        );
6808
6809        // 3. FreshMergeExperimental (override OFF) admits the same set — the
6810        //    override merely extends this to the production policy.
6811        let (adm, bound_p, unbound_p) = scenario(XfaRenderingPolicy::FreshMergeExperimental, false);
6812        assert_eq!(
6813            adm, 1,
6814            "fresh-merge admits the data-bound subform without override"
6815        );
6816        assert_eq!(
6817            bound_p,
6818            Presence::Visible,
6819            "data-bound admitted under fresh-merge"
6820        );
6821        assert_eq!(
6822            unbound_p,
6823            Presence::Hidden,
6824            "non-data suppressed under fresh-merge"
6825        );
6826    }
6827
6828    // GL-QA36: verify the re-entrance guard prevents infinite recursion.
6829    //
6830    // We call flatten_xfa_to_pdf_simulate_reentrant (a #[cfg(test)] helper
6831    // that sets FLATTEN_DEPTH=1 before calling) to avoid accessing the
6832    // thread-local directly from the test sub-module.
6833    #[test]
6834    fn flatten_xfa_to_pdf_recursion_guard_returns_error() {
6835        let pdf_bytes = build_xfa_pdf(SIMPLE_XDP);
6836        let result = flatten_xfa_to_pdf_simulate_reentrant(&pdf_bytes);
6837        assert!(
6838            result.is_err(),
6839            "expected recursion guard to return Err, got Ok"
6840        );
6841        let err_msg = result.unwrap_err().to_string();
6842        assert!(
6843            err_msg.contains("recursively"),
6844            "expected error message to mention recursion, got: {err_msg}"
6845        );
6846    }
6847
6848    // GL-QA36: verify the depth counter is reset to 0 after a normal call so
6849    // subsequent calls on the same thread are not falsely blocked.
6850    #[test]
6851    fn flatten_xfa_to_pdf_depth_counter_resets_after_call() {
6852        let pdf_bytes = build_xfa_pdf(SIMPLE_XDP);
6853        // First call; should succeed and reset depth to 0.
6854        let _ = flatten_xfa_to_pdf(&pdf_bytes);
6855        // Second call must not be blocked by a leaked counter.
6856        let pdf_bytes2 = build_xfa_pdf(SIMPLE_XDP);
6857        let result = flatten_xfa_to_pdf(&pdf_bytes2);
6858        assert!(
6859            result.is_ok(),
6860            "second flatten call should succeed, got: {result:?}"
6861        );
6862    }
6863
6864    // XFA-F1-05 (issue #1088): flatten_xfa_to_pdf must never panic on empty input.
6865    // An empty byte slice is not a valid PDF, so the function should return an
6866    // error rather than panic or crash.
6867    #[test]
6868    fn flatten_xfa_to_pdf_does_not_panic_on_empty_input() {
6869        let result = flatten_xfa_to_pdf(&[]);
6870        // We only require it does not panic; an Err is perfectly acceptable.
6871        // (An Ok result would mean the PDF library accepts empty bytes, which
6872        //  would also be fine — the important invariant is no panic/abort.)
6873        let _ = result;
6874    }
6875
6876    // -----------------------------------------------------------------------
6877    // XFA-F6-01 (#1109): Pipeline contract — minimal well-formed XFA PDF
6878    // -----------------------------------------------------------------------
6879
6880    /// XFA-F6-01: the flatten pipeline completes without panicking on a
6881    /// minimal well-formed XFA PDF. This exercises all pipeline stages and
6882    /// verifies the debug_assert ordering constraints hold.
6883    #[test]
6884    fn flatten_pipeline_completes_on_minimal_xfa_pdf() {
6885        let pdf_bytes = build_xfa_pdf(SIMPLE_XDP);
6886        // The pipeline must not panic (debug_assert violations would panic in
6887        // debug builds). We do not require Ok — layout failure → static_fallback
6888        // is acceptable, the important invariant is no panic.
6889        let result = flatten_xfa_to_pdf(&pdf_bytes);
6890        let _ = result; // Ok or Err both acceptable; panic is not
6891    }
6892
6893    #[test]
6894    fn flatten_with_layout_dump_preserves_pdf_bytes() {
6895        let pdf_bytes = build_xfa_pdf(SIMPLE_XDP);
6896        let flattened = flatten_xfa_to_pdf(&pdf_bytes).expect("plain flatten should succeed");
6897        let (flattened_with_dump, layout_dump) =
6898            flatten_xfa_to_pdf_with_layout_dump(&pdf_bytes).expect("dump flatten should succeed");
6899
6900        assert_eq!(flattened_with_dump, flattened);
6901        assert!(!layout_dump.pages.is_empty());
6902        assert_eq!(layout_dump.pages[0].page_num, 1);
6903        assert!(layout_dump.pages[0].used_height <= layout_dump.pages[0].page_height);
6904    }
6905
6906    // -----------------------------------------------------------------------
6907    // XFA-F6-02 (#1110): AcroForm/XFA removal tests
6908    // -----------------------------------------------------------------------
6909
6910    /// After flattening an XFA PDF, the output must not contain /NeedsRendering.
6911    #[test]
6912    fn flatten_removes_needs_rendering() {
6913        // Build a PDF with NeedsRendering in the catalog.
6914        let mut pdf_bytes = build_xfa_pdf(SIMPLE_XDP);
6915        // Insert NeedsRendering into the catalog via lopdf.
6916        {
6917            let mut doc = Document::load_mem(&pdf_bytes).expect("parse for NeedsRendering test");
6918            let root_id = match doc.trailer.get(b"Root") {
6919                Ok(Object::Reference(id)) => *id,
6920                _ => panic!("no Root in trailer"),
6921            };
6922            if let Ok(Object::Dictionary(ref mut dict)) = doc.get_object_mut(root_id) {
6923                dict.set("NeedsRendering", Object::Boolean(true));
6924            }
6925            let mut out = Vec::new();
6926            doc.save_to(&mut out)
6927                .expect("re-save for NeedsRendering test");
6928            pdf_bytes = out;
6929        }
6930
6931        // Flatten should strip NeedsRendering.
6932        let flattened = flatten_xfa_to_pdf(&pdf_bytes).expect("flatten failed");
6933        let doc = Document::load_mem(&flattened).expect("parse flattened PDF");
6934        let root_id = match doc.trailer.get(b"Root") {
6935            Ok(Object::Reference(id)) => *id,
6936            _ => panic!("no Root in flattened trailer"),
6937        };
6938        let catalog = doc.get_dictionary(root_id).expect("catalog dict");
6939        assert!(
6940            catalog.get(b"NeedsRendering").is_err(),
6941            "/NeedsRendering must be absent after flatten"
6942        );
6943    }
6944
6945    /// After flattening an XFA PDF, the output must not contain /XFA anywhere
6946    /// in the catalog or AcroForm dictionary.
6947    #[test]
6948    fn flatten_removes_xfa_entry() {
6949        let pdf_bytes = build_xfa_pdf(SIMPLE_XDP);
6950        let flattened = flatten_xfa_to_pdf(&pdf_bytes).expect("flatten failed");
6951
6952        // Search the serialised bytes for /XFA — the key must not appear.
6953        // We look for " /XFA" / "\n/XFA" patterns in the raw output.
6954        let flattened_str = String::from_utf8_lossy(&flattened);
6955        assert!(
6956            !flattened_str.contains("/XFA"),
6957            "/XFA must be absent from flattened output, but was found"
6958        );
6959    }
6960
6961    #[test]
6962    fn remove_acroform_purges_xfa_packet_objects() {
6963        let (mut doc, acroform_id, xfa_ids) = build_xfa_doc_with_xfa_array();
6964
6965        remove_acroform(&mut doc);
6966
6967        assert!(
6968            !doc.objects.contains_key(&acroform_id),
6969            "AcroForm object should be removed from doc.objects"
6970        );
6971        for xfa_id in &xfa_ids {
6972            assert!(
6973                !doc.objects.contains_key(xfa_id),
6974                "XFA packet object {xfa_id:?} should be removed from doc.objects"
6975            );
6976        }
6977
6978        let mut out = Vec::new();
6979        doc.save_to(&mut out).expect("save cleaned PDF");
6980        let out_str = String::from_utf8_lossy(&out);
6981        assert!(
6982            !out_str.contains("xdp:xdp"),
6983            "serialized output should not contain orphaned XFA packet payloads"
6984        );
6985        assert!(
6986            !out_str.contains("<template"),
6987            "serialized output should not contain orphaned template payloads"
6988        );
6989    }
6990
6991    /// After flattening, there must be no empty /Annots arrays in the output.
6992    #[test]
6993    fn flatten_removes_empty_annots_arrays() {
6994        // Build a PDF with an empty Annots array on the page.
6995        let mut pdf_bytes = build_xfa_pdf(SIMPLE_XDP);
6996        {
6997            let mut doc = Document::load_mem(&pdf_bytes).expect("parse for annots test");
6998            let page_id = doc.page_iter().next().expect("at least one page");
6999            if let Ok(Object::Dictionary(ref mut dict)) = doc.get_object_mut(page_id) {
7000                dict.set("Annots", Object::Array(vec![]));
7001            }
7002            let mut out = Vec::new();
7003            doc.save_to(&mut out).expect("re-save for annots test");
7004            pdf_bytes = out;
7005        }
7006
7007        let flattened = flatten_xfa_to_pdf(&pdf_bytes).expect("flatten failed");
7008        let doc = Document::load_mem(&flattened).expect("parse flattened PDF");
7009        for page_id in doc.page_iter() {
7010            let page = doc.get_dictionary(page_id).expect("page dict");
7011            if let Ok(Object::Array(arr)) = page.get(b"Annots") {
7012                // absent = good; only a present /Annots must be non-empty
7013                assert!(
7014                    !arr.is_empty(),
7015                    "page {:?}: /Annots must either be absent or non-empty after flatten",
7016                    page_id
7017                );
7018            }
7019        }
7020    }
7021
7022    #[test]
7023    fn remove_acroform_strips_widgets_from_indirect_annots_arrays() {
7024        let appearance = Object::Stream(Stream::new(
7025            dictionary! {
7026                "Type" => Object::Name(b"XObject".to_vec()),
7027                "Subtype" => Object::Name(b"Form".to_vec()),
7028                "BBox" => Object::Array(vec![
7029                    Object::Integer(0), Object::Integer(0),
7030                    Object::Integer(20), Object::Integer(20),
7031                ]),
7032                "Resources" => Object::Dictionary(dictionary! {}),
7033            },
7034            b"BT /F1 8 Tf 1 1 Td (X) Tj ET\n".to_vec(),
7035        ));
7036        let pdf_bytes = build_xfa_pdf_with_widget_appearance(
7037            Vec::new(),
7038            appearance,
7039            dictionary! {
7040                "FT" => Object::Name(b"Tx".to_vec()),
7041                "T" => Object::string_literal("field[0]"),
7042            },
7043        );
7044
7045        let mut doc = Document::load_mem(&pdf_bytes).expect("parse test PDF");
7046        let page_id = doc.page_iter().next().expect("page");
7047        let annots = page_annotations(&doc, page_id);
7048        let annots_id = doc.add_object(Object::Array(annots));
7049        if let Ok(Object::Dictionary(ref mut page_dict)) = doc.get_object_mut(page_id) {
7050            page_dict.set("Annots", Object::Reference(annots_id));
7051        }
7052
7053        remove_acroform(&mut doc);
7054
7055        let page = doc.get_dictionary(page_id).expect("page dict");
7056        assert!(
7057            page.get(b"Annots").is_err(),
7058            "widget-only indirect /Annots must be removed"
7059        );
7060    }
7061
7062    #[test]
7063    fn acroform_without_xfa_falls_back_to_static_cleanup() {
7064        let appearance = Object::Stream(Stream::new(
7065            dictionary! {
7066                "Type" => Object::Name(b"XObject".to_vec()),
7067                "Subtype" => Object::Name(b"Form".to_vec()),
7068                "BBox" => Object::Array(vec![
7069                    Object::Integer(0), Object::Integer(0),
7070                    Object::Integer(20), Object::Integer(20),
7071                ]),
7072                "Resources" => Object::Dictionary(dictionary! {}),
7073            },
7074            b"BT /F1 8 Tf 1 1 Td (X) Tj ET\n".to_vec(),
7075        ));
7076        let pdf_bytes = build_xfa_pdf_with_widget_appearance(
7077            Vec::new(),
7078            appearance,
7079            dictionary! {
7080                "FT" => Object::Name(b"Tx".to_vec()),
7081                "T" => Object::string_literal("field[0]"),
7082            },
7083        );
7084
7085        let mut doc = Document::load_mem(&pdf_bytes).expect("parse source PDF");
7086        let root_id = match doc.trailer.get(b"Root") {
7087            Ok(Object::Reference(id)) => *id,
7088            _ => panic!("no Root"),
7089        };
7090        let acroform_id = doc
7091            .get_dictionary(root_id)
7092            .expect("catalog")
7093            .get(b"AcroForm")
7094            .expect("AcroForm")
7095            .as_reference()
7096            .expect("AcroForm ref");
7097        if let Ok(Object::Dictionary(ref mut acroform)) = doc.get_object_mut(acroform_id) {
7098            acroform.remove(b"XFA");
7099        }
7100        let mut acroform_only = Vec::new();
7101        doc.save_to(&mut acroform_only)
7102            .expect("save AcroForm-only PDF");
7103
7104        let flattened = flatten_xfa_to_pdf(&acroform_only).expect("flatten failed");
7105        let flattened_doc = Document::load_mem(&flattened).expect("parse flattened PDF");
7106        let root_id = match flattened_doc.trailer.get(b"Root") {
7107            Ok(Object::Reference(id)) => *id,
7108            _ => panic!("no Root in flattened PDF"),
7109        };
7110        let catalog = flattened_doc
7111            .get_dictionary(root_id)
7112            .expect("flattened catalog");
7113        assert!(
7114            catalog.get(b"AcroForm").is_err(),
7115            "AcroForm-only PDFs should still be cleaned by flatten"
7116        );
7117
7118        let page_id = flattened_doc.page_iter().next().expect("flattened page");
7119        assert!(
7120            page_annotations(&flattened_doc, page_id).is_empty(),
7121            "flattened AcroForm-only PDFs should not retain widget annotations"
7122        );
7123    }
7124
7125    // -----------------------------------------------------------------------
7126    // XFA-F6-03 (#1111): validate_flattened_pdf tests
7127    // -----------------------------------------------------------------------
7128
7129    /// A clean (non-XFA) PDF must pass validation with no warnings.
7130    #[test]
7131    fn validate_flattened_pdf_clean_pdf_passes() {
7132        // Build the minimal PDF document (no AcroForm/XFA).
7133        let mut doc = Document::with_version("1.4");
7134        let pages_id = doc.new_object_id();
7135        let page_id = doc.add_object(Object::Dictionary(dictionary! {
7136            "Type"     => Object::Name(b"Page".to_vec()),
7137            "Parent"   => Object::Reference(pages_id),
7138            "MediaBox" => Object::Array(vec![
7139                Object::Integer(0), Object::Integer(0),
7140                Object::Integer(612), Object::Integer(792),
7141            ])
7142        }));
7143        doc.objects.insert(
7144            pages_id,
7145            Object::Dictionary(dictionary! {
7146                "Type"  => Object::Name(b"Pages".to_vec()),
7147                "Kids"  => Object::Array(vec![Object::Reference(page_id)]),
7148                "Count" => Object::Integer(1)
7149            }),
7150        );
7151        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
7152            "Type"  => Object::Name(b"Catalog".to_vec()),
7153            "Pages" => Object::Reference(pages_id)
7154        }));
7155        doc.trailer.set("Root", Object::Reference(catalog_id));
7156        let mut pdf_bytes = Vec::new();
7157        doc.save_to(&mut pdf_bytes).expect("save clean PDF");
7158
7159        let validation = validate_flattened_pdf(&pdf_bytes).expect("validate failed");
7160        assert!(
7161            validation.has_no_acroform,
7162            "clean PDF should have no AcroForm"
7163        );
7164        assert!(validation.has_no_xfa, "clean PDF should have no XFA");
7165        assert!(
7166            validation.has_no_needs_rendering,
7167            "clean PDF should have no NeedsRendering"
7168        );
7169        assert_eq!(validation.page_count, 1, "clean PDF should report 1 page");
7170        assert!(
7171            validation.warnings.is_empty(),
7172            "clean PDF should produce no warnings, got: {:?}",
7173            validation.warnings
7174        );
7175    }
7176
7177    /// validate_flattened_pdf must not panic on empty input.
7178    #[test]
7179    fn validate_flattened_pdf_does_not_panic_on_empty_input() {
7180        let result = validate_flattened_pdf(&[]);
7181        // Should return Ok with a warning, not panic.
7182        assert!(
7183            result.is_ok(),
7184            "expected Ok from empty input, got: {:?}",
7185            result.err()
7186        );
7187        let v = result.unwrap();
7188        assert_eq!(v.page_count, 0, "empty input has 0 pages");
7189        assert!(
7190            !v.warnings.is_empty(),
7191            "empty input should produce at least one warning"
7192        );
7193    }
7194
7195    // -----------------------------------------------------------------------
7196    // XFA-F6-04 (#1112): compare_flatten_quality tests
7197    // -----------------------------------------------------------------------
7198
7199    /// Page count comparison works correctly via compare_flatten_quality.
7200    #[test]
7201    fn compare_flatten_quality_page_count_comparison() {
7202        let original = build_xfa_pdf(SIMPLE_XDP);
7203        let flattened = flatten_xfa_to_pdf(&original).expect("flatten failed");
7204        let metrics =
7205            compare_flatten_quality(&original, &flattened).expect("compare_flatten_quality failed");
7206        // Both before and after must parse to at least 1 page.
7207        assert!(
7208            metrics.page_count_before >= 1,
7209            "original must have >= 1 page"
7210        );
7211        assert!(
7212            metrics.page_count_after >= 1,
7213            "flattened must have >= 1 page"
7214        );
7215        // page_count_match must reflect equality.
7216        assert_eq!(
7217            metrics.page_count_match,
7218            metrics.page_count_before == metrics.page_count_after,
7219            "page_count_match must equal page_count_before == page_count_after"
7220        );
7221    }
7222
7223    /// Content ratio is computed correctly.
7224    #[test]
7225    fn compare_flatten_quality_content_ratio_computed() {
7226        let original = build_xfa_pdf(SIMPLE_XDP);
7227        let flattened = flatten_xfa_to_pdf(&original).expect("flatten failed");
7228        let metrics =
7229            compare_flatten_quality(&original, &flattened).expect("compare_flatten_quality failed");
7230        // Ratio should be a finite positive number.
7231        assert!(
7232            metrics.content_ratio.is_finite() && metrics.content_ratio >= 0.0,
7233            "content_ratio must be finite and >= 0, got: {}",
7234            metrics.content_ratio
7235        );
7236        // Verify the ratio matches the raw values.
7237        let expected = if metrics.content_stream_bytes_before == 0 {
7238            1.0_f64
7239        } else {
7240            metrics.content_stream_bytes_after as f64 / metrics.content_stream_bytes_before as f64
7241        };
7242        assert!(
7243            (metrics.content_ratio - expected).abs() < 1e-9,
7244            "content_ratio mismatch: expected {expected}, got {}",
7245            metrics.content_ratio
7246        );
7247    }
7248
7249    // -----------------------------------------------------------------------
7250    // XFA-F7-02 (#1114): validate_text_completeness tests
7251    // -----------------------------------------------------------------------
7252
7253    /// validate_text_completeness returns completeness_ratio = 1.0 when the
7254    /// original XFA bytes have no datasets packet (nothing to check).
7255    #[test]
7256    fn validate_text_completeness_no_datasets_returns_perfect_ratio() {
7257        // Build an XFA PDF whose XDP has no <datasets> packet — just a template.
7258        let xdp = r#"<?xml version="1.0"?>
7259<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
7260  <template>
7261    <subform name="root">
7262      <field name="greeting"><ui><textEdit/></ui></field>
7263    </subform>
7264  </template>
7265</xdp:xdp>"#;
7266        let original = build_xfa_pdf(xdp);
7267        // Use a minimal clean PDF as the "flattened" output.
7268        let mut doc = Document::with_version("1.4");
7269        let pages_id = doc.new_object_id();
7270        let page_id = doc.add_object(Object::Dictionary(dictionary! {
7271            "Type"     => Object::Name(b"Page".to_vec()),
7272            "Parent"   => Object::Reference(pages_id),
7273            "MediaBox" => Object::Array(vec![
7274                Object::Integer(0), Object::Integer(0),
7275                Object::Integer(612), Object::Integer(792),
7276            ])
7277        }));
7278        doc.objects.insert(
7279            pages_id,
7280            Object::Dictionary(dictionary! {
7281                "Type"  => Object::Name(b"Pages".to_vec()),
7282                "Kids"  => Object::Array(vec![Object::Reference(page_id)]),
7283                "Count" => Object::Integer(1)
7284            }),
7285        );
7286        let catalog_id = doc.add_object(Object::Dictionary(dictionary! {
7287            "Type"  => Object::Name(b"Catalog".to_vec()),
7288            "Pages" => Object::Reference(pages_id)
7289        }));
7290        doc.trailer.set("Root", Object::Reference(catalog_id));
7291        let mut flattened = Vec::new();
7292        doc.save_to(&mut flattened).unwrap();
7293
7294        let result = validate_text_completeness(&original, &flattened)
7295            .expect("validate_text_completeness should not fail");
7296        assert!(
7297            result.expected_values.is_empty(),
7298            "no datasets packet means no expected values"
7299        );
7300        assert_eq!(
7301            result.completeness_ratio, 1.0,
7302            "empty expected set should yield ratio 1.0"
7303        );
7304    }
7305
7306    /// validate_text_completeness returns ratio 1.0 on empty inputs (no panic).
7307    #[test]
7308    fn validate_text_completeness_empty_inputs_do_not_panic() {
7309        let result = validate_text_completeness(&[], &[]);
7310        assert!(result.is_ok(), "should return Ok on empty inputs");
7311        let v = result.unwrap();
7312        assert_eq!(v.completeness_ratio, 1.0);
7313        assert!(v.expected_values.is_empty());
7314        assert!(v.missing_values.is_empty());
7315    }
7316
7317    // -----------------------------------------------------------------------
7318    // XFA-F9-03 (#1122): Debug logging — no panic/error on empty/non-XFA input
7319    // -----------------------------------------------------------------------
7320
7321    /// Calling `flatten_xfa_to_pdf` with completely empty input must not panic
7322    /// and must return an Ok (pass-through) or a well-formed Err.
7323    ///
7324    /// This also exercises the logging infrastructure: no log::error! calls
7325    /// should be emitted for inputs that simply have no XFA content.
7326    #[test]
7327    fn flatten_empty_bytes_does_not_panic_and_does_not_error() {
7328        // Empty byte slice: not a PDF, no XFA markers — should return Ok([])
7329        // or at worst a well-formed Err (not a panic).
7330        // We only assert it does not panic; Ok or Err is acceptable.
7331        let _ = flatten_xfa_to_pdf(b"");
7332    }
7333
7334    /// Non-XFA PDF bytes: flatten_xfa_to_pdf must return the input unchanged
7335    /// and must not emit any log errors.
7336    #[test]
7337    fn flatten_non_xfa_bytes_returns_input_unchanged() {
7338        // A trivial byte string that looks vaguely like PDF but has no /AcroForm
7339        // and no xdp:xdp — the pre-check at the start of flatten_xfa_to_pdf
7340        // should return immediately with the original bytes cloned.
7341        let input = b"%PDF-1.4\n%%EOF\n";
7342        // Err is acceptable for degenerate input; a success must pass through.
7343        if let Ok(out) = flatten_xfa_to_pdf(input) {
7344            assert_eq!(out, input, "non-XFA input should pass through unchanged");
7345        }
7346    }
7347}