mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:35:21 +00:00
M2: layers open in the state the document's view auto-state sets (ISO 32000-2 8.11.4.4)
The default configuration's /AS array (usage application dictionaries) was never read, so a group whose /Usage /View /ViewState is OFF and that a /View usage application manages still drew on screen. - hayro-interpret patch (67): OcgState::from_catalog applies the View event's auto state after /BaseState, /ON and /OFF and before ocg_overrides. Each application with /Event /View and /View in /Category recommends each listed group's /ViewState; a group is ON only if every recommendation is ON. /Zoom, /User and /Language give no recommendation. At most 256 applications, 4096 /OCGs entries and 16 categories each are read. InterpreterSettings::view_auto_state (on by default) switches it. - render: RenderConfig::for_print turns it off; print as image sets it, since printing is not the View event. - inspect: a layer's initial and visible state apply the same auto state, so the Layers panel and doc_info match the page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f137a289ba
commit
e5ce0afd02
@@ -22,6 +22,9 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont
|
||||
let mut config = src.config.clone();
|
||||
// The copy below holds only what prints, so the viewer's own comment hiding plays no part.
|
||||
config.hide_comments = false;
|
||||
// Printing is not the View event: a layer the auto state turns off on screen keeps its
|
||||
// configured state on paper, as in a print that goes out as vectors.
|
||||
config.for_print = true;
|
||||
let printed = guard(|| -> Result<Vec<u8>, String> {
|
||||
let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?;
|
||||
pdfcraft_print::keep_printed(&mut cos, content);
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::core::inspect;
|
||||
use super::core::{Inspector, load_options};
|
||||
use super::model::{Attachment, AttachmentSource, FontInfo, Layer, PageTransition, TransitionStyle};
|
||||
use super::navigation::NameTreeSeen;
|
||||
use super::{AssertUnwindSafe, Dictionary, DocInfo, Document, HashSet, Object, ObjectId, catch_unwind};
|
||||
use super::{AssertUnwindSafe, Dictionary, DocInfo, Document, HashMap, HashSet, Object, ObjectId, catch_unwind};
|
||||
|
||||
/// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to
|
||||
/// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template.
|
||||
@@ -12,6 +12,13 @@ const MAX_XFA_CONFIG_BYTES: usize = 16 << 20;
|
||||
/// At most this many layers are read from all of `/RBGroups` together.
|
||||
const MAX_LAYER_GROUP_ENTRIES: usize = 4096;
|
||||
|
||||
/// The configuration's auto state (`/AS`): at most this many usage application dictionaries,
|
||||
/// `/OCGs` entries across them, and `/Category` names in each are read. The renderer's
|
||||
/// (vendored hayro-interpret patch (67)) have the same values.
|
||||
const MAX_AUTO_STATE_APPLICATIONS: usize = 256;
|
||||
const MAX_AUTO_STATE_GROUPS: usize = 4096;
|
||||
const MAX_AUTO_STATE_CATEGORIES: usize = 16;
|
||||
|
||||
impl<'a> Inspector<'a> {
|
||||
// ── object helpers ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,11 +107,14 @@ impl<'a> Inspector<'a> {
|
||||
.and_then(|o| self.resolve(o).as_array().ok())
|
||||
.map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
let auto = config.map(|c| self.view_auto_state(c)).unwrap_or_default();
|
||||
let Ok(Object::Array(ocgs)) = props.get(b"OCGs").map(|o| self.resolve(o)) else { return };
|
||||
for g in ocgs {
|
||||
let Some(d) = self.dict(g) else { continue };
|
||||
let id = g.as_reference().ok();
|
||||
// The View auto state, when it manages the group, then the configuration.
|
||||
let visible = match id {
|
||||
Some(id) if let Some(&state) = auto.get(&id) => state,
|
||||
Some(id) if off.contains(&id) => false,
|
||||
Some(id) if on.contains(&id) => true,
|
||||
_ => !base_off,
|
||||
@@ -114,6 +124,54 @@ impl<'a> Inspector<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The state the configuration's auto state (`/AS`) gives each group it manages for the View
|
||||
/// event (ISO 32000-2 §8.11.4.4, Table 101), which overrides `/BaseState`, `/ON` and `/OFF`
|
||||
/// as the document opens. Keep in step with the renderer's (vendored hayro-interpret patch
|
||||
/// (67), `apply_view_auto_state`): each usage application with `/Event /View` and `/View` in
|
||||
/// its `/Category` recommends the `/ViewState` of each group in its `/OCGs` whose `/Usage`
|
||||
/// gives one; a group is on only if all its recommendations are. Groups with none are absent.
|
||||
/// Other categories (`/Zoom`, `/User`, `/Language`) give no recommendation here.
|
||||
fn view_auto_state(&self, config: &Dictionary) -> HashMap<ObjectId, bool> {
|
||||
let mut out: HashMap<ObjectId, bool> = HashMap::new();
|
||||
let Some(applications) = config.get(b"AS").ok().and_then(|o| self.resolve(o).as_array().ok()) else { return out };
|
||||
let mut groups_left = MAX_AUTO_STATE_GROUPS;
|
||||
for application in applications.iter().take(MAX_AUTO_STATE_APPLICATIONS) {
|
||||
let Some(application) = self.dict(application) else { continue };
|
||||
if self.name(application, b"Event").as_deref() != Some("View") {
|
||||
continue;
|
||||
}
|
||||
let views = application.get(b"Category").ok().and_then(|o| self.resolve(o).as_array().ok()).is_some_and(|categories| {
|
||||
categories.iter().take(MAX_AUTO_STATE_CATEGORIES).any(|c| matches!(c, Object::Name(n) if n.as_slice() == b"View"))
|
||||
});
|
||||
if !views {
|
||||
continue;
|
||||
}
|
||||
let Some(groups) = application.get(b"OCGs").ok().and_then(|o| self.resolve(o).as_array().ok()) else { continue };
|
||||
for g in groups {
|
||||
if groups_left == 0 {
|
||||
return out;
|
||||
}
|
||||
groups_left -= 1;
|
||||
let Ok(id) = g.as_reference() else { continue };
|
||||
let state = self
|
||||
.dict(g)
|
||||
.and_then(|d| d.get(b"Usage").ok())
|
||||
.and_then(|o| self.dict(o))
|
||||
.and_then(|u| u.get(b"View").ok())
|
||||
.and_then(|o| self.dict(o))
|
||||
.and_then(|v| self.name(v, b"ViewState"));
|
||||
let on = match state.as_deref() {
|
||||
Some("ON") => true,
|
||||
Some("OFF") => false,
|
||||
_ => continue,
|
||||
};
|
||||
let entry = out.entry(id).or_insert(true);
|
||||
*entry &= on;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The default configuration's radio-button groups (`/RBGroups`) of two or more layers.
|
||||
pub(super) fn layer_groups(&self, catalog: &Dictionary) -> Vec<Vec<ObjectId>> {
|
||||
let groups = catalog
|
||||
|
||||
@@ -769,6 +769,40 @@ trailer << /Root 1 0 R >>
|
||||
assert_eq!(layers, [("Red", true), ("Green", false), ("Blue", false)]);
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §8.11.4.4: the View auto state (`/AS`) decides the initial state of the groups
|
||||
/// it manages, over `/ON` and `/OFF`; the others keep the configuration's. Malformed entries
|
||||
/// are ignored.
|
||||
#[test]
|
||||
fn initial_layer_visibility_applies_the_view_auto_state() {
|
||||
let initial = |config: &str| {
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D {config} >> >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj
|
||||
5 0 obj << /Type /OCG /Name (ViewOff) /Usage << /View << /ViewState /OFF >> >> >> endobj
|
||||
6 0 obj << /Type /OCG /Name (ViewOn) /Usage 8 0 R >> endobj
|
||||
7 0 obj << /Type /OCG /Name (Plain) >> endobj
|
||||
8 0 obj << /View << /ViewState /ON >> >> endobj
|
||||
9 0 obj 9 0 R endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF"
|
||||
);
|
||||
let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens");
|
||||
// Opened, so each is shown as it starts.
|
||||
info.layers.iter().inspect(|l| assert_eq!(l.initial, l.visible, "{}", l.name)).map(|l| l.initial).collect::<Vec<_>>()
|
||||
};
|
||||
let managed = "/AS [<< /Event /View /Category [/View] /OCGs [5 0 R 6 0 R 7 0 R] >>]";
|
||||
assert_eq!(initial(&format!("<< /OFF [6 0 R 7 0 R] {managed} >>")), [false, true, false]);
|
||||
assert_eq!(initial("<< /OFF [6 0 R 7 0 R] >>"), [true, false, false], "without /AS");
|
||||
assert_eq!(initial("<< /OFF [6 0 R] /AS [<< /Event /Print /Category [/View] /OCGs [5 0 R 6 0 R] >>] >>"), [true, false, true], "Print event");
|
||||
assert_eq!(initial("<< /AS [<< /Event /View /Category [/Zoom] /OCGs [5 0 R] >>] >>"), [true, true, true], "Zoom only");
|
||||
for bad in ["/AS 9 0 R", "/AS [9 0 R 1 null]", "/AS [<< /Event /View /OCGs [5 0 R] >>]", "/AS [<< /Event /View /Category [/View] /OCGs 9 0 R >>]"]
|
||||
{
|
||||
assert_eq!(initial(&format!("<< {bad} >>")), [true, true, true], "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
const ORPHAN_FIELDS: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
|
||||
@@ -83,6 +83,9 @@ pub struct RenderConfig {
|
||||
/// viewer and preview rasters: exports, printing and OCR render without it, so nothing drawn with an
|
||||
/// installed font is saved or printed (AGENTS.md §1.2, §1.4).
|
||||
pub system_fonts: bool,
|
||||
/// The render is output for printing (print as image): the configuration's auto state for
|
||||
/// the View event (ISO 32000-2 §8.11.4.4) is not applied, since printing is not viewing.
|
||||
pub for_print: bool,
|
||||
}
|
||||
|
||||
impl RenderConfig {
|
||||
@@ -104,6 +107,7 @@ impl RenderConfig {
|
||||
let system_fonts = self.system_fonts;
|
||||
InterpreterSettings {
|
||||
ocg_overrides: self.layers.clone(),
|
||||
view_auto_state: !self.for_print,
|
||||
hide_comments: self.hide_comments,
|
||||
font_resolver: Arc::new(move |query| {
|
||||
japanese_fallback(query).or_else(|| system_fonts.then(|| system_fallback(query)).flatten()).or_else(|| standard(query))
|
||||
|
||||
@@ -2303,6 +2303,112 @@ trailer << /Root 1 0 R >>
|
||||
assert_eq!(px(off), vec![255, 255, 255, 255], "toggled-off layer must not draw");
|
||||
}
|
||||
|
||||
/// A page whose red square is in group 5 0 R (`ocg` its dictionary), under the default
|
||||
/// configuration `config`; `extra` adds objects from number 8.
|
||||
fn auto_state_pdf(ocg: &str, config: &str, extra: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D {config} >> >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length 44 >> stream
|
||||
/OC /L1 BDC 1 0 0 rg 0 0 100 100 re f EMC
|
||||
endstream endobj
|
||||
5 0 obj {ocg} endobj
|
||||
{extra}
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// Whether the red square in group 5 0 R draws.
|
||||
fn auto_state_red(pdf: Vec<u8>, config: RenderConfig) -> bool {
|
||||
let mut r = PageRenderer::new(Arc::new(pdf), config);
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() });
|
||||
p.rgba.get(..4) == Some(&[255, 0, 0, 255][..])
|
||||
}
|
||||
|
||||
const VIEW_OFF: &str = "<< /Type /OCG /Name (A) /Usage << /View << /ViewState /OFF >> >> >>";
|
||||
const VIEW_ON: &str = "<< /Type /OCG /Name (A) /Usage << /View << /ViewState /ON >> >> >>";
|
||||
const AS_VIEW: &str = "/AS [<< /Event /View /Category [/View] /OCGs [5 0 R] >>]";
|
||||
|
||||
/// ISO 32000-2 §8.11.4.4: the configuration's auto state (`/AS`) sets a group's state for
|
||||
/// the View event from its usage, over `/BaseState`, `/ON` and `/OFF`. It was ignored, so a
|
||||
/// group whose `/ViewState` is OFF still drew on screen.
|
||||
#[test]
|
||||
fn the_view_auto_state_sets_a_groups_state_on_screen() {
|
||||
let shown = |ocg: &str, config: &str| auto_state_red(auto_state_pdf(ocg, config, ""), RenderConfig::default());
|
||||
assert!(!shown(VIEW_OFF, &format!("<< {AS_VIEW} >>")), "ViewState OFF, managed: hidden");
|
||||
assert!(shown(VIEW_OFF, "<< >>"), "no /AS: the usage alone changes nothing");
|
||||
assert!(shown(VIEW_OFF, "<< /AS [<< /Event /View /Category [/View] /OCGs [] >>] >>"), "not listed in /OCGs");
|
||||
assert!(shown(VIEW_ON, &format!("<< /OFF [5 0 R] {AS_VIEW} >>")), "ViewState ON wins over /OFF");
|
||||
assert!(shown(VIEW_ON, &format!("<< /BaseState /OFF {AS_VIEW} >>")), "and over /BaseState /OFF");
|
||||
assert!(!shown(VIEW_ON, "<< /OFF [5 0 R] >>"), "without /AS, /OFF decides");
|
||||
// The View event only, and the View category only.
|
||||
assert!(shown(VIEW_OFF, "<< /AS [<< /Event /Print /Category [/View] /OCGs [5 0 R] >>] >>"), "Print event: not on screen");
|
||||
assert!(shown(VIEW_OFF, "<< /AS [<< /Event /Export /Category [/View] /OCGs [5 0 R] >>] >>"), "Export event: not on screen");
|
||||
assert!(shown(VIEW_OFF, "<< /AS [<< /Event /View /Category [/Print] /OCGs [5 0 R] >>] >>"), "a category without a View entry");
|
||||
// A category it cannot evaluate gives no recommendation; the View one still applies.
|
||||
assert!(!shown(VIEW_OFF, "<< /AS [<< /Event /View /Category [/Zoom /View] /OCGs [5 0 R] >>] >>"), "Zoom alongside View");
|
||||
let zoomed = "<< /Type /OCG /Name (A) /Usage << /Zoom << /min 4 >> >> >>";
|
||||
assert!(shown(zoomed, "<< /AS [<< /Event /View /Category [/Zoom] /OCGs [5 0 R] >>] >>"), "Zoom alone: unchanged");
|
||||
// Indirect applications, arrays and usage dictionaries are followed.
|
||||
let indirect = "<< /Type /OCG /Name (A) /Usage 10 0 R >>";
|
||||
let extra = "8 0 obj [9 0 R] endobj\n9 0 obj << /Event /View /Category [/View] /OCGs 11 0 R >> endobj\n10 0 obj << /View << /ViewState /OFF >> >> endobj\n11 0 obj [5 0 R] endobj";
|
||||
assert!(!auto_state_red(auto_state_pdf(indirect, "<< /AS 8 0 R >>", extra), RenderConfig::default()), "indirect");
|
||||
}
|
||||
|
||||
/// The Layers panel's toggles still decide over the auto state, and a render for printing
|
||||
/// does not apply it (printing is not the View event).
|
||||
#[test]
|
||||
fn layer_toggles_and_print_renders_are_not_the_view_auto_state() {
|
||||
let pdf = auto_state_pdf(VIEW_OFF, &format!("<< {AS_VIEW} >>"), "");
|
||||
let on = RenderConfig { layers: Arc::new(vec![(5, 0, true)]), ..Default::default() };
|
||||
assert!(auto_state_red(pdf.clone(), on), "toggled on in the Layers panel");
|
||||
assert!(auto_state_red(pdf.clone(), RenderConfig { for_print: true, ..Default::default() }), "printed: its configured state");
|
||||
assert!(!auto_state_red(pdf, RenderConfig::default()), "on screen: hidden");
|
||||
}
|
||||
|
||||
/// Malformed auto state is ignored without a panic, and the walk is bounded.
|
||||
#[test]
|
||||
fn malformed_view_auto_state_is_ignored() {
|
||||
let shown = |config: &str, extra: &str| auto_state_red(auto_state_pdf(VIEW_OFF, config, extra), RenderConfig::default());
|
||||
for config in [
|
||||
"<< /AS 5 >>",
|
||||
"<< /AS << /Event /View /Category [/View] /OCGs [5 0 R] >> >>",
|
||||
"<< /AS [1 null /x (s) [5 0 R]] >>",
|
||||
"<< /AS [<< /Event /View /Category [/View] >>] >>",
|
||||
"<< /AS [<< /Event /View /OCGs [5 0 R] >>] >>",
|
||||
"<< /AS [<< /Category [/View] /OCGs [5 0 R] >>] >>",
|
||||
"<< /AS [<< /Event (View) /Category [/View] /OCGs [5 0 R] >>] >>",
|
||||
"<< /AS [<< /Event /View /Category /View /OCGs [5 0 R] >>] >>",
|
||||
"<< /AS [<< /Event /View /Category [/View] /OCGs 5 0 R >>] >>",
|
||||
"<< /AS [<< /Event /View /Category [/View] /OCGs [(5) 5 /OCG] >>] >>",
|
||||
] {
|
||||
assert!(shown(config, ""), "{config}: ignored");
|
||||
}
|
||||
let bad_state = |s: &str| {
|
||||
let ocg = format!("<< /Type /OCG /Name (A) /Usage << /View << /ViewState {s} >> >> >>");
|
||||
auto_state_red(auto_state_pdf(&ocg, &format!("<< {AS_VIEW} >>"), ""), RenderConfig::default())
|
||||
};
|
||||
for s in ["/Off", "(OFF)", "0", "null", "[/OFF]"] {
|
||||
assert!(bad_state(s), "ViewState {s}: no recommendation");
|
||||
}
|
||||
// Cyclic references: an /AS, an application and a usage that refer to themselves.
|
||||
assert!(shown("<< /AS 8 0 R >>", "8 0 obj 8 0 R endobj"), "self-referencing /AS");
|
||||
assert!(shown("<< /AS [8 0 R] >>", "8 0 obj 8 0 R endobj"), "self-referencing application");
|
||||
let cyclic = "<< /Type /OCG /Name (A) /Usage 8 0 R >>";
|
||||
assert!(auto_state_red(auto_state_pdf(cyclic, &format!("<< {AS_VIEW} >>"), "8 0 obj 8 0 R endobj"), RenderConfig::default()));
|
||||
// Huge arrays: the group listed after 4096 other entries is past the cap.
|
||||
let many = format!("<< /AS [<< /Event /View /Category [/View] /OCGs [{}5 0 R] >>] >>", "9 0 R ".repeat(4096));
|
||||
assert!(shown(&many, "9 0 obj << /Type /OCG /Name (B) >> endobj"), "past the cap: unchanged");
|
||||
let within = format!("<< /AS [<< /Event /View /Category [/View] /OCGs [{}5 0 R] >>] >>", "9 0 R ".repeat(4095));
|
||||
assert!(!shown(&within, "9 0 obj << /Type /OCG /Name (B) >> endobj"), "within the cap: applied");
|
||||
let apps = format!("<< /AS [{}<< /Event /View /Category [/View] /OCGs [5 0 R] >>] >>", "<< /Event /Print >> ".repeat(256));
|
||||
assert!(shown(&apps, ""), "past the application cap: unchanged");
|
||||
}
|
||||
|
||||
/// #2986: an optional content membership dictionary with a visibility expression (`/VE`,
|
||||
/// ISO 32000-2 §8.11.2.2) shows its content only when the expression is true, and the
|
||||
/// expression takes precedence over `/OCGs` and `/P`. It was ignored, so content under it
|
||||
|
||||
@@ -1334,8 +1334,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = ["doc_info", "layer_set"]
|
||||
evidence = ["crates/render/src/raster/tests.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/it/automation.rs::layers_and_hidden_comments_change_what_page_render_draws"]
|
||||
notes = "layer_set shows or hides a layer by name (every group with that name, as CAD exporters repeat names) or by position, and page_render draws with the document's layer visibility. Still missing: Reset to initial visibility (view.layers-reset), layer properties, and /OC on annotations (#2176)."
|
||||
evidence = ["crates/render/src/raster/tests.rs::layer_override_hides_content", "crates/engine/src/tests.rs::layer_choices_survive_edits", "crates/automation/tests/it/automation.rs::layers_and_hidden_comments_change_what_page_render_draws", "crates/render/src/raster/tests.rs::the_view_auto_state_sets_a_groups_state_on_screen", "crates/render/src/raster/tests.rs::layer_toggles_and_print_renders_are_not_the_view_auto_state", "crates/render/src/raster/tests.rs::malformed_view_auto_state_is_ignored"]
|
||||
notes = "layer_set shows or hides a layer by name (every group with that name, as CAD exporters repeat names) or by position, and page_render draws with the document's layer visibility. Still missing: Reset to initial visibility (view.layers-reset), layer properties, and /OC on annotations (#2176). The default configuration's auto state for the View event (/AS, ISO 32000-2 8.11.4.4) sets the groups it manages from their /Usage /View /ViewState as the document opens, on screen and in the panel, and toggles still override it; renders for printing don't apply it. Not yet: the /Zoom, /User and /Language categories, and the Print event's auto state when printing."
|
||||
|
||||
[[feature]]
|
||||
id = "view.layers-reset"
|
||||
@@ -1346,7 +1346,7 @@ milestone = "M12"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = ["doc_info"]
|
||||
evidence = ["crates/engine/src/tests.rs::reset_layers_returns_to_the_files_visibility", "crates/render/src/inspect/tests.rs::initial_layer_visibility_is_the_default_configuration"]
|
||||
evidence = ["crates/engine/src/tests.rs::reset_layers_returns_to_the_files_visibility", "crates/render/src/inspect/tests.rs::initial_layer_visibility_is_the_default_configuration", "crates/render/src/inspect/tests.rs::initial_layer_visibility_applies_the_view_auto_state"]
|
||||
notes = "Layers panel ▸ Layer options ▸ Reset to Initial Visibility shows each layer as the default configuration (/OCProperties /D) has it; doc_info reports it as `initial`. Not yet: listing the layers of all pages or the current page only."
|
||||
|
||||
[[feature]]
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -237,7 +237,7 @@ impl<'a> Context<'a> {
|
||||
let mut ocg_state = {
|
||||
let root_ref = xref.root_id();
|
||||
xref.get::<Dict<'_>>(root_ref)
|
||||
.map(|catalog| OcgState::from_catalog(&catalog))
|
||||
.map(|catalog| OcgState::from_catalog(&catalog, xref, settings.view_auto_state))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
// PdfCraft patch: apply viewer layer toggles.
|
||||
|
||||
+6
@@ -111,6 +111,11 @@ pub struct InterpreterSettings {
|
||||
/// PdfCraft patch: viewer overrides for optional content groups (object number, generation,
|
||||
/// visible), applied on top of the document's default configuration (Layers panel toggles).
|
||||
pub ocg_overrides: Arc<Vec<(i32, i32, bool)>>,
|
||||
/// PdfCraft patch (67): apply the default configuration's auto state for the View event
|
||||
/// (`/AS` usage applications with `/Event /View`, ISO 32000-2 §8.11.4.4) before
|
||||
/// `ocg_overrides`. On by default: that is the state for viewing. Off for output made for
|
||||
/// printing, which the View event is not.
|
||||
pub view_auto_state: bool,
|
||||
/// PdfCraft patch: once this is `true`, interpretation stops before the next content
|
||||
/// operator. What was drawn is then incomplete, so only set it when the output will be
|
||||
/// thrown away (a viewer's render whose result nobody can receive any more).
|
||||
@@ -140,6 +145,7 @@ impl Default for InterpreterSettings {
|
||||
render_annotations: true,
|
||||
hide_comments: false,
|
||||
ocg_overrides: Arc::new(Vec::new()),
|
||||
view_auto_state: true,
|
||||
cancelled: None,
|
||||
preview: false,
|
||||
}
|
||||
|
||||
Vendored
+94
-3
@@ -1,6 +1,9 @@
|
||||
use hayro_syntax::object::dict::keys::{BASE_STATE, D, OCGS, OCMD, OCPROPERTIES, OFF, ON, P, TYPE, VE};
|
||||
use hayro_syntax::object::dict::keys::{
|
||||
AS, BASE_STATE, D, OCGS, OCMD, OCPROPERTIES, OFF, ON, P, TYPE, USAGE, VE, VIEW, VIEW_STATE,
|
||||
};
|
||||
use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier};
|
||||
use std::collections::HashSet;
|
||||
use hayro_syntax::xref::XRef;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub(crate) struct OcgState {
|
||||
inactive_ocgs: HashSet<ObjectIdentifier>,
|
||||
@@ -30,7 +33,10 @@ impl OcgState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_catalog(catalog: &Dict<'_>) -> Self {
|
||||
/// The groups' states as the document opens: the default configuration (`/BaseState`, then
|
||||
/// `/ON` and `/OFF`), then, when `view_auto_state` is set (PdfCraft patch (67)), the auto
|
||||
/// state its `/AS` array gives for the View event ([`apply_view_auto_state`]).
|
||||
pub(crate) fn from_catalog(catalog: &Dict<'_>, xref: &XRef, view_auto_state: bool) -> Self {
|
||||
let Some(oc_properties) = catalog.get::<Dict<'_>>(OCPROPERTIES) else {
|
||||
return Self::dummy();
|
||||
};
|
||||
@@ -83,6 +89,10 @@ impl OcgState {
|
||||
read_ocg_array(ON, true);
|
||||
read_ocg_array(OFF, false);
|
||||
|
||||
if view_auto_state {
|
||||
apply_view_auto_state(&config, xref, &mut inactive);
|
||||
}
|
||||
|
||||
Self {
|
||||
inactive_ocgs: inactive,
|
||||
known_ocgs: known,
|
||||
@@ -213,6 +223,87 @@ impl Default for OcgState {
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (67): at most this many usage application dictionaries of an `/AS` array are
|
||||
/// read; the rest are ignored.
|
||||
const MAX_AUTO_STATE_APPLICATIONS: usize = 256;
|
||||
/// PdfCraft patch (67): at most this many `/OCGs` entries are read across all of them.
|
||||
const MAX_AUTO_STATE_GROUPS: usize = 4096;
|
||||
/// PdfCraft patch (67): at most this many `/Category` names are read per application.
|
||||
const MAX_AUTO_STATE_CATEGORIES: usize = 16;
|
||||
|
||||
/// PdfCraft patch (67): apply the configuration's auto state for the View event (ISO 32000-2
|
||||
/// §8.11.4.4, Tables 99 and 101). For each usage application dictionary in `/AS` whose `/Event`
|
||||
/// is `/View`, each group in its `/OCGs` gets a recommended state from each category in its
|
||||
/// `/Category` that the group's `/Usage` gives one for. A group is ON only if every
|
||||
/// recommendation it receives, across all such dictionaries, is ON, and OFF otherwise; a group
|
||||
/// that receives none keeps its configured state.
|
||||
///
|
||||
/// Only the `/View` category (`/ViewState`) is evaluated. `/Zoom`, `/User` and `/Language` need
|
||||
/// the magnification, user and locale, which the interpreter doesn't know, so they give no
|
||||
/// recommendation. Malformed entries (not a dictionary, no `/Category`, a `/ViewState` other
|
||||
/// than ON or OFF) are skipped, and the walk is bounded by the caps above.
|
||||
fn apply_view_auto_state(config: &Dict<'_>, xref: &XRef, inactive: &mut HashSet<ObjectIdentifier>) {
|
||||
let Some(applications) = config.get::<Array<'_>>(AS) else {
|
||||
return;
|
||||
};
|
||||
let mut recommended: HashMap<ObjectIdentifier, bool> = HashMap::new();
|
||||
let mut groups_left = MAX_AUTO_STATE_GROUPS;
|
||||
for application in applications.raw_iter().take(MAX_AUTO_STATE_APPLICATIONS) {
|
||||
let application = match application {
|
||||
MaybeRef::Ref(r) => xref.get::<Dict<'_>>(r.into()),
|
||||
MaybeRef::NotRef(Object::Dict(d)) => Some(d),
|
||||
MaybeRef::NotRef(_) => None,
|
||||
};
|
||||
let Some(application) = application else {
|
||||
continue;
|
||||
};
|
||||
if application.get::<Name<'_>>(b"Event").as_deref() != Some(VIEW) {
|
||||
continue;
|
||||
}
|
||||
let views = application.get::<Array<'_>>(b"Category").is_some_and(|categories| {
|
||||
categories
|
||||
.raw_iter()
|
||||
.take(MAX_AUTO_STATE_CATEGORIES)
|
||||
.any(|c| matches!(c, MaybeRef::NotRef(Object::Name(n)) if n.as_ref() == VIEW))
|
||||
});
|
||||
if !views {
|
||||
continue;
|
||||
}
|
||||
let Some(groups) = application.get::<Array<'_>>(OCGS) else {
|
||||
continue;
|
||||
};
|
||||
for group in groups.raw_iter() {
|
||||
if groups_left == 0 {
|
||||
break;
|
||||
}
|
||||
groups_left -= 1;
|
||||
let Some(r) = group.as_obj_ref() else {
|
||||
continue;
|
||||
};
|
||||
let id: ObjectIdentifier = r.into();
|
||||
let state = xref
|
||||
.get::<Dict<'_>>(id)
|
||||
.and_then(|g| g.get::<Dict<'_>>(USAGE))
|
||||
.and_then(|u| u.get::<Dict<'_>>(VIEW))
|
||||
.and_then(|v| v.get::<Name<'_>>(VIEW_STATE));
|
||||
let on = match state.as_deref() {
|
||||
Some(ON) => true,
|
||||
Some(OFF) => false,
|
||||
_ => continue,
|
||||
};
|
||||
let entry = recommended.entry(id).or_insert(true);
|
||||
*entry &= on;
|
||||
}
|
||||
}
|
||||
for (id, on) in recommended {
|
||||
if on {
|
||||
inactive.remove(&id);
|
||||
} else {
|
||||
inactive.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (40): how deep visibility expressions may nest; deeper ones are malformed.
|
||||
const MAX_VISIBILITY_EXPRESSION_DEPTH: usize = 32;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user