Merge PR #3637: Fix named properties in forms without resources

ROADMAP.md: progress log union, newest first
This commit is contained in:
aiongg
2026-10-22 01:45:57 +00:00
6 changed files with 340 additions and 9 deletions
+3
View File
@@ -2,6 +2,8 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
@@ -174,6 +176,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged.
- **2026-10-20 (M2, #3574):** Forms without Resources use page Properties for ActualText and layer visibility. Synthetic tests cover boundaries, nested/shared forms, and save/reopen. Estimates unchanged.
- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
@@ -0,0 +1,294 @@
//! Contributor-original synthetic forms for named marked-content properties.
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
use std::sync::Arc;
fn fixture(own: bool) -> Vec<u8> {
let res1 = "<< /Font << /F1 3 0 R >> /XObject << /Fm0 8 0 R >> /Properties << /P1 << /ActualText (REPLACED) >> >> >>";
let res2 = "<< /Font << /F1 3 0 R >> /XObject << /Fm0 9 0 R >> /Properties << /oc1 7 0 R >> >>";
let own1 = if own { format!("/Resources {res1}") } else { String::new() };
let own2 = if own { format!("/Resources {res2}") } else { String::new() };
let body1 = "/Span /P1 BDC BT /F1 24 Tf 72 700 Td (glyphs) Tj ET EMC";
let body2 = "/OC /oc1 BDC BT /F1 24 Tf 72 700 Td (HIDDENTEXT) Tj ET EMC";
let page1 = "q /Fm0 Do Q";
let page2 = "q /Fm0 Do Q BT /F1 24 Tf 72 600 Td (visible) Tj ET";
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [7 0 R] /D << /OFF [7 0 R] >> >> >>".into(),
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R /Resources {res1} >>"),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 10 0 R /Resources {res2} >>"),
stream("", page1),
"<< /Type /OCG /Name (Hidden) >>".into(),
stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] {own1}"), body1),
stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] {own2}"), body2),
stream("", page2),
])
}
fn text(renderer: &mut PageRenderer, page: usize) -> Arc<pdfcraft_render::PageText> {
let result = renderer.render(RenderRequest { page, kind: RequestKind::Text, ..Default::default() });
assert!(result.error.is_none(), "{:?}", result.error);
result.text.unwrap()
}
#[test]
fn named_actual_text_in_resource_less_form_replaces_glyphs() {
for own in [true, false] {
let mut renderer = PageRenderer::new(Arc::new(fixture(own)), RenderConfig::default());
let result = text(&mut renderer, 0);
assert_eq!(result.plain_text(), "REPLACED", "own Resources: {own}");
assert_eq!(result.find("REPLACED").len(), 1);
assert!(result.find("glyphs").is_empty());
}
}
#[test]
fn named_off_layer_in_resource_less_form_stays_hidden() {
for own in [true, false] {
let mut renderer = PageRenderer::new(Arc::new(fixture(own)), RenderConfig::default());
let image = renderer.render(RenderRequest { page: 1, scale: 1.0, ..Default::default() });
assert!(image.error.is_none(), "{:?}", image.error);
assert_eq!((image.width, image.height), (612, 792));
let upper = image
.rgba
.as_chunks::<4>()
.0
.iter()
.enumerate()
.filter(|(index, _)| (70..105).contains(&(index / 612)))
.filter(|(_, pixel)| **pixel != [255, 255, 255, 255])
.count();
let lower = image
.rgba
.as_chunks::<4>()
.0
.iter()
.enumerate()
.filter(|(index, _)| (170..200).contains(&(index / 612)))
.filter(|(_, pixel)| **pixel != [255, 255, 255, 255])
.count();
let result = text(&mut renderer, 1);
println!(
"own={own}: upper ink={upper}; lower ink={lower}; text={:?}; hidden Find={:?}; visible Find={:?}",
result.plain_text(),
result.find("HIDDENTEXT"),
result.find("visible")
);
assert_eq!(upper, 0, "OFF layer has no upper ink, own Resources: {own}");
assert!(lower > 0, "ordinary page text stays visible");
assert_eq!(result.plain_text(), "visible", "own Resources: {own}");
assert_eq!(result.find("visible").len(), 1);
assert!(result.find("HIDDENTEXT").is_empty());
}
}
// Contributor-original synthetic PDFs. No font or document assets are embedded.
fn pdf(objects: &[String]) -> Vec<u8> {
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
bytes
}
fn stream(dict: &str, body: &str) -> String {
format!("<< {dict} /Length {} >>\nstream\n{body}\nendstream", body.len())
}
fn resources(properties: &str) -> String {
format!("<< /Font << /F1 3 0 R >> /XObject << /Fm 6 0 R /Inner 7 0 R >> {properties} >>")
}
fn form(resources: &str, body: &str) -> String {
stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] {resources}"), body)
}
const GLYPHS: &str = "/Span /P BDC BT /F1 12 Tf 10 150 Td (glyphs) Tj ET EMC";
const OFF_PAINT: &str = "/OC /O BDC 1 0 0 rg 10 100 20 20 re f EMC 0 0 1 rg 50 100 20 20 re f";
fn objects(page_resources: &str, form_resources: &str, body: &str) -> Vec<String> {
vec![
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [8 0 R 9 0 R] /D << /OFF [8 0 R] >> >> >>".into(),
"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 5 0 R {page_resources} >>"),
stream("", "/Fm Do"),
form(form_resources, body),
form("", GLYPHS),
"<< /Type /OCG /Name (PageOff) >>".into(),
"<< /Type /OCG /Name (FormOn) >>".into(),
]
}
fn assert_text(bytes: Vec<u8>, page: usize, expected: &str) {
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let result = text(&mut renderer, page);
assert_eq!(result.plain_text(), expected);
assert_eq!(result.find(expected).len(), 1);
if expected != "glyphs" {
assert!(result.find("glyphs").is_empty());
}
}
#[test]
fn own_form_dictionary_stops_property_fallback() {
let page = format!("/Resources {}", resources("/Properties << /P << /ActualText (PAGE) >> >>"));
for (own, expected) in [
("", "PAGE"),
("/Resources << /Properties << /P << /ActualText (OWN) >> >> >>", "OWN"),
("/Resources << >>", "glyphs"),
("/Resources << /Properties << >> >>", "glyphs"),
("/Resources << /Properties << /Other << /ActualText (OTHER) >> >> >>", "glyphs"),
("/Resources << /Properties << /P 42 >> >>", "glyphs"),
("/Resources << /Properties << /P 99 0 R >> >>", "glyphs"),
("/Resources << /Properties 42 >>", "glyphs"),
("/Resources 42", "glyphs"),
("/Resources 99 0 R", "glyphs"),
// Preserve the pre-fix behavior for an explicitly present null entry.
("/Resources null", "glyphs"),
] {
assert_text(pdf(&objects(&page, own, GLYPHS)), 0, expected);
}
}
#[test]
fn nested_resource_less_form_uses_page_origin_and_own_form_shadows_it() {
let page = format!("/Resources {}", resources("/Properties << /P << /ActualText (PAGE) >> >>"));
let outer = format!("/Resources {}", resources("/Properties << /P << /ActualText (CALLER) >> >>"));
for (inner, expected) in [("", "PAGE"), ("/Resources << /Properties << /P << /ActualText (INNER) >> >> >>", "INNER")] {
let mut fixture = objects(&page, &outer, "/Inner Do");
fixture[6] = form(inner, GLYPHS);
assert_text(pdf(&fixture), 0, expected);
}
}
#[test]
fn shared_form_uses_each_pages_properties() {
let first = format!("/Resources {}", resources("/Properties << /P << /ActualText (FIRST) >> >>"));
let second = format!("/Resources {}", resources("/Properties << /P << /ActualText (SECOND) >> >>"));
let mut fixture = objects(&first, "", GLYPHS);
fixture[1] = "<< /Type /Pages /Kids [4 0 R 10 0 R] /Count 2 >>".into();
fixture.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 5 0 R {second} >>"));
let bytes = pdf(&fixture);
assert_text(bytes.clone(), 0, "FIRST");
assert_text(bytes, 1, "SECOND");
}
#[test]
fn page_tree_properties_inherit_only_until_an_own_resources_dictionary() {
let root = resources("/Properties << /P << /ActualText (ROOT) >> >>");
let near = resources("/Properties << /P << /ActualText (NEAR) >> >>");
for (node, page, expected) in [
("".to_string(), "".to_string(), "ROOT"),
(format!("/Resources {near}"), "".to_string(), "NEAR"),
("/Resources << >>".to_string(), "".to_string(), "glyphs"),
("".to_string(), "/Resources << >>".to_string(), "glyphs"),
] {
let mut fixture = objects(&page, "", GLYPHS);
fixture[1] = format!("<< /Type /Pages /Resources {root} /Kids [10 0 R] /Count 1 >>");
fixture[3] = fixture[3].replace("/Parent 2 0 R", "/Parent 10 0 R");
fixture.push(format!("<< /Type /Pages /Parent 2 0 R /Kids [4 0 R] /Count 1 {node} >>"));
assert_text(pdf(&fixture), 0, expected);
}
}
fn assert_paint(bytes: Vec<u8>, page: usize, layers: Vec<(i32, i32, bool)>, visible: bool) {
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig { layers: Arc::new(layers), ..Default::default() });
let image = renderer.render(RenderRequest { page, scale: 1.0, ..Default::default() });
assert!(image.error.is_none(), "{:?}", image.error);
assert_eq!((image.width, image.height), (200, 200));
let pixel = |x: usize, y: usize| &image.rgba[(y * 200 + x) * 4..(y * 200 + x) * 4 + 4];
assert_eq!(pixel(60, 90), [0, 0, 255, 255], "unmarked control remains blue");
assert_eq!(pixel(20, 90), if visible { [255, 0, 0, 255] } else { [255; 4] });
}
#[test]
fn indirect_page_properties_preserve_ocg_identity_and_visibility_overrides() {
let page = format!("/Resources {}", resources("/Properties 10 0 R"));
let mut fixture = objects(&page, "", OFF_PAINT);
fixture.push("<< /O 8 0 R >>".into());
let bytes = pdf(&fixture);
assert_paint(bytes.clone(), 0, vec![], false);
assert_paint(bytes.clone(), 0, vec![(9, 0, true)], false);
assert_paint(bytes.clone(), 0, vec![(8, 0, true)], true);
assert_paint(bytes, 0, vec![(8, 0, false)], false);
}
#[test]
fn nested_ocg_uses_page_origin_and_own_dictionary_boundaries() {
let page = format!("/Resources {}", resources("/Properties << /O 8 0 R >>"));
let caller = format!("/Resources {}", resources("/Properties << /O 9 0 R >>"));
for (inner, visible) in [
("", false),
("/Resources << /Properties << /O 9 0 R >> >>", true),
("/Resources << >>", true),
("/Resources << /Properties << /Other 8 0 R >> >>", true),
("/Resources << /Properties << /O 42 >> >>", true),
("/Resources << /Properties << /O 99 0 R >> >>", true),
] {
let mut fixture = objects(&page, &caller, "/Inner Do");
fixture[6] = form(inner, OFF_PAINT);
assert_paint(pdf(&fixture), 0, vec![], visible);
}
}
#[test]
fn direct_and_inline_properties_and_unmarked_content_keep_existing_behavior() {
let page = format!("/Resources {}", resources("/Properties << /P << /ActualText (DIRECT) >> >>"));
for (body, expected) in [
(GLYPHS, "DIRECT"),
("/Span << /ActualText (INLINE) >> BDC BT /F1 12 Tf 10 150 Td (glyphs) Tj ET EMC", "INLINE"),
("/Span BMC BT /F1 12 Tf 10 150 Td (glyphs) Tj ET EMC", "glyphs"),
("/Span << /ActualText (OUTER) >> BDC /Span /P BDC BT /F1 12 Tf 10 150 Td (glyphs) Tj ET EMC EMC", "OUTER"),
] {
assert_text(pdf(&objects(&page, "", body)), 0, expected);
}
let inline_ref = OFF_PAINT.replace("/O BDC", "<< /OC 8 0 R >> BDC");
assert_paint(pdf(&objects(&page, "", &inline_ref)), 0, vec![], false);
assert_paint(pdf(&objects(&page, "", &inline_ref)), 0, vec![(8, 0, true)], true);
let direct_ocg = format!("/Resources {}", resources("/Properties << /O << /Type /OCG /Name (Direct) >> >>"));
assert_paint(pdf(&objects(&direct_ocg, "", OFF_PAINT)), 0, vec![], true);
assert_paint(pdf(&objects(&page, "", &OFF_PAINT.replace("/O BDC", "<< /Type /OCG /Name (Inline) >> BDC"))), 0, vec![], true);
}
#[test]
fn save_reopen_preserves_missing_resources_and_effective_named_properties() {
let page = format!("/Resources {}", resources("/Properties << /P << /ActualText (PAGE) >> /O 8 0 R >>"));
for own in ["", "/Resources << /Properties << /P << /ActualText (PAGE) >> /O 8 0 R >> >>"] {
let bytes = pdf(&objects(&page, own, GLYPHS));
let mut document = lopdf::Document::load_mem(&bytes).unwrap();
let before = document.get_object((6, 0)).unwrap().as_stream().unwrap().dict.clone();
let mut saved = Vec::new();
document.save_to(&mut saved).unwrap();
let reopened = lopdf::Document::load_mem(&saved).unwrap();
assert_eq!(reopened.get_object((6, 0)).unwrap().as_stream().unwrap().dict, before);
assert_text(saved, 0, "PAGE");
let painted = objects(&page, own, OFF_PAINT);
let mut document = lopdf::Document::load_mem(&pdf(&painted)).unwrap();
let mut saved = Vec::new();
document.save_to(&mut saved).unwrap();
assert_paint(saved, 0, vec![], false);
}
}
#[test]
fn shared_form_uses_each_pages_ocg_reference() {
let first = format!("/Resources {}", resources("/Properties << /O 8 0 R >>"));
let second = format!("/Resources {}", resources("/Properties << /O 9 0 R >>"));
let mut fixture = objects(&first, "", OFF_PAINT);
fixture[1] = "<< /Type /Pages /Kids [4 0 R 10 0 R] /Count 2 >>".into();
fixture.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 5 0 R {second} >>"));
let bytes = pdf(&fixture);
assert_paint(bytes.clone(), 0, vec![], false);
assert_paint(bytes.clone(), 1, vec![], true);
assert_paint(bytes.clone(), 0, vec![(8, 0, true)], true);
assert_paint(bytes, 1, vec![(9, 0, false)], false);
}
+3 -1
View File
@@ -1,5 +1,7 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -113,7 +115,7 @@ total matches the parity estimate.
|---|---|---|---|---|---|
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Missing: ≥ 250 tests, own image codecs |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. |
+6
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -66,7 +66,7 @@ pub(crate) struct FormXObject<'a> {
is_isolated: bool,
is_knockout: bool,
pub(crate) dict: Dict<'a>,
resources: Dict<'a>,
resources: Option<Dict<'a>>,
}
impl<'a> FormXObject<'a> {
@@ -75,7 +75,8 @@ impl<'a> FormXObject<'a> {
// PdfCraft patch: see `context::MAX_PAGE_CONTENT`.
let decoded = crate::context::decode_content(stream)?;
let resources = dict.get::<Dict<'_>>(RESOURCES).unwrap_or_default();
// PdfCraft patch: retain absence; present malformed dictionaries stay local-empty.
let resources = dict.contains_key(RESOURCES).then(|| dict.get::<Dict<'_>>(RESOURCES).unwrap_or_default());
let matrix = Affine::new(
dict.get::<[f64; 6]>(MATRIX)
@@ -191,7 +192,7 @@ pub(crate) fn draw_form_xobject<'a, 'b>(
interpret(
iter,
&Resources::from_parent(x_object.resources.clone(), resources.clone()),
&Resources::for_form(x_object.resources.clone(), resources.clone()),
context,
device,
);
+30 -5
View File
@@ -148,8 +148,8 @@ fn resolve_pages<'a>(
ctx.rotate = Some(rotate);
}
let resources = Resources::from_parent(
pages_dict.get::<Dict<'_>>(RESOURCES).unwrap_or_default(),
let resources = Resources::for_page(
pages_dict.contains_key(RESOURCES).then(|| pages_dict.get::<Dict<'_>>(RESOURCES).unwrap_or_default()),
resources.clone(),
);
@@ -431,8 +431,8 @@ impl<'a> Page<'a> {
.map_or(1.0, |u| u.min(MAX_USER_UNIT) as f32);
let ctx = resources.ctx.clone();
let resources = Resources::from_parent(
dict.get::<Dict<'_>>(RESOURCES).unwrap_or_default(),
let resources = Resources::for_page(
dict.contains_key(RESOURCES).then(|| dict.get::<Dict<'_>>(RESOURCES).unwrap_or_default()),
resources,
);
@@ -674,11 +674,13 @@ impl<'a> Page<'a> {
pub struct Resources<'a> {
parent: Option<Box<Self>>,
ctx: ReaderContext<'a>,
// PdfCraft patch: legacy forms without Resources use the page, not their caller.
page_properties: Dict<'a>,
/// The raw dictionary of external graphics states.
pub ext_g_states: Dict<'a>,
/// The raw dictionary of fonts.
pub fonts: Dict<'a>,
/// The raw dictionary of properties.
/// The effective property dictionary selected at page or form entry.
pub properties: Dict<'a>,
/// The raw dictionary of color spaces.
pub color_spaces: Dict<'a>,
@@ -698,6 +700,26 @@ impl<'a> Resources<'a> {
Self::new(resources, Some(parent), &ctx)
}
// PdfCraft patch: page-tree inheritance stops at any present Resources dictionary.
fn for_page(resources: Option<Dict<'a>>, parent: Self) -> Self {
let properties = resources.as_ref().map_or_else(|| parent.properties.clone(), |dict| dict.get::<Dict<'_>>(PROPERTIES).unwrap_or_default());
let mut result = Self::from_parent(resources.unwrap_or_default(), parent);
result.properties = properties.clone();
result.page_properties = properties;
result
}
/// Create form resources, using page Properties only when Resources is absent.
/// Other resource categories retain their existing parent-chain lookup.
// PdfCraft patch: ISO 32000-2, 7.8.3 and Table 93 require the page origin.
pub fn for_form(resources: Option<Dict<'a>>, parent: Self) -> Self {
let properties =
resources.as_ref().map_or_else(|| parent.page_properties.clone(), |dict| dict.get::<Dict<'_>>(PROPERTIES).unwrap_or_default());
let mut result = Self::from_parent(resources.unwrap_or_default(), parent);
result.properties = properties;
result
}
/// Create a new `Resources` object.
pub(crate) fn new(resources: Dict<'a>, parent: Option<Self>, ctx: &ReaderContext<'a>) -> Self {
let ext_g_states = resources.get::<Dict<'_>>(EXT_G_STATE).unwrap_or_default();
@@ -708,6 +730,8 @@ impl<'a> Resources<'a> {
let shadings = resources.get::<Dict<'_>>(SHADING).unwrap_or_default();
let properties = resources.get::<Dict<'_>>(PROPERTIES).unwrap_or_default();
// PdfCraft patch: generic child resources retain the page origin.
let page_properties = parent.as_ref().map_or_else(|| properties.clone(), |parent| parent.page_properties.clone());
let parent = parent.map(Box::new);
Self {
@@ -716,6 +740,7 @@ impl<'a> Resources<'a> {
fonts,
color_spaces,
properties,
page_properties,
x_objects,
patterns,
shadings,