diff --git a/crates/engine/src/layers.rs b/crates/engine/src/layers.rs index f75967097c..b2fab68acb 100644 --- a/crates/engine/src/layers.rs +++ b/crates/engine/src/layers.rs @@ -6,10 +6,102 @@ use pdfcraft_render::{Layer, LayerOp, RenderPool}; use crate::{Document, display_config, render_threads}; +/// Most usage application dictionaries (`/AS` entries) that [`print_layer_states`] reads, and +/// most category names and groups it reads in all of them together (entries can share one +/// array, so the count is over every entry, not each). +const MAX_AUTO_STATES: usize = 1024; +const MAX_AUTO_ITEMS: usize = 65_536; + +/// The usage dictionary entry and its state key that a `/Category` name of a usage application +/// dictionary reads when printing (ISO 32000-2 Table 101): Print, View and Export each name a +/// state of the group's own. Zoom, User and Language depend on the magnification, the user and +/// the system language, which printing doesn't fix, so they give no recommendation here. +fn print_category(name: &[u8]) -> Option<(&'static [u8], &'static [u8])> { + match name { + b"Print" => Some((b"Print", b"PrintState")), + b"View" => Some((b"View", b"ViewState")), + b"Export" => Some((b"Export", b"ExportState")), + _ => None, + } +} + +/// The layers as they print: `layers` as shown, then the default configuration's auto state for +/// the Print event (ISO 32000-2 §8.11.4.4, Table 101). Each usage application dictionary +/// (`/AS` entry) whose `/Event` is `/Print` recommends, for each group in its `/OCGs` and each of +/// its categories ([`print_category`]), the state the group's usage dictionary gives (for the +/// Print category, `/Usage /Print /PrintState`). A group with recommendations is on only if all +/// of them, in all the entries naming it, are ON; other groups keep their state. `/Category` is +/// an array; a single name is read as one. `None` when no `/AS` entry is for printing. +pub(crate) fn print_layer_states(cos: &pdfcraft_cos::Document, layers: &[Layer]) -> Option> { + use pdfcraft_cos::Object; + let root = cos.trailer().get(b"Root").map(|r| cos.resolve(r))?; + let properties = root.as_dict()?.get(b"OCProperties").map(|p| cos.resolve(p))?; + let config = properties.as_dict()?.get(b"D").map(|d| cos.resolve(d))?; + let auto = config.as_dict()?.get(b"AS").map(|a| cos.resolve(a))?; + let mut printing = false; + let mut recommended: std::collections::HashMap<(u32, u16), bool> = std::collections::HashMap::new(); + let mut items_left = MAX_AUTO_ITEMS; + for entry in auto.as_array()?.iter().take(MAX_AUTO_STATES) { + let entry = cos.resolve(entry); + let Some(entry) = entry.as_dict() else { continue }; + if entry.get(b"Event").map(|e| cos.resolve(e)).as_deref().and_then(Object::as_name) != Some(b"Print") { + continue; + } + printing = true; + let categories = entry.get(b"Category").map(|c| cos.resolve(c)); + let mut read: Vec<(&[u8], &[u8])> = Vec::new(); + let mut consider = |name: Option<&[u8]>| { + if let Some(category) = name.and_then(print_category).filter(|c| !read.contains(c)) { + read.push(category); + } + }; + match categories.as_deref() { + Some(Object::Array(names)) => { + let count = names.len().min(items_left); + items_left -= count; + names.iter().take(count).for_each(|n| consider(cos.resolve(n).as_name())); + } + Some(Object::Name(n)) => consider(Some(n.as_slice())), + _ => {} + } + if read.is_empty() { + continue; + } + let Some(groups) = entry.get(b"OCGs").map(|g| cos.resolve(g)) else { continue }; + for group in groups.as_array().into_iter().flatten() { + let Some(left) = items_left.checked_sub(1) else { break }; + items_left = left; + let Some(id) = group.as_ref() else { continue }; + let ocg = cos.resolve(group); + let usage = ocg.as_dict().and_then(|g| g.get(b"Usage").map(|u| cos.resolve(u))); + for (entry_key, state_key) in &read { + // No state given (a Print category without `/PrintState`): no recommendation, so + // the group is left as it is. + let state = usage + .as_deref() + .and_then(|u| u.as_dict().and_then(|u| u.get(entry_key).map(|e| cos.resolve(e)))) + .and_then(|e| e.as_dict().and_then(|e| e.get(state_key).map(|s| cos.resolve(s)))) + .and_then(|s| match s.as_name() { + Some(b"ON") => Some(true), + Some(b"OFF") => Some(false), + _ => None, + }); + if let Some(on) = state { + let all_on = recommended.entry((id.num, id.generation)).or_insert(true); + *all_on = *all_on && on; + } + } + } + } + printing.then(|| layers.iter().map(|l| Layer { visible: recommended.get(&l.id).copied().unwrap_or(l.visible), ..l.clone() }).collect()) +} + /// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy -/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its -/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` -/// is left alone. +/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. The +/// states written are final, so what would set them again goes: the auto state (`/AS`), and the +/// layers' own `/Usage`, which some readers apply when they draw (a `/ViewState /ON` turns a layer +/// that prints off back on). The configuration's other entries (`/Order`, `/RBGroups`…) are kept. +/// A document without `/OCProperties` is left alone. pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { use pdfcraft_cos::{Dict, ObjRef, Object}; let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; @@ -22,18 +114,35 @@ pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: config.set(b"BaseState".to_vec(), Object::name("ON")); config.set(b"ON".to_vec(), refs(true)); config.set(b"OFF".to_vec(), refs(false)); + config.remove(b"AS"); properties.set(b"D".to_vec(), Object::Dict(config)); root.set(b"OCProperties".to_vec(), Object::Dict(properties)); cos.set(root_ref, Object::Dict(root)); + for layer in layers { + let id = ObjRef::new(layer.id.0, layer.id.1); + let Some(mut group) = cos.get(id).as_dict().cloned() else { continue }; + if group.remove(b"Usage").is_some() { + cos.set(id, Object::Dict(group)); + } + } } /// Render `doc` with the layer visibility in its `info.layers`. pub(crate) fn use_layer_choices(doc: &mut Document) { let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); doc.config.layers = Arc::new(overrides); + doc.config.print_layers = print_preview_layers(doc.editor.as_ref().map(|e| &e.cos), &doc.info.layers); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); } +/// The layer overrides a print preview draws with (`RenderConfig::print_layers`): the layers as +/// they print ([`print_layer_states`]), so the preview shows the page as it prints. `None` when +/// the document has no auto state for printing (or no structure to read it from). +pub(crate) fn print_preview_layers(cos: Option<&pdfcraft_cos::Document>, layers: &[Layer]) -> Option>> { + let printed = print_layer_states(cos?, layers)?; + Some(Arc::new(printed.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect())) +} + /// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the /// state the changes before it left. Returns whether any layer ended up changed. pub(crate) fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff4f5ff2a1..c6272a0abf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -150,7 +150,7 @@ pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, typed_signature_shape}; pub use history::Label; -use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; +use layers::{apply_layer_state, print_layer_states, print_preview_layers, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; pub use page_filter::{PageOrientation, PageParity, filter_pages}; pub use pictures::{MarkFile, image_dimensions}; diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index facf058695..a09acc9360 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -8,7 +8,8 @@ use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect}; use crate::history::{Editor, Keys, TextBlocksCache}; use crate::{ AppearanceCache, DocId, Document, Session, StatusCaptions, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, display_config, guard, - info_fields, js, note_warnings, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets, + info_fields, js, note_warnings, print_preview_layers, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, + xfa_values_from_datasets, }; /// Immutable session state needed while a document is prepared away from its owning thread. @@ -286,7 +287,7 @@ impl Session { /// The last step of opening: build the document record and register it. fn build_document(context: &OpenContext, data: DocumentOpenData<'_>) -> Document { let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data; - let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; + let mut config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let (mut editor, read_only_reason) = match cos { Ok(Ok(cos)) => { let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; @@ -321,6 +322,8 @@ impl Session { } } } + // The print preview draws the layers as they print. + config.print_layers = print_preview_layers(editor.as_ref().map(|e| &e.cos), &info.layers); let display = display_bytes(editor.as_mut(), &bytes, &mut signatures, &context.captions); let renderer = RenderPool::new(display.clone(), render_threads(), display_config(&config)); let (form, adopted) = editor diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs index 4ed5895860..29df75b014 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -11,7 +11,7 @@ use pdfcraft_print::{Content, Raster}; use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest}; use crate::export::encode_png; -use crate::{Document, guard}; +use crate::{Document, guard, print_layer_states}; /// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one /// page per entry, the size of the source page as displayed, drawing what `content` prints. @@ -22,11 +22,16 @@ 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; - let printed = guard(|| -> Result, String> { + let (printed, layers) = guard(|| -> Result<(Vec, Option>), 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); - write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string()) + // The layers as the vector print sets them: as shown, then the Print event's auto state. + let layers = print_layer_states(&cos, &doc.info.layers); + Ok((write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string())?, layers)) })??; + if let Some(layers) = layers { + config.layers = std::sync::Arc::new(layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect()); + } let mut renderer = PageRenderer::new(std::sync::Arc::new(printed), config); let mut images: Vec<(String, Vec)> = Vec::with_capacity(pages.len()); let mut used: Option = None; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index e5029ccbd6..fe2d62aeb3 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -14,8 +14,8 @@ use crate::{ LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, StatusCaptions, SummarySort, TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, - parse_ymd, print, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, - sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets, + parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, + source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets, }; mod editing; diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 753f8dd0bd..05cf718d4d 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -6,7 +6,7 @@ use super::{ inspect, js, keys_after, note_warnings, render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, uses_scripts, write_incremental, xfa, xfa_sync_datasets, }; -use crate::{EditNotice, MarkKind}; +use crate::{EditNotice, MarkKind, print_preview_layers}; /// Whether the edit, or any edit inside a batch, deletes pages. fn deletes_pages(edit: &Edit) -> bool { @@ -545,6 +545,7 @@ impl Session { } } doc.info = info; + doc.config.print_layers = print_preview_layers(Some(&editor.cos), &doc.info.layers); doc.fonts_stale = false; let mut form = pdfcraft_forms::fields(&editor.cos); xfa::mark_script_buttons(&editor.cos, &mut form); diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index f4e25bc2f6..b787e4d6e2 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -1,7 +1,7 @@ use super::{ Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions, - Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state, - source_kind, writable, write_full, + Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_layer_states, print_raster, + set_default_layer_state, source_kind, writable, write_full, }; impl Session { @@ -167,7 +167,11 @@ impl Session { // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer // action) has changed one, the print copy's default configuration says which are on, so a // hidden layer stays off on paper too; untouched, the document's own configuration prints. - if !doc.config.layers.is_empty() { + // On top of either, the configuration's auto state for the Print event sets the layers it + // names (`print_layer_states`), as printing does in a viewer (ISO 32000-2 §8.11.4.4). + if let Some(printed) = print_layer_states(&cos, &doc.info.layers) { + set_default_layer_state(&mut cos, &printed); + } else if !doc.config.layers.is_empty() { set_default_layer_state(&mut cos, &doc.info.layers); } // Signature states as the screen shows them, so not when its display copy could not be diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 94e20d9400..efb196de56 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3441,6 +3441,185 @@ fn printing_keeps_hidden_layers_hidden() { assert!(red > 100, "shown again, Layer A prints again: {red} red"); } +/// A PDF of `objs`, numbered from 1, the first being the catalog. +fn pdf_of_objects(objs: &[Vec]) -> Vec { + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + pdf +} + +/// ISO 32000-2 §8.11.4.4: the default configuration's auto state for the Print event sets the +/// layers it names when the document prints. Red is on screen but `/PrintState /OFF`, green is off +/// on screen but `/PrintState /ON`: on paper, vector or as an image, green prints and red doesn't. +/// Two blue squares are on screen and `/PrintState /OFF` too, but no Print recommendation reaches +/// them (one is named only in the View entry, the other in a Print entry without the Print +/// category), so they print as shown. +#[test] +fn printing_applies_the_print_auto_state() { + let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC \ + /OC /C BDC 0 0 1 rg 210 10 40 40 re f EMC /OC /D BDC 0 0 1 rg 210 55 40 40 re f EMC"; + let screen_only = b"/Usage << /View << /ViewState /ON >> /Print << /PrintState /OFF >> >> >>"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R 8 0 R] /D << /OFF [6 0 R] \ + /AS [<< /Event /Print /OCGs [5 0 R 6 0 R] /Category [/Print] >> << /Event /View /OCGs [5 0 R 6 0 R 7 0 R] /Category [/View] >> \ + << /Event /Print /OCGs [8 0 R] /Category [/Language] >>] >> >> >>" + .to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 100] /Contents 4 0 R \ + /Resources << /Properties << /A 5 0 R /B 6 0 R /C 7 0 R /D 8 0 R >> >> >>" + .to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(), + [b"<< /Type /OCG /Name (View only) ".as_slice(), screen_only].concat(), + b"<< /Type /OCG /Name (Print only) /Usage << /View << /ViewState /OFF >> /Print << /PrintState /ON >> >> >>".to_vec(), + [b"<< /Type /OCG /Name (View entry) ".as_slice(), screen_only].concat(), + [b"<< /Type /OCG /Name (Other category) ".as_slice(), screen_only].concat(), + ]; + let pdf = pdf_of_objects(&objs); + + let colours = |page: &pdfcraft_render::RenderedPage| { + let px = page.rgba.as_chunks::<4>().0; + let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count(); + let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count(); + let blue = px.iter().filter(|p| p[2] > 200 && p[0] < 60 && p[1] < 60).count(); + (red, green, blue) + }; + let mut s = Session::new(); + let id = s.open("auto-state.pdf", None, Arc::new(pdf), None).unwrap(); + let (red, green, shown_blue) = colours(&render_page(&s, id)); + assert!(red > 100 && green == 0, "on screen, red shows and green doesn't: {red} red, {green} green"); + // The print dialog's preview shows the page as it prints, the view as it shows; so after a + // Layers panel change too. + let preview = |s: &Session, tag: u64| { + let doc = s.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.display.clone(), display_config(&doc.config)); + colours(&renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 2.0, tag, ..Default::default() })) + }; + let view_entry = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "View entry").unwrap(); + let mut printed_blue = Vec::new(); + for _ in 0..2 { + let (red, green, blue) = preview(&s, pdfcraft_render::PRINT_TAG); + assert!(red == 0 && green > 100, "the preview shows the print: {red} red, {green} green"); + printed_blue.push(blue); + let (red, green, _) = preview(&s, 0); + assert!(red > 100 && green == 0, "the view is unchanged: {red} red, {green} green"); + // Hidden after the first round, shown again after the second. + assert!(s.set_layer_visible(id, view_entry, printed_blue.len() == 2)); + } + // Hidden in the panel, View entry (no Print recommendation) leaves the preview too. + assert!(printed_blue[1] * 4 < printed_blue[0] * 3, "the preview follows the panel: {printed_blue:?} blue"); + for raster in [None, Some(print::Raster { dpi: 72.0, grayscale: false })] { + let bytes = Arc::new(s.print_pdf(id, &print::Settings { pages: vec![0], raster, ..Default::default() }).unwrap()); + let mut out = Session::new(); + let printed = out.open("print.pdf", None, bytes.clone(), None).unwrap(); + let (red, green, blue) = colours(&render_page(&out, printed)); + assert_eq!(red, 0, "the view-only layer doesn't print (as image: {})", raster.is_some()); + assert!(green > 100, "the print-only layer prints (as image: {}): {green} green", raster.is_some()); + // Both blue squares print: the one shown on screen alone is half of them. + assert!( + blue * 4 > shown_blue * 3, + "the layers no Print recommendation reaches print as shown (as image: {}): {blue} of {shown_blue} blue", + raster.is_some() + ); + // The vector copy's states are final: nothing a reader applies when drawing is left to + // turn them back (the auto state, or a layer's `/Usage /View /ViewState`). + let layers = &out.get(printed).unwrap().info.layers; + if raster.is_none() { + let cos = pdfcraft_cos::Document::open(bytes).unwrap(); + assert_eq!(layers.len(), 4); + for layer in layers { + let group = cos.get(pdfcraft_cos::ObjRef::new(layer.id.0, layer.id.1)); + assert!(group.as_dict().is_some_and(|g| !g.contains(b"Usage")), "{} keeps its /Usage", layer.name); + } + let root = cos.trailer().get(b"Root").map(|r| cos.resolve(r)).unwrap(); + let properties = root.as_dict().and_then(|r| r.get(b"OCProperties")).map(|p| cos.resolve(p)).unwrap(); + let config = properties.as_dict().and_then(|p| p.get(b"D")).map(|d| cos.resolve(d)).unwrap(); + assert!(config.as_dict().is_some_and(|d| !d.contains(b"AS")), "the print copy keeps /AS"); + } + } +} + +/// The print auto state of `layers` (object numbers from 5) in a document whose default +/// configuration has `auto` as its `/AS` and `groups` as the layers' dictionaries. +fn print_states_of(auto: &str, groups: &[&str]) -> Vec { + let ocgs: String = (0..groups.len()).map(|i| format!("{} 0 R ", i + 5)).collect(); + let mut objs: Vec> = vec![ + format!("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [{ocgs}] /D << /AS {auto} >> >> >>").into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_vec(), + b"<< /Length 0 >>\nstream\n\nendstream".to_vec(), + ]; + objs.extend(groups.iter().map(|g| g.as_bytes().to_vec())); + let cos = pdfcraft_cos::Document::open(Arc::new(pdf_of_objects(&objs))).unwrap(); + let layers: Vec = (0..groups.len()).map(|i| Layer { id: (i as u32 + 5, 0), name: format!("{i}"), visible: true, initial: true }).collect(); + crate::print_layer_states(&cos, &layers).expect("there are Print entries").iter().map(|l| l.visible).collect() +} + +/// ISO 32000-2 §8.11.4.4: a group prints only if every category of every Print entry naming it +/// recommends ON. A Print category (`/PrintState /ON`) and a View category of another Print entry +/// (`/ViewState /OFF`) disagree, so the group stays off; with both ON it prints. A `/Category` +/// written as a single name (Table 101 asks for an array) is read as one. +#[test] +fn a_layer_prints_only_if_every_recommendation_is_on() { + let auto = "[<< /Event /Print /OCGs [5 0 R 6 0 R] /Category [/Print] >> << /Event /Print /OCGs [5 0 R 6 0 R] /Category [/View] >> \ + << /Event /Print /OCGs [7 0 R] /Category /Print >>]"; + let states = print_states_of( + auto, + &[ + "<< /Type /OCG /Name (Disagree) /Usage << /View << /ViewState /OFF >> /Print << /PrintState /ON >> >> >>", + "<< /Type /OCG /Name (Agree) /Usage << /View << /ViewState /ON >> /Print << /PrintState /ON >> >> >>", + "<< /Type /OCG /Name (Single name) /Usage << /Print << /PrintState /OFF >> >> >>", + ], + ); + assert_eq!(states, [false, true, false]); +} + +/// At most 1,024 `/AS` entries are read: an entry past them that would turn the layer off is not +/// reached, while the same entry within them is. +#[test] +fn the_print_auto_state_reads_a_bounded_number_of_entries() { + let layer = "<< /Type /OCG /Name (Layer) /Usage << /Print << /PrintState /OFF >> >> >>"; + let other = "<< /Type /OCG /Name (Other) >>"; + let auto = |before: usize| { + let filler = "<< /Event /Print /OCGs [6 0 R] /Category [/Print] >> ".repeat(before); + format!("[{filler} << /Event /Print /OCGs [5 0 R] /Category [/Print] >>]") + }; + assert_eq!(print_states_of(&auto(1023), &[layer, other]), [false, true], "the 1,024th entry is read"); + assert_eq!(print_states_of(&auto(1024), &[layer, other]), [true, true], "the 1,025th entry is not"); +} + +/// Reading the print auto state is bounded over all `/AS` entries together: 1,024 Print entries +/// sharing one `/Category` array of 70,000 names, `/Print` last, read 65,536 names in all, find no +/// Print category and recommend nothing, instead of reading the array 1,024 times. +#[test] +fn the_print_auto_state_reads_a_bounded_number_of_categories() { + let names = format!("[{} /Print]", "/View ".repeat(70_000)); + let entries = "<< /Event /Print /OCGs [5 0 R] /Category 6 0 R >> ".repeat(1024); + let objs: Vec> = vec![ + format!("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /AS [{entries}] >> >> >>").into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_vec(), + b"<< /Length 0 >>\nstream\n\nendstream".to_vec(), + b"<< /Type /OCG /Name (Layer) /Usage << /Print << /PrintState /OFF >> >> >>".to_vec(), + names.into_bytes(), + ]; + let cos = pdfcraft_cos::Document::open(Arc::new(pdf_of_objects(&objs))).unwrap(); + let layer = Layer { id: (5, 0), name: "Layer".into(), visible: true, initial: true }; + let printed = crate::print_layer_states(&cos, std::slice::from_ref(&layer)).expect("there are Print entries"); + assert!(printed.iter().all(|l| l.visible), "past the bound nothing is read, so the layer prints as shown"); +} + #[test] fn printing_lays_out_sheets_that_render() { let (s, id) = session_with(5); diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 9331c82ea7..f99ae74c64 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -87,6 +87,10 @@ pub struct RenderConfig { /// `hayro_syntax::MAX_PAGE_JBIG2_WORK`. Those past it aren't drawn, like an image that can't /// be decoded. pub jbig2_work: Option, + /// Layer visibility overrides for print-preview rasters ([`PRINT_TAG`]), when the document + /// prints its layers differently from how they are shown (the Print event's auto state, ISO + /// 32000-2 §8.11.4.4): the preview shows the page as it prints. `None`: previews use `layers`. + pub print_layers: Option>>, } impl RenderConfig { @@ -355,16 +359,20 @@ fn render_page<'a>( config: &RenderConfig, req: RenderRequest, ) -> Result { - // Print previews share the viewer pool, but must use the output font policy. Keep the - // caller's cancellation/warning hooks, layers and annotation settings. A fresh cache is - // essential: hayro keys cached fonts by PDF dictionary, not by the font resolver, so the - // viewer cache could supply an installed face even after changing the resolver (and a - // cached missing font from print preview could blank the viewer on switching back). + // Print previews share the viewer pool, but must use the output font policy and the layers + // as they print (`print_layers`). Keep the caller's cancellation/warning hooks and annotation + // settings. A fresh cache is essential: hayro keys cached fonts by PDF dictionary, not by the + // font resolver, so the viewer cache could supply an installed face even after changing the + // resolver (and a cached missing font from print preview could blank the viewer on switching + // back); nothing drawn with the viewer's layers is reused either. let print_settings; let print_cache; - let (settings, cache) = if config.system_fonts && req.tag & PRINT_TAG != 0 { + let print = req.tag & PRINT_TAG != 0; + let print_layers = config.print_layers.as_ref().filter(|_| print); + let (settings, cache) = if (config.system_fonts && print) || print_layers.is_some() { print_settings = InterpreterSettings { font_resolver: RenderConfig { system_fonts: false, ..config.clone() }.settings().font_resolver, + ocg_overrides: print_layers.cloned().unwrap_or_else(|| settings.ocg_overrides.clone()), ..settings.clone() }; print_cache = RenderCache::new(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f891478f10..f917c3390b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8345,8 +8345,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = [] -evidence = ["crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf"] -notes = "live sheet preview with sheet navigation" +evidence = ["crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_applies_the_print_auto_state"] +notes = "live sheet preview with sheet navigation; layers are drawn as they print (the Print event's auto state, ISO 32000-2 §8.11.4.4)" [[feature]] id = "print.grayscale" @@ -8393,8 +8393,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/automation/tests/it/automation.rs::printing_through_tools", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render"] -notes = "Save as PDF from the dialog" +evidence = ["crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/automation/tests/it/automation.rs::printing_through_tools", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/engine/src/tests.rs::printing_keeps_hidden_layers_hidden", "crates/engine/src/tests.rs::printing_applies_the_print_auto_state", "crates/engine/src/tests.rs::a_layer_prints_only_if_every_recommendation_is_on"] +notes = "Save as PDF from the dialog. Layers print as shown in the Layers panel, then as the default configuration's auto state for the Print event sets them (/AS, /PrintState; ISO 32000-2 §8.11.4.4), vector or as image; the print copy keeps those states in /D without /AS or the layers' /Usage, and the dialog's preview shows them. A Print entry's Print, View and Export categories are read; Zoom, User and Language give no recommendation when printing." [[feature]] id = "print.print-pages-from-thumbnails"