diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 4015c1ea..657a7fda 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -103,7 +103,7 @@ that supply shaped glyphs do not need that shaper. Adapters that call `draw_text DeckCraft, also need `write-simple-text` when adopting this revision. `write-pdf` adds embedded-PDF support independently of text shaping. -The writer pins Krilla 0.8.2, with local unwind-cleanup and print color patches documented in +The writer pins Krilla 0.8.2, with local unwind-cleanup, print color and optional content patches documented in [`vendor/krilla/README.craft.md`](../../vendor/krilla/README.craft.md). Successful serialization uses the same backend version as before extraction. It provides: @@ -144,6 +144,18 @@ PDF/X; apps that claim conformance set `pdfx_version` and keep their own checks. settings needs the patched Krilla (`CRAFT_PRINT_COLOR`, see [`vendor/krilla/README.craft.md`](../../vendor/krilla/README.craft.md)). +### Optional content + +Drawing layers are written as optional content groups. `Document::add_optional_content_group(name, +visible)` returns an `OcgId`; `Surface::start_optional_content(id)` and +`Surface::end_optional_content()` mark the content between them, and nest independently of +tagged sections. Both return `KrillaError` on misuse instead of panicking, and a mark left open +when its stream finishes fails `Writer::finish`. The catalog lists every group in +`/OCProperties`, hidden ones in `/OFF`; `Document::set_optional_content_page_mode(true)` adds +`/PageMode /UseOC`. Optional content needs PDF 1.5 and fails PDF/A-1 validation (patch 12 in +[`vendor/krilla/README.craft.md`](../../vendor/krilla/README.craft.md), marker +`CRAFT_OPTIONAL_CONTENT`). + `Writer::document_mut` and `write::backend` deliberately expose Krilla's page, resource and painting APIs. The apps keep their document walks, pagination, font selection/licensing, outline fallbacks and warnings, colour policy, PDF/X postprocessing, tagging semantics, diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs index b52d5edc..3731a99b 100644 --- a/crates/pdf/src/write.rs +++ b/crates/pdf/src/write.rs @@ -8,10 +8,11 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; pub use krilla as backend; -// Require the root workspace patch; an unpatched backend can abort during unwind, and -// writes RGB blending groups and no print output intent. +// Require the root workspace patch; an unpatched backend can abort during unwind, writes +// RGB blending groups and no print output intent, and has no optional content. const _: bool = backend::CRAFT_UNWIND_POISONING; const _: bool = backend::CRAFT_PRINT_COLOR; +const _: bool = backend::CRAFT_OPTIONAL_CONTENT; pub mod print; diff --git a/crates/pdf/tests/optional_content.rs b/crates/pdf/tests/optional_content.rs new file mode 100644 index 00000000..38b77334 --- /dev/null +++ b/crates/pdf/tests/optional_content.rs @@ -0,0 +1,287 @@ +//! Optional content (layers): `Document::add_optional_content_group`, +//! `Surface::start_optional_content` and `Surface::end_optional_content`, read back with the +//! shared reader. +#![cfg(feature = "write")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::BTreeMap; +use std::sync::Arc; + +use backend::SerializeSettings; +use backend::configure::{Archival, PdfVersion, ValidationError}; +use backend::error::KrillaError; +use backend::geom::{PathBuilder, Rect, Size}; +use backend::optional_content::OcgId; +use backend::page::PageSettings; +use backend::paint::Fill; +use backend::surface::Surface; +use backend::tagging::ContentTag; +use craft_pdf::write::{self, Metadata, WriteError, Writer, backend}; +use hayro_syntax::object::{Array, Dict, Name, Stream, String as PdfString}; + +const NOTES: &str = "注記"; + +fn rect(surface: &mut Surface<'_>, x: f32) { + let mut path = PathBuilder::new(); + path.push_rect(Rect::from_xywh(x, 10.0, 20.0, 20.0).unwrap()); + surface.set_fill(Some(Fill::default())); + surface.draw_path(&path.finish().unwrap()); +} + +fn page_settings() -> PageSettings { + PageSettings::new(Size::from_wh(200.0, 60.0).unwrap()) +} + +/// Two pages and three groups: "Walls" (shown), the hidden notes layer and "Unused" (shown, +/// on no page). Page 1 marks one rect per layer, plus a mark inside an isolated group. Page 2 +/// nests the notes inside the walls and a tagged section inside the notes. +fn layered(settings: SerializeSettings, page_mode: bool) -> Result, WriteError> { + let mut writer = Writer::new(settings, Metadata::default()); + let document = writer.document_mut(); + let walls = document.add_optional_content_group("Walls", true); + let notes = document.add_optional_content_group(NOTES, false); + document.add_optional_content_group("Unused", true); + document.set_optional_content_page_mode(page_mode); + { + let mut page = document.start_page_with(page_settings()); + let mut surface = page.surface(); + surface.start_optional_content(walls).unwrap(); + rect(&mut surface, 0.0); + surface.end_optional_content().unwrap(); + surface.start_optional_content(notes).unwrap(); + rect(&mut surface, 30.0); + surface.end_optional_content().unwrap(); + surface.push_isolated(); + surface.start_optional_content(notes).unwrap(); + rect(&mut surface, 60.0); + surface.end_optional_content().unwrap(); + surface.pop(); + surface.finish(); + page.finish(); + } + { + let mut page = document.start_page_with(page_settings()); + let mut surface = page.surface(); + surface.start_optional_content(walls).unwrap(); + rect(&mut surface, 0.0); + surface.start_optional_content(notes).unwrap(); + surface.start_tagged(ContentTag::Other); + rect(&mut surface, 30.0); + surface.end_tagged(); + surface.end_optional_content().unwrap(); + surface.end_optional_content().unwrap(); + surface.finish(); + page.finish(); + } + writer.finish() +} + +fn text(string: &PdfString<'_>) -> String { + let bytes = string.as_bytes(); + match bytes.strip_prefix(&[0xFE, 0xFF]) { + Some(utf16) => String::from_utf16(&utf16.chunks(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect::>()).unwrap(), + None => String::from_utf8(bytes.to_vec()).unwrap(), + } +} + +fn ocg_name(ocg: &Dict<'_>) -> String { + assert_eq!(ocg.get::>(b"Type".as_slice()).unwrap().as_str(), "OCG"); + text(&ocg.get::>(b"Name".as_slice()).unwrap()) +} + +fn names(array: &Array<'_>) -> Vec { + array.iter::>().map(|ocg| ocg_name(&ocg)).collect() +} + +fn open(bytes: &[u8]) -> hayro_syntax::Pdf { + hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap() +} + +fn root(pdf: &hayro_syntax::Pdf) -> Dict<'_> { + pdf.xref().get::>(pdf.xref().root_id()).unwrap() +} + +/// The `/OC /name BDC` names in a content stream, after checking that every `BDC` has its +/// `EMC` and that nothing ends before it starts. +fn oc_marks(content: &[u8]) -> Vec { + // pdf-writer omits the space before a name and around dictionaries. + let content = String::from_utf8_lossy(content).replace('/', " /").replace("<<", " << ").replace(">>", " >> "); + let tokens: Vec<&str> = content.split_whitespace().collect(); + let mut depth = 0usize; + let mut marks = Vec::new(); + for (i, token) in tokens.iter().enumerate() { + match *token { + "BDC" => { + depth += 1; + if i >= 2 && tokens[i - 2] == "/OC" { + marks.push(tokens[i - 1].trim_start_matches('/').to_owned()); + } + } + "EMC" => depth = depth.checked_sub(1).expect("EMC without BDC"), + _ => {} + } + } + assert_eq!(depth, 0, "unbalanced marked content"); + marks +} + +/// Each `/Properties` entry of a resource dictionary, as resource name to group name. +fn properties(resources: &Dict<'_>) -> BTreeMap { + resources.keys().map(|key| (key.as_str().to_owned(), ocg_name(&resources.get::>(key.as_ref()).unwrap()))).collect() +} + +/// Per page: the layer of each optional content mark, in content order. +fn page_layers(bytes: &[u8]) -> Vec> { + let pdf = open(bytes); + pdf.pages() + .iter() + .map(|page| { + let map = properties(&page.resources().properties); + oc_marks(page.page_stream().unwrap()) + .iter() + .map(|mark| map.get(mark).unwrap_or_else(|| panic!("/{mark} is not in /Properties")).clone()) + .collect() + }) + .collect() +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn groups_are_listed_in_the_catalog_with_hidden_ones_off() { + let bytes = layered(SerializeSettings::default(), false).unwrap(); + let pdf = open(&bytes); + let root = root(&pdf); + let properties = root.get::>(b"OCProperties".as_slice()).unwrap(); + let all = ["Walls", NOTES, "Unused"].map(str::to_owned).to_vec(); + assert_eq!(names(&properties.get::>(b"OCGs".as_slice()).unwrap()), all); + let config = properties.get::>(b"D".as_slice()).unwrap(); + assert_eq!(text(&config.get::>(b"Name".as_slice()).unwrap()), "Layers"); + assert_eq!(names(&config.get::>(b"Order".as_slice()).unwrap()), all); + assert_eq!(names(&config.get::>(b"OFF".as_slice()).unwrap()), vec![NOTES.to_owned()]); + assert!(root.get::>(b"PageMode".as_slice()).is_none()); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn page_mode_use_oc_is_opt_in() { + let bytes = layered(SerializeSettings::default(), true).unwrap(); + let pdf = open(&bytes); + assert_eq!(root(&pdf).get::>(b"PageMode".as_slice()).unwrap().as_str(), "UseOC"); + + // Without groups there is nothing to show. + let mut writer = Writer::new(SerializeSettings::default(), Metadata::default()); + writer.document_mut().set_optional_content_page_mode(true); + let bytes = writer.finish().unwrap(); + let pdf = open(&bytes); + assert!(root(&pdf).get::>(b"PageMode".as_slice()).is_none()); + assert!(root(&pdf).get::>(b"OCProperties".as_slice()).is_none()); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn marks_name_groups_in_their_own_stream_resources() { + let bytes = layered(SerializeSettings::default(), false).unwrap(); + assert_eq!(page_layers(&bytes), vec![vec!["Walls".to_owned(), NOTES.to_owned()], vec!["Walls".to_owned(), NOTES.to_owned()]]); + + // Page 2 holds a tagged section inside the nested marks: three BDCs, balanced. + let pdf = open(&bytes); + let page = &pdf.pages()[1]; + let content = String::from_utf8_lossy(page.page_stream().unwrap()).into_owned(); + assert_eq!(content.matches("BDC").count(), 3); + + // The isolated group on page 1 is a form XObject with its own `/Properties`. + let page = &pdf.pages()[0]; + let x_objects = &page.resources().x_objects; + let forms: Vec> = x_objects.keys().filter_map(|key| x_objects.get::>(key.as_ref())).collect(); + assert_eq!(forms.len(), 1); + let resources = forms[0].dict().get::>(b"Resources".as_slice()).unwrap(); + let map = properties(&resources.get::>(b"Properties".as_slice()).unwrap()); + let decoded = forms[0].decoded().unwrap(); + let layers: Vec = oc_marks(&decoded).iter().map(|mark| map[mark].clone()).collect(); + assert_eq!(layers, vec![NOTES.to_owned()]); +} + +fn validation_errors(version: PdfVersion, archival: Option) -> Vec { + let settings = SerializeSettings { configuration: write::configuration(version, archival).unwrap(), ..Default::default() }; + match layered(settings, false) { + Ok(_) => Vec::new(), + Err(WriteError::Serialization(error)) => match *error { + KrillaError::Validation(errors) => errors.into_iter().map(|(error, _)| error).collect(), + other => panic!("expected validation errors, got {other:?}"), + }, + Err(other) => panic!("expected validation errors, got {other:?}"), + } +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn optional_content_is_a_pdf_a1_error() { + let is_oc = |error: &ValidationError| matches!(error, ValidationError::OptionalContent(_)); + // The metadata is missing, so every archival export fails; only PDF/A-1 names layers. + assert!(validation_errors(PdfVersion::Pdf14, Some(Archival::A1_B)).iter().any(is_oc)); + assert!(!validation_errors(PdfVersion::Pdf17, Some(Archival::A2_B)).iter().any(is_oc)); + assert!(validation_errors(PdfVersion::Pdf14, None).is_empty()); +} + +fn one_page(draw: impl FnOnce(&mut Surface<'_>, OcgId)) -> Result, WriteError> { + let mut writer = Writer::new(SerializeSettings::default(), Metadata::default()); + let layer = writer.document_mut().add_optional_content_group("Layer", true); + { + let mut page = writer.document_mut().start_page_with(page_settings()); + let mut surface = page.surface(); + draw(&mut surface, layer); + surface.finish(); + page.finish(); + } + writer.finish() +} + +fn serialization_error(result: Result, WriteError>) -> KrillaError { + match result { + Err(WriteError::Serialization(error)) => *error, + other => panic!("expected a serialization error, got {:?}", other.map(|bytes| bytes.len())), + } +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn misuse_returns_errors_instead_of_panicking() { + // An end without a start, and an end that would close a tagged section started inside + // the mark, are refused and write nothing; the document stays valid. + let bytes = one_page(|surface, layer| { + assert!(matches!(surface.end_optional_content(), Err(KrillaError::UnbalancedOptionalContent(_)))); + surface.start_optional_content(layer).unwrap(); + surface.start_tagged(ContentTag::Other); + assert!(matches!(surface.end_optional_content(), Err(KrillaError::UnbalancedOptionalContent(_)))); + surface.end_tagged(); + surface.end_optional_content().unwrap(); + }) + .unwrap(); + assert_eq!(page_layers(&bytes), vec![vec!["Layer".to_owned()]]); + + // A group of another document. + let mut other = backend::Document::new(); + let foreign = (0..4).map(|i| other.add_optional_content_group(format!("{i}"), true)).last().unwrap(); + one_page(|surface, _| { + assert!(matches!(surface.start_optional_content(foreign), Err(KrillaError::UnknownOptionalContentGroup(_)))); + }) + .unwrap(); + + // Marks left open when the page or a group finishes, and a tagged section that ends + // inside a mark started within it, fail the document. + let unbalanced = |draw: fn(&mut Surface<'_>, OcgId)| { + assert!(matches!(serialization_error(one_page(draw)), KrillaError::UnbalancedOptionalContent(_))); + }; + unbalanced(|surface, layer| surface.start_optional_content(layer).unwrap()); + unbalanced(|surface, layer| { + surface.push_isolated(); + surface.start_optional_content(layer).unwrap(); + surface.pop(); + }); + unbalanced(|surface, layer| { + surface.start_tagged(ContentTag::Other); + surface.start_optional_content(layer).unwrap(); + surface.end_tagged(); + assert!(surface.end_optional_content().is_err()); + }); +} diff --git a/vendor/krilla/README.craft.md b/vendor/krilla/README.craft.md index 794b4c2b..3b55e756 100644 --- a/vendor/krilla/README.craft.md +++ b/vendor/krilla/README.craft.md @@ -21,6 +21,7 @@ itself omits those texts. Its examples and four CC0 ICC profiles are retained wi | `patches/09-rendition-class.patch` | Master rendition in XMP | `chunk_container.rs` | | `patches/10-mesh-gradients.patch` | Mesh gradients | `content.rs`, `error.rs`, `graphics/{mask,paint,shading_function}.rs`, `serialize.rs`, `surface.rs` | | `patches/11-png-decode-cap.patch` | PNG decode cap | `graphics/image.rs` | +| `patches/12-optional-content.patch` | Optional content | `chunk_container.rs`, `configure/{PDF_A1,PDF_A2,PDF_A4,PDF_UA1}.md`, `configure/validate.rs`, `content.rs`, `error.rs`, `graphics/{mod,optional_content}.rs`, `lib.rs`, `resource.rs`, `serialize.rs`, `surface.rs` | Each patch applies with `patch -p1` from this directory, in numeric order, to the pristine crate. `SOURCE.json` records, for every retained file, the upstream hash (`upstream_sha256`) @@ -300,3 +301,38 @@ image into are reserved fallibly too, and a failure there is an image error. returns an error when they overflow or don't match the data, where upstream multiplied in `u32` and asserted while writing (32 DeviceN components at 16 bits overflow at about 67 megapixels). + +## 12: optional content + +Krilla 0.8.2 can't write optional content groups (layers), and it has no hook for raw content +operators or extra catalog and resource entries, so CADCraft's drawing layers can't be added +from outside. The new module `graphics/optional_content.rs` holds the feature; the other files +get small hooks. + +- `Document::add_optional_content_group(name, visible) -> OcgId` writes + `<< /Type /OCG /Name (…) >>` once (a text string, so any Unicode name works). +- `Surface::start_optional_content(OcgId)` and `Surface::end_optional_content()`, both + returning `KrillaResult<()>`, write `/OC /ocN BDC` … `EMC`, with `/ocN` in the current + stream's `/Properties` resources (a new resource kind beside `/ExtGState`). Inside a group, + opacity or mask, the entry goes in that form's resources. +- Marks nest, and nest independently of tagging, whose single marked-content level is + unchanged. An end with nothing open, or one that would close a tagged section started inside + the mark, returns `KrillaError::UnbalancedOptionalContent` and writes nothing. An `OcgId` index + the document doesn't have returns `KrillaError::UnknownOptionalContentGroup`. Marks still open + when their stream finishes (surface finish, or a pop of a group, opacity or mask), and a + tagged section ended while a mark started inside it is open, are closed with `EMC` and make + `Document::finish` fail with `KrillaError::UnbalancedOptionalContent`. Nothing panics. +- When any group exists, the catalog gets `/OCProperties << /OCGs [all] /D << /Name (Layers) + /Order [all] /OFF [hidden] >> >>`, listing groups that no page uses too; `/OFF` is omitted + when every group is shown. `Document::set_optional_content_page_mode(true)` adds + `/PageMode /UseOC`, written only when groups exist. +- Validation: adding a group registers `ValidationError::OptionalContent`, which PDF/A-1 + prohibits (6.1.13), and below PDF 1.5 also + `ValidationError::RequiresNewerPdfVersion(VersionedFeature::OptionalContent)`, which PDF/UA-1 + prohibits. Without a validator a PDF 1.4 document still gets the groups, as with krilla's + other versioned features. The PDF/A-2, A-4 and UA-1 ledgers record that the one + configuration has a `Name`, an `Order` listing every group and no `AS`. + +`CRAFT_OPTIONAL_CONTENT` marks the patch; craft-pdf requires it at compile time. + +Tests: `crates/pdf/tests/optional_content.rs`. diff --git a/vendor/krilla/SOURCE.json b/vendor/krilla/SOURCE.json index bcd1560b..ce4d6ed2 100644 --- a/vendor/krilla/SOURCE.json +++ b/vendor/krilla/SOURCE.json @@ -72,17 +72,17 @@ { "path": "src/chunk_container.rs", "upstream_sha256": "7317162f659d7c756ee170146d360e2c757cef953dc5a2a62f32daea90fc0c3f", - "sha256": "51903c82a6e81d806b9a17d08a3be758ec0682010c4c0d22db9f8429a10e8536" + "sha256": "b6ddeb3cb612feedb9b4b869a421bb3f9a56a8407f3294c3401485df65357af6" }, { "path": "src/configure/PDF_A1.md", "upstream_sha256": "e6ea127125ec0984f3fcd57f9886fc960aa90c9f751dd59cd61fe9cbc2cdb903", - "sha256": "f647baeb6bd3152c688cefb71462ba05e969e0a9572c3f95779b2b18bf384220" + "sha256": "ce55787bf6f984552492f771d2dc500cb730038f9ba19355bb466c680384b54b" }, { "path": "src/configure/PDF_A2.md", "upstream_sha256": "47c87d3b29aff6e87703ba6f6021a2cfa88b4faf7f2c35101719b8b9f1bb9705", - "sha256": "af366de5b86fa3995bf08b1f71d9d901acbf2130560fcdb537cac75b1c65b599" + "sha256": "90799a209489944266918690a59c7053f8b14c880e9caf23069ed601c539855f" }, { "path": "src/configure/PDF_A3.md", @@ -92,12 +92,12 @@ { "path": "src/configure/PDF_A4.md", "upstream_sha256": "27710cf95e6ef83714cfa372e09796c9ec0d5455ceb31c101a7dccfdd8ee1b5b", - "sha256": "f3a6ed247033124a8ad583e665e532e736841f2997974425688ced2c156fc6c6" + "sha256": "2e68d89bfc0f3f020ee96da85a3b9c59f643d3fc3119e35798e5eaf90ea5254e" }, { "path": "src/configure/PDF_UA1.md", "upstream_sha256": "f3932749a56db38eb302e05b59b6a18312b9eda2e473b2b6e99645a29d3dc670", - "sha256": "f3932749a56db38eb302e05b59b6a18312b9eda2e473b2b6e99645a29d3dc670" + "sha256": "64d0c64fd15e30c0d3b786d4ebd2928d219a529114e39d6c26e5680cdc8a1ea3" }, { "path": "src/configure/README.md", @@ -112,7 +112,7 @@ { "path": "src/configure/validate.rs", "upstream_sha256": "7e2a0ac6d7ecaac1ed2158cdc9e94411c2499c98e541ffb471f2bb06d5069c33", - "sha256": "65de778c777253d369917f002809672deac4f5f579200d8194778fc6c94d9ea9" + "sha256": "3c281fc219faea71de989bdeb571c676596dd1050ec64113c10cf9a357ff0100" }, { "path": "src/configure/version.rs", @@ -122,7 +122,7 @@ { "path": "src/content.rs", "upstream_sha256": "9e59b2c77b73b36caedacfe80894d1cce7fd6fe6f7438ea24fda2019391a980c", - "sha256": "ba6dfad6d5ef4bae7f960ab2d3fe2673fa40394edde261005271a24c3b1c41df" + "sha256": "e477e77d7209bc837b045f448b502fcce81047d91b8b27ab49e26a9410e74322" }, { "path": "src/data.rs", @@ -137,7 +137,7 @@ { "path": "src/error.rs", "upstream_sha256": "656b260bd99d250535099f17f2db8e6e6135e72c74fb6dff304371db9f646be5", - "sha256": "fdc6eaecce55bfd689f5034b57e9afaa3a3a21ea0444b95c409ac41190b61a4c" + "sha256": "fadd496a1d3e782ef8dfc40a59bc0c070406846d8a24b8b21eaa6130d4c58f5a" }, { "path": "src/geom.rs", @@ -187,7 +187,12 @@ { "path": "src/graphics/mod.rs", "upstream_sha256": "9f82d56c24f2fd51736e68b9c182cae90617816d00e814102f45996705b13224", - "sha256": "b6dcb248a344f818f63b669f4fa87930147c5c204c7351866c806a36aab94c3c" + "sha256": "19fc4fc3476ef3882b9ff9d654f12ee5313d4d6a5fddeeb6ceccdc176a15f6db" + }, + { + "path": "src/graphics/optional_content.rs", + "upstream_sha256": null, + "sha256": "6ac9d9276434a8e6189dc8947a557303feea15353588b86ee09b4e97cadeddc5" }, { "path": "src/graphics/paint.rs", @@ -287,7 +292,7 @@ { "path": "src/lib.rs", "upstream_sha256": "7533b14f1cef6749c961a7c1ba95d3274d6584e60febfccc03b9d63b4dc2abeb", - "sha256": "bdeede1bb2e548e702aa1b4007d5b8cde22c63d78179270c7e4c12ec7ac0ff57" + "sha256": "7263cddc8304d24b018e9552f5764358ad642198d4e79d75df568ceee213a9be" }, { "path": "src/num.rs", @@ -307,12 +312,12 @@ { "path": "src/resource.rs", "upstream_sha256": "56a062eb37e5da88a78a129c6391103caddf7a2c7ca9fcfdb577319412c97107", - "sha256": "f451ced9b9cbdc4d15d1c1aa909c1aa0f1e8fc75d31ace86bb01815ec9166a95" + "sha256": "6062e553b90733b66207d27f98383f7458cc72048596590dc5fbde487c3a9099" }, { "path": "src/serialize.rs", "upstream_sha256": "4f2c439dbd20006eea112e056b2f1a5ba98a1ede9227a45f66a6df46fb1a07e8", - "sha256": "ff5a2f067010b729b7f4e48dcdadf1d648b6acf67fd59eefd51c2a3a812b7ef9" + "sha256": "99cda1c62910281776726de63b1600830484be0972b8d60adbca2d9b341cd49b" }, { "path": "src/stream.rs", @@ -322,7 +327,7 @@ { "path": "src/surface.rs", "upstream_sha256": "94de0a0384fc69c9785c6595b3f2c7ed8319635c901b8dc12894e252ab3ef8da", - "sha256": "b566fdb63806112da32854eccd37c4cf2b9de5ae04009addeb27e981a1bc23c4" + "sha256": "67e0f7db850084692132271364259fc42597f4a0f699ffad5d74c59834976040" }, { "path": "src/text/cid.rs", diff --git a/vendor/krilla/patches/12-optional-content.patch b/vendor/krilla/patches/12-optional-content.patch new file mode 100644 index 00000000..c211ad62 --- /dev/null +++ b/vendor/krilla/patches/12-optional-content.patch @@ -0,0 +1,709 @@ +diff --git a/src/chunk_container.rs b/src/chunk_container.rs +index 978eacc..023784f 100644 +--- a/src/chunk_container.rs ++++ b/src/chunk_container.rs +@@ -55,6 +55,7 @@ pub(crate) struct NonStreamChunks { + pub(crate) patterns: Chunk, + pub(crate) pages: Chunk, + pub(crate) embedded_files: Chunk, ++ pub(crate) optional_content: Chunk, + } + + impl ChunkContainer { +@@ -93,6 +94,7 @@ impl ChunkContainer { + patterns: sc.new_chunk(), + pages: sc.new_chunk(), + embedded_files: sc.new_chunk(), ++ optional_content: sc.new_chunk(), + }, + } + } +@@ -345,6 +347,9 @@ impl ChunkContainer { + } + } + ++ sc.optional_content ++ .write_catalog_entries(&mut catalog, &remapper); ++ + catalog.finish(); + } + +@@ -441,6 +446,7 @@ impl Visit for NonStreamChunks { + self.patterns.visit(sc, f)?; + self.pages.visit(sc, f)?; + self.embedded_files.visit(sc, f)?; ++ self.optional_content.visit(sc, f)?; + + Ok(()) + } +diff --git a/src/configure/PDF_A1.md b/src/configure/PDF_A1.md +index 06b5243..a4c7bc3 100644 +--- a/src/configure/PDF_A1.md ++++ b/src/configure/PDF_A1.md +@@ -58,7 +58,8 @@ with an even number of characters and without whitespaces. 🟢 + DeviceN colorants is never reached. 🟢 + - krilla only uses u16 for CIDs. 🟢 + +-6.1.13: krilla does not support optional content. 🔵 ++6.1.13: krilla fails export if an optional content group is added ++(`ValidationError::OptionalContent`), so the catalog never has `OCProperties`. 🟢 + + ## 6.2 Graphics + +diff --git a/src/configure/PDF_A2.md b/src/configure/PDF_A2.md +index 7e3fd8f..69acde5 100644 +--- a/src/configure/PDF_A2.md ++++ b/src/configure/PDF_A2.md +@@ -222,7 +222,9 @@ krilla prohibits embedding files in this export mode. 🟢 + + # 6.9 Optional content + +-krilla does not support optional content. 🔵 ++- krilla writes every optional content group with a `Name`. 🟢 ++- krilla writes one optional content configuration, `D`, with the `Name` `Layers`, an `Order` ++ array that lists every group, and no `AS` key. 🟢 + + # 6.10 Use of alternate presentations and transitions + +diff --git a/src/configure/PDF_A4.md b/src/configure/PDF_A4.md +index aac8008..be1314b 100644 +--- a/src/configure/PDF_A4.md ++++ b/src/configure/PDF_A4.md +@@ -191,7 +191,9 @@ See `README.md` for the meaning of each color. + - krilla straight out forbids embedding files in this export mode. 🟢 + + ## 6.10 Optional content +-- krilla does not support optional content. 🟢 ++- krilla writes every optional content group with a `Name`. 🟢 ++- krilla writes one optional content configuration, `D`, with the `Name` `Layers`, an `Order` ++ array that lists every group, and no `AS` key. 🟢 + + ## 6.11 Use of alternate presentations and transitions + - krilla does not use the `AlternatePresentations` entry. 🟢 +diff --git a/src/configure/PDF_UA1.md b/src/configure/PDF_UA1.md +index 16959bb..80449b8 100644 +--- a/src/configure/PDF_UA1.md ++++ b/src/configure/PDF_UA1.md +@@ -87,7 +87,7 @@ See `README.md` for the meaning of each color. + - krilla always generates an ID for notes. 🟢 + + # 7.10 Optional content +-- krilla does currently not support optional content. 🔵 ++- krilla writes its one optional content configuration, `D`, with a `Name`. 🟢 + + # 7.11 Embedded files + - krilla supports embedded files, and requires a description in this export mode. 🟢 +diff --git a/src/configure/validate.rs b/src/configure/validate.rs +index b0a8f03..fb429ce 100644 +--- a/src/configure/validate.rs ++++ b/src/configure/validate.rs +@@ -159,6 +159,12 @@ pub enum ValidationError { + EmbeddedPDF(Option), + /// A feature only available in a later PDF version was required. + RequiresNewerPdfVersion(VersionedFeature, Option), ++ /// The PDF contains optional content groups, which PDF/A-1 forbids. ++ /// ++ /// Occurs if [`Document::add_optional_content_group`] was called. ++ /// ++ /// [`Document::add_optional_content_group`]: crate::Document::add_optional_content_group ++ OptionalContent(Option), + } + + /// Features that may require a later PDF version than the current one. +@@ -170,6 +176,8 @@ pub enum VersionedFeature { + HeaderFooterArtifactSubtypes, + /// Scope attribute for table header cells. + TableHeaderScope, ++ /// Optional content groups (layers). ++ OptionalContent, + } + + impl VersionedFeature { +@@ -179,6 +187,7 @@ impl VersionedFeature { + VersionedFeature::StructureOrderTabbing => PdfVersion::Pdf15, + VersionedFeature::HeaderFooterArtifactSubtypes => PdfVersion::Pdf17, + VersionedFeature::TableHeaderScope => PdfVersion::Pdf15, ++ VersionedFeature::OptionalContent => PdfVersion::Pdf15, + } + } + } +@@ -566,7 +575,8 @@ impl Archival { + | ValidationError::ImageInterpolation(_) + | ValidationError::PdfxVersionWithoutExtensionSchema + | ValidationError::EmbeddedFile(EmbedError::Existence, _) +- | ValidationError::EmbeddedPDF(_), ++ | ValidationError::EmbeddedPDF(_) ++ | ValidationError::OptionalContent(_), + ) => true, + // Allowed under all PDF/A-1 profiles. + ( +@@ -582,7 +592,8 @@ impl Archival { + | ValidationError::RequiresNewerPdfVersion( + VersionedFeature::HeaderFooterArtifactSubtypes + | VersionedFeature::StructureOrderTabbing +- | VersionedFeature::TableHeaderScope, ++ | VersionedFeature::TableHeaderScope ++ | VersionedFeature::OptionalContent, + _, + ), + ) => false, +@@ -628,9 +639,11 @@ impl Archival { + | ValidationError::RequiresNewerPdfVersion( + VersionedFeature::HeaderFooterArtifactSubtypes + | VersionedFeature::StructureOrderTabbing +- | VersionedFeature::TableHeaderScope, ++ | VersionedFeature::TableHeaderScope ++ | VersionedFeature::OptionalContent, + _, +- ), ++ ) ++ | ValidationError::OptionalContent(_), + ) => false, + // Forbidden under PDF/A-2 but allowed under PDF/A-3. + ( +@@ -707,9 +720,11 @@ impl Archival { + | ValidationError::RequiresNewerPdfVersion( + VersionedFeature::HeaderFooterArtifactSubtypes + | VersionedFeature::StructureOrderTabbing +- | VersionedFeature::TableHeaderScope, ++ | VersionedFeature::TableHeaderScope ++ | VersionedFeature::OptionalContent, + _, +- ), ++ ) ++ | ValidationError::OptionalContent(_), + ) => false, + // Forbidden under PDF/A-4 but allowed under other PDF/A-4 profiles. + ( +@@ -1133,7 +1148,8 @@ impl Accessibility { + | ValidationError::RequiresNewerPdfVersion( + VersionedFeature::HeaderFooterArtifactSubtypes + | VersionedFeature::StructureOrderTabbing +- | VersionedFeature::TableHeaderScope, ++ | VersionedFeature::TableHeaderScope ++ | VersionedFeature::OptionalContent, + _, + ), + ) => true, +@@ -1159,7 +1175,8 @@ impl Accessibility { + EmbedError::Existence | EmbedError::MissingDate | EmbedError::MissingMimeType, + _, + ) +- | ValidationError::MissingDocumentDate, ++ | ValidationError::MissingDocumentDate ++ | ValidationError::OptionalContent(_), + ) => false, + } + } +diff --git a/src/content.rs b/src/content.rs +index 26ad665..68779d6 100644 +--- a/src/content.rs ++++ b/src/content.rs +@@ -22,6 +22,7 @@ use crate::graphics::graphics_state::{ExtGState, GraphicsStates}; + #[cfg(feature = "raster-images")] + use crate::graphics::image::Image; + use crate::graphics::mask::Mask; ++use crate::graphics::optional_content::{self, OpenMarks}; + use crate::graphics::paint::{Fill, FillRule, InnerPaint, LineCap, LineJoin, Paint, Stroke}; + use crate::graphics::shading_function::{ + GradientProperties, GradientPropertiesExt, ShadingFunction, +@@ -42,8 +43,8 @@ use crate::text::{Glyph, GlyphId}; + use crate::util::{calculate_stroke_bbox, NameExt}; + + pub(crate) struct ContentBuilder { +- rd_builder: ResourceDictionaryBuilder, +- content: Content, ++ pub(crate) rd_builder: ResourceDictionaryBuilder, ++ pub(crate) content: Content, + validation_errors: HashSet, + root_transform: Transform, + graphics_states: GraphicsStates, +@@ -57,6 +58,7 @@ pub(crate) struct ContentBuilder { + /// A temporary buffer that's reused across the builder. + scratch: Vec, + pub(crate) active_marked_content: bool, ++ pub(crate) optional_content: OpenMarks, + } + + /// Stores either a device-specific color space, +@@ -84,6 +86,7 @@ impl ContentBuilder { + bbox: None, + scratch: Vec::new(), + active_marked_content: false, ++ optional_content: OpenMarks::default(), + } + } + +@@ -96,7 +99,8 @@ impl ContentBuilder { + } + } + +- pub(crate) fn finish(self, sc: &mut SerializeContext) -> Stream { ++ pub(crate) fn finish(mut self, sc: &mut SerializeContext) -> Stream { ++ optional_content::close_open_marks(&mut self, sc); + let buf = self.content.finish(); + sc.register_limits(buf.limits()); + +@@ -153,6 +157,7 @@ impl ContentBuilder { + panic!("can't end marked content when none has been started"); + } + ++ self.optional_content.tag_ended(); + self.content.end_marked_content(); + self.active_marked_content = false; + } +diff --git a/src/error.rs b/src/error.rs +index 4c582ae..a19ebf6 100644 +--- a/src/error.rs ++++ b/src/error.rs +@@ -34,6 +34,15 @@ pub enum KrillaError { + /// + /// [`MeshGradient`]: crate::paint::MeshGradient + MixedMeshColorSpaces(Option), ++ /// [`Surface::start_optional_content`] was given an [`OcgId`] that this document did ++ /// not create. ++ /// ++ /// [`Surface::start_optional_content`]: crate::surface::Surface::start_optional_content ++ /// [`OcgId`]: crate::optional_content::OcgId ++ UnknownOptionalContentGroup(Option), ++ /// Optional content marks were not balanced: an end without a start, a mark still open ++ /// when its stream finished, or a mark overlapping a tagged section. ++ UnbalancedOptionalContent(Option), + /// An error while attempting to embed a font. + Font(Font, String), + /// A list of validation errors. Can only occur if you set the `validator` in +@@ -79,6 +88,14 @@ impl Display for KrillaError { + f.write_str("a mesh gradient mixes color spaces that can't be written as one")?; + write_location(f, *location) + } ++ KrillaError::UnknownOptionalContentGroup(location) => { ++ f.write_str("an optional content group from another document was used")?; ++ write_location(f, *location) ++ } ++ KrillaError::UnbalancedOptionalContent(location) => { ++ f.write_str("optional content marks are not balanced")?; ++ write_location(f, *location) ++ } + KrillaError::Font(_, message) => write!(f, "failed to embed font: {message}"), + KrillaError::Validation(errors) => { + let count = errors.len(); +diff --git a/src/graphics/mod.rs b/src/graphics/mod.rs +index 57e9406..3864d6e 100644 +--- a/src/graphics/mod.rs ++++ b/src/graphics/mod.rs +@@ -6,6 +6,7 @@ pub mod icc; + #[cfg(feature = "raster-images")] + pub mod image; + pub mod mask; ++pub mod optional_content; + pub mod paint; + pub(crate) mod device_n; + pub(crate) mod separation; +diff --git a/src/graphics/optional_content.rs b/src/graphics/optional_content.rs +new file mode 100644 +index 0000000..45c135f +--- /dev/null ++++ b/src/graphics/optional_content.rs +@@ -0,0 +1,266 @@ ++//! Optional content (layers). ++//! ++//! An optional content group (OCG) is a named layer that a viewer can show or hide. Create ++//! groups on the [`Document`] with [`Document::add_optional_content_group`], then mark ++//! content on any surface with [`Surface::start_optional_content`] and ++//! [`Surface::end_optional_content`]. ++//! ++//! Every group is written once as `<< /Type /OCG /Name (…) >>`, and the catalog gets ++//! `/OCProperties` listing all groups, also those that no page uses, with the hidden ones ++//! in the default configuration's `/OFF` array. Marked content is written as ++//! `/OC /ocN BDC … EMC`, with `/ocN` registered in the `/Properties` resources of the ++//! stream that holds it. ++//! ++//! Optional content marks nest independently of tagged sections, but they must not overlap ++//! them: a tagged section started inside a mark ends before the mark does, and a mark started ++//! inside a tagged section ends before the section does. Marks are balanced per stream: a mark ++//! started before [`Surface::push_group`] (or any other push that starts a new stream, such as ++//! an opacity or a mask) can't be ended inside it. ++//! ++//! Optional content needs PDF 1.5 and is forbidden in PDF/A-1. ++//! ++//! [`Surface::push_group`]: crate::surface::Surface::push_group ++ ++use std::collections::HashMap; ++ ++use pdf_writer::types::PageMode; ++use pdf_writer::writers::{self, Catalog}; ++use pdf_writer::{Dict, Finish, Name, Ref, TextStr}; ++ ++use crate::configure::validate::VersionedFeature; ++use crate::configure::ValidationError; ++use crate::content::ContentBuilder; ++use crate::document::Document; ++use crate::error::{KrillaError, KrillaResult}; ++use crate::resource::{Resource, ResourceDictionaryBuilder, ResourceMapper}; ++use crate::serialize::SerializeContext; ++use crate::surface::{Location, Surface}; ++use crate::util::NameExt; ++ ++/// An optional content group of a [`Document`], returned by ++/// [`Document::add_optional_content_group`]. ++/// ++/// An id is only meaningful for the document that created it. ++#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] ++pub struct OcgId(usize); ++ ++struct Group { ++ ref_: Ref, ++ visible: bool, ++} ++ ++/// The document's optional content groups and settings. ++#[derive(Default)] ++pub(crate) struct OptionalContent { ++ groups: Vec, ++ page_mode: bool, ++ /// Where the first unbalanced or overlapping mark was found; ++ /// [`SerializeContext::finish`] fails with [`KrillaError::UnbalancedOptionalContent`]. ++ pub(crate) misuse: Option>, ++} ++ ++impl OptionalContent { ++ fn group_ref(&self, id: OcgId) -> Option { ++ self.groups.get(id.0).map(|g| g.ref_) ++ } ++ ++ fn record_misuse(&mut self, location: Option) { ++ self.misuse.get_or_insert(location); ++ } ++ ++ /// Writes `/OCProperties`, and `/PageMode /UseOC` when requested, if any group exists. ++ pub(crate) fn write_catalog_entries( ++ &self, ++ catalog: &mut Catalog<'_>, ++ remapper: &HashMap, ++ ) { ++ if self.groups.is_empty() { ++ return; ++ } ++ ++ let all: Vec = self ++ .groups ++ .iter() ++ .filter_map(|g| remapper.get(&g.ref_).copied()) ++ .collect(); ++ let hidden: Vec = self ++ .groups ++ .iter() ++ .filter(|g| !g.visible) ++ .filter_map(|g| remapper.get(&g.ref_).copied()) ++ .collect(); ++ ++ let mut properties = catalog.insert(Name(b"OCProperties")).dict(); ++ properties ++ .insert(Name(b"OCGs")) ++ .array() ++ .items(all.iter().copied()); ++ let mut config = properties.insert(Name(b"D")).dict(); ++ config.pair(Name(b"Name"), TextStr("Layers")); ++ config ++ .insert(Name(b"Order")) ++ .array() ++ .items(all.iter().copied()); ++ if !hidden.is_empty() { ++ config.insert(Name(b"OFF")).array().items(hidden); ++ } ++ config.finish(); ++ properties.finish(); ++ ++ if self.page_mode { ++ catalog.page_mode(PageMode::UseOC); ++ } ++ } ++} ++ ++impl Document { ++ /// Add an optional content group (a layer) named `name`, shown when the document opens ++ /// if `visible` is true. ++ /// ++ /// The group is listed in the catalog's `/OCProperties` even if no content uses it. ++ /// Names are text strings, so any Unicode name works; they need not be unique. ++ /// ++ /// Optional content needs PDF 1.5 and is forbidden in PDF/A-1: adding a group registers ++ /// [`ValidationError::OptionalContent`] and, below PDF 1.5, ++ /// [`ValidationError::RequiresNewerPdfVersion`]. ++ pub fn add_optional_content_group(&mut self, name: impl Into, visible: bool) -> OcgId { ++ let sc = &mut self.serializer_context; ++ let name = name.into(); ++ ++ sc.register_validation_error(ValidationError::OptionalContent(sc.location)); ++ if sc.serialize_settings().pdf_version() ++ < VersionedFeature::OptionalContent.minimum_pdf_version() ++ { ++ sc.register_validation_error(ValidationError::RequiresNewerPdfVersion( ++ VersionedFeature::OptionalContent, ++ sc.location, ++ )); ++ } ++ ++ let ref_ = sc.new_ref(); ++ let mut ocg = self ++ .chunk_container ++ .non_stream ++ .optional_content ++ .indirect(ref_) ++ .dict(); ++ ocg.pair(Name(b"Type"), Name(b"OCG")); ++ ocg.pair(Name(b"Name"), TextStr(&name)); ++ ocg.finish(); ++ ++ let groups = &mut sc.optional_content.groups; ++ groups.push(Group { ref_, visible }); ++ OcgId(groups.len() - 1) ++ } ++ ++ /// Whether the document opens with the viewer's layers panel showing ++ /// (`/PageMode /UseOC`). Only written when the document has optional content groups. ++ /// Off by default. ++ pub fn set_optional_content_page_mode(&mut self, use_oc: bool) { ++ self.serializer_context.optional_content.page_mode = use_oc; ++ } ++} ++ ++impl Surface<'_> { ++ /// Start content that belongs to the optional content group `group`, until the matching ++ /// [`Surface::end_optional_content`]. ++ /// ++ /// Marks nest, and a mark may contain tagged sections (see the [module ++ /// documentation](crate::optional_content) for the nesting rules). Content left open ++ /// when the surface finishes, or when a group, opacity or mask pushed after the start ++ /// is popped, is closed there, and [`Document::finish`] then fails with ++ /// [`KrillaError::UnbalancedOptionalContent`]. ++ /// ++ /// Returns [`KrillaError::UnknownOptionalContentGroup`] and writes nothing if `group` ++ /// does not belong to this document. ++ pub fn start_optional_content(&mut self, group: OcgId) -> KrillaResult<()> { ++ let (builder, sc) = self.content_and_context(); ++ let Some(ocg) = sc.optional_content.group_ref(group) else { ++ return Err(KrillaError::UnknownOptionalContentGroup(sc.location)); ++ }; ++ ++ let name = builder.rd_builder.register_resource(Properties(ocg)); ++ builder ++ .content ++ .begin_marked_content_with_properties(Name(b"OC")) ++ .properties_named(name.to_pdf_name()); ++ let tagged = builder.active_marked_content; ++ builder.optional_content.open.push(tagged); ++ Ok(()) ++ } ++ ++ /// End the innermost optional content started with [`Surface::start_optional_content`] ++ /// in the current stream. ++ /// ++ /// Returns [`KrillaError::UnbalancedOptionalContent`] and writes nothing if no optional ++ /// content is open in the current stream, or if a tagged section started inside it is ++ /// still open. ++ pub fn end_optional_content(&mut self) -> KrillaResult<()> { ++ let (builder, sc) = self.content_and_context(); ++ let tagged = builder.active_marked_content; ++ if builder.optional_content.open.last() != Some(&tagged) { ++ return Err(KrillaError::UnbalancedOptionalContent(sc.location)); ++ } ++ ++ builder.optional_content.open.pop(); ++ builder.content.end_marked_content(); ++ Ok(()) ++ } ++} ++ ++/// The optional content marks open in one content stream. Each entry records whether a ++/// tagged section was open when the mark started. ++#[derive(Default)] ++pub(crate) struct OpenMarks { ++ open: Vec, ++ overlap: bool, ++} ++ ++impl OpenMarks { ++ /// Called when a tagged section ends: a mark started inside it would be closed by its ++ /// `EMC`. ++ pub(crate) fn tag_ended(&mut self) { ++ if self.open.last() == Some(&true) { ++ self.overlap = true; ++ } ++ } ++} ++ ++/// Close the marks still open in `builder` before its stream is finished, and record the ++/// misuse for [`SerializeContext::finish`]. ++pub(crate) fn close_open_marks(builder: &mut ContentBuilder, sc: &mut SerializeContext) { ++ let open = std::mem::take(&mut builder.optional_content.open); ++ for _ in &open { ++ builder.content.end_marked_content(); ++ } ++ if !open.is_empty() || builder.optional_content.overlap { ++ let location = sc.location; ++ sc.optional_content.record_misuse(location); ++ } ++} ++ ++/// An entry of a resource dictionary's `/Properties`: an optional content group. ++#[derive(Debug, Eq, PartialEq, Hash, Clone)] ++pub(crate) struct Properties(Ref); ++ ++impl Resource for Properties { ++ fn new(ref_: Ref) -> Self { ++ Self(ref_) ++ } ++ ++ fn get_ref(&self) -> Ref { ++ self.0 ++ } ++ ++ fn get_dict<'a>(resources: &'a mut writers::Resources) -> Dict<'a> { ++ resources.insert(Name(b"Properties")).dict() ++ } ++ ++ fn get_prefix() -> &'static str { ++ "oc" ++ } ++ ++ fn get_mapper(b: &mut ResourceDictionaryBuilder) -> &mut ResourceMapper { ++ &mut b.properties ++ } ++} +diff --git a/src/lib.rs b/src/lib.rs +index 099a9d4..cc608aa 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -187,3 +187,6 @@ pub const CRAFT_UNWIND_POISONING: bool = true; + /// Identifies the print color patches: CMYK blending groups, print output intent and + /// PDF/X markers. + pub const CRAFT_PRINT_COLOR: bool = true; ++ ++/// Identifies the optional content patch: layers written as optional content groups. ++pub const CRAFT_OPTIONAL_CONTENT: bool = true; +diff --git a/src/resource.rs b/src/resource.rs +index 56b8488..da8ab83 100644 +--- a/src/resource.rs ++++ b/src/resource.rs +@@ -7,6 +7,7 @@ use pdf_writer::types::ProcSet; + use pdf_writer::writers; + use pdf_writer::{Chunk, Dict, Finish, Name, Ref}; + ++use crate::graphics::optional_content::Properties; + use crate::serialize::{Cacheable, SerializeContext}; + use crate::util::NameExt; + +@@ -180,6 +181,7 @@ pub(crate) struct ResourceDictionaryBuilder { + pub(crate) x_objects: ResourceMapper, + pub(crate) shadings: ResourceMapper, + pub(crate) fonts: ResourceMapper, ++ pub(crate) properties: ResourceMapper, + } + + impl ResourceDictionaryBuilder { +@@ -191,6 +193,7 @@ impl ResourceDictionaryBuilder { + x_objects: ResourceMapper::new(), + shadings: ResourceMapper::new(), + fonts: ResourceMapper::new(), ++ properties: ResourceMapper::new(), + } + } + +@@ -209,6 +212,7 @@ impl ResourceDictionaryBuilder { + x_objects: self.x_objects.into_resource_list(), + shadings: self.shadings.into_resource_list(), + fonts: self.fonts.into_resource_list(), ++ properties: self.properties.into_resource_list(), + } + } + } +@@ -221,6 +225,7 @@ pub(crate) struct ResourceDictionary { + pub(crate) x_objects: ResourceList, + pub(crate) shadings: ResourceList, + pub(crate) fonts: ResourceList, ++ pub(crate) properties: ResourceList, + } + + impl Default for ResourceDictionary { +@@ -232,6 +237,7 @@ impl Default for ResourceDictionary { + x_objects: ResourceList::empty(), + shadings: ResourceList::empty(), + fonts: ResourceList::empty(), ++ properties: ResourceList::empty(), + } + } + } +@@ -253,7 +259,8 @@ impl ResourceDictionary { + || self.patterns.len() > 0 + || self.x_objects.len() > 0 + || self.shadings.len() > 0 +- || self.fonts.len() > 0; ++ || self.fonts.len() > 0 ++ || self.properties.len() > 0; + + if !write_proc_sets && !has_resource_entries { + // `Resources` dictionary is mandatory (or rather, it's mandatory if +@@ -282,6 +289,7 @@ impl ResourceDictionary { + write_resource_type::(&mut resources, &self.x_objects); + write_resource_type::(&mut resources, &self.shadings); + write_resource_type::(&mut resources, &self.fonts); ++ write_resource_type::(&mut resources, &self.properties); + parent.set_resources(resources_ref); + } + } +diff --git a/src/serialize.rs b/src/serialize.rs +index 587e2c6..4f4af4c 100644 +--- a/src/serialize.rs ++++ b/src/serialize.rs +@@ -22,6 +22,7 @@ use crate::graphics::device_n::DeviceNColorSpace; + use crate::graphics::icc::{ICCBasedColorSpace, ICCProfile}; + #[cfg(feature = "raster-images")] + use crate::graphics::image::Image; ++use crate::graphics::optional_content::OptionalContent; + use crate::graphics::separation::SeparationColorSpace; + use crate::interactive::destination::{NamedDestination, XyzDestination}; + use crate::interchange::embed::EmbeddedFile; +@@ -356,6 +357,8 @@ pub(crate) struct SerializeContext { + /// Where the first mesh gradient whose corner colors share no color space was drawn; + /// [`SerializeContext::finish`] fails with [`KrillaError::MixedMeshColorSpaces`]. + pub(crate) mixed_mesh_colors: Option>, ++ /// The document's optional content groups. ++ pub(crate) optional_content: OptionalContent, + /// The ref of the page tree. + page_tree_ref: Ref, + /// PDF 2.0 namespaces. +@@ -417,6 +420,7 @@ impl SerializeContext { + Self { + poisoned: false, + mixed_mesh_colors: None, ++ optional_content: OptionalContent::default(), + cached_mappings: HashMap::new(), + pdf2_ns, + global_objects: GlobalObjects::default(), +@@ -631,6 +635,9 @@ impl SerializeContext { + if let Some(location) = self.mixed_mesh_colors { + return Err(KrillaError::MixedMeshColorSpaces(location)); + } ++ if let Some(location) = self.optional_content.misuse { ++ return Err(KrillaError::UnbalancedOptionalContent(location)); ++ } + + // We need to be careful here that we serialize the objects in the right order, + // as in some cases we use MaybeTake::take to remove an object, which means that +diff --git a/src/surface.rs b/src/surface.rs +index 15a728d..b5b602d 100644 +--- a/src/surface.rs ++++ b/src/surface.rs +@@ -100,6 +100,11 @@ impl<'a> Surface<'a> { + } + } + ++ /// The content stream being drawn and the serialization context, borrowed together. ++ pub(crate) fn content_and_context(&mut self) -> (&mut ContentBuilder, &mut SerializeContext) { ++ (self.bd.get_mut(), self.sc) ++ } ++ + /// Return a `StreamBuilder` to allow drawing on a sub-context. + pub fn stream_builder(&mut self) -> StreamBuilder<'_> { + StreamBuilder::new(self.sc, self.chunk_container) diff --git a/vendor/krilla/src/chunk_container.rs b/vendor/krilla/src/chunk_container.rs index 978eacc9..023784f7 100644 --- a/vendor/krilla/src/chunk_container.rs +++ b/vendor/krilla/src/chunk_container.rs @@ -55,6 +55,7 @@ pub(crate) struct NonStreamChunks { pub(crate) patterns: Chunk, pub(crate) pages: Chunk, pub(crate) embedded_files: Chunk, + pub(crate) optional_content: Chunk, } impl ChunkContainer { @@ -93,6 +94,7 @@ impl ChunkContainer { patterns: sc.new_chunk(), pages: sc.new_chunk(), embedded_files: sc.new_chunk(), + optional_content: sc.new_chunk(), }, } } @@ -345,6 +347,9 @@ impl ChunkContainer { } } + sc.optional_content + .write_catalog_entries(&mut catalog, &remapper); + catalog.finish(); } @@ -441,6 +446,7 @@ impl Visit for NonStreamChunks { self.patterns.visit(sc, f)?; self.pages.visit(sc, f)?; self.embedded_files.visit(sc, f)?; + self.optional_content.visit(sc, f)?; Ok(()) } diff --git a/vendor/krilla/src/configure/PDF_A1.md b/vendor/krilla/src/configure/PDF_A1.md index 06b52439..a4c7bc37 100644 --- a/vendor/krilla/src/configure/PDF_A1.md +++ b/vendor/krilla/src/configure/PDF_A1.md @@ -58,7 +58,8 @@ with an even number of characters and without whitespaces. 🟢 DeviceN colorants is never reached. 🟢 - krilla only uses u16 for CIDs. 🟢 -6.1.13: krilla does not support optional content. 🔵 +6.1.13: krilla fails export if an optional content group is added +(`ValidationError::OptionalContent`), so the catalog never has `OCProperties`. 🟢 ## 6.2 Graphics diff --git a/vendor/krilla/src/configure/PDF_A2.md b/vendor/krilla/src/configure/PDF_A2.md index 7e3fd8f9..69acde59 100644 --- a/vendor/krilla/src/configure/PDF_A2.md +++ b/vendor/krilla/src/configure/PDF_A2.md @@ -222,7 +222,9 @@ krilla prohibits embedding files in this export mode. 🟢 # 6.9 Optional content -krilla does not support optional content. 🔵 +- krilla writes every optional content group with a `Name`. 🟢 +- krilla writes one optional content configuration, `D`, with the `Name` `Layers`, an `Order` + array that lists every group, and no `AS` key. 🟢 # 6.10 Use of alternate presentations and transitions diff --git a/vendor/krilla/src/configure/PDF_A4.md b/vendor/krilla/src/configure/PDF_A4.md index aac80088..be1314bd 100644 --- a/vendor/krilla/src/configure/PDF_A4.md +++ b/vendor/krilla/src/configure/PDF_A4.md @@ -191,7 +191,9 @@ See `README.md` for the meaning of each color. - krilla straight out forbids embedding files in this export mode. 🟢 ## 6.10 Optional content -- krilla does not support optional content. 🟢 +- krilla writes every optional content group with a `Name`. 🟢 +- krilla writes one optional content configuration, `D`, with the `Name` `Layers`, an `Order` + array that lists every group, and no `AS` key. 🟢 ## 6.11 Use of alternate presentations and transitions - krilla does not use the `AlternatePresentations` entry. 🟢 diff --git a/vendor/krilla/src/configure/PDF_UA1.md b/vendor/krilla/src/configure/PDF_UA1.md index 16959bbe..80449b8b 100644 --- a/vendor/krilla/src/configure/PDF_UA1.md +++ b/vendor/krilla/src/configure/PDF_UA1.md @@ -87,7 +87,7 @@ See `README.md` for the meaning of each color. - krilla always generates an ID for notes. 🟢 # 7.10 Optional content -- krilla does currently not support optional content. 🔵 +- krilla writes its one optional content configuration, `D`, with a `Name`. 🟢 # 7.11 Embedded files - krilla supports embedded files, and requires a description in this export mode. 🟢 diff --git a/vendor/krilla/src/configure/validate.rs b/vendor/krilla/src/configure/validate.rs index b0a8f03e..fb429ceb 100644 --- a/vendor/krilla/src/configure/validate.rs +++ b/vendor/krilla/src/configure/validate.rs @@ -159,6 +159,12 @@ pub enum ValidationError { EmbeddedPDF(Option), /// A feature only available in a later PDF version was required. RequiresNewerPdfVersion(VersionedFeature, Option), + /// The PDF contains optional content groups, which PDF/A-1 forbids. + /// + /// Occurs if [`Document::add_optional_content_group`] was called. + /// + /// [`Document::add_optional_content_group`]: crate::Document::add_optional_content_group + OptionalContent(Option), } /// Features that may require a later PDF version than the current one. @@ -170,6 +176,8 @@ pub enum VersionedFeature { HeaderFooterArtifactSubtypes, /// Scope attribute for table header cells. TableHeaderScope, + /// Optional content groups (layers). + OptionalContent, } impl VersionedFeature { @@ -179,6 +187,7 @@ impl VersionedFeature { VersionedFeature::StructureOrderTabbing => PdfVersion::Pdf15, VersionedFeature::HeaderFooterArtifactSubtypes => PdfVersion::Pdf17, VersionedFeature::TableHeaderScope => PdfVersion::Pdf15, + VersionedFeature::OptionalContent => PdfVersion::Pdf15, } } } @@ -566,7 +575,8 @@ impl Archival { | ValidationError::ImageInterpolation(_) | ValidationError::PdfxVersionWithoutExtensionSchema | ValidationError::EmbeddedFile(EmbedError::Existence, _) - | ValidationError::EmbeddedPDF(_), + | ValidationError::EmbeddedPDF(_) + | ValidationError::OptionalContent(_), ) => true, // Allowed under all PDF/A-1 profiles. ( @@ -582,7 +592,8 @@ impl Archival { | ValidationError::RequiresNewerPdfVersion( VersionedFeature::HeaderFooterArtifactSubtypes | VersionedFeature::StructureOrderTabbing - | VersionedFeature::TableHeaderScope, + | VersionedFeature::TableHeaderScope + | VersionedFeature::OptionalContent, _, ), ) => false, @@ -628,9 +639,11 @@ impl Archival { | ValidationError::RequiresNewerPdfVersion( VersionedFeature::HeaderFooterArtifactSubtypes | VersionedFeature::StructureOrderTabbing - | VersionedFeature::TableHeaderScope, + | VersionedFeature::TableHeaderScope + | VersionedFeature::OptionalContent, _, - ), + ) + | ValidationError::OptionalContent(_), ) => false, // Forbidden under PDF/A-2 but allowed under PDF/A-3. ( @@ -707,9 +720,11 @@ impl Archival { | ValidationError::RequiresNewerPdfVersion( VersionedFeature::HeaderFooterArtifactSubtypes | VersionedFeature::StructureOrderTabbing - | VersionedFeature::TableHeaderScope, + | VersionedFeature::TableHeaderScope + | VersionedFeature::OptionalContent, _, - ), + ) + | ValidationError::OptionalContent(_), ) => false, // Forbidden under PDF/A-4 but allowed under other PDF/A-4 profiles. ( @@ -1133,7 +1148,8 @@ impl Accessibility { | ValidationError::RequiresNewerPdfVersion( VersionedFeature::HeaderFooterArtifactSubtypes | VersionedFeature::StructureOrderTabbing - | VersionedFeature::TableHeaderScope, + | VersionedFeature::TableHeaderScope + | VersionedFeature::OptionalContent, _, ), ) => true, @@ -1159,7 +1175,8 @@ impl Accessibility { EmbedError::Existence | EmbedError::MissingDate | EmbedError::MissingMimeType, _, ) - | ValidationError::MissingDocumentDate, + | ValidationError::MissingDocumentDate + | ValidationError::OptionalContent(_), ) => false, } } diff --git a/vendor/krilla/src/content.rs b/vendor/krilla/src/content.rs index 26ad6653..68779d60 100644 --- a/vendor/krilla/src/content.rs +++ b/vendor/krilla/src/content.rs @@ -22,6 +22,7 @@ use crate::graphics::graphics_state::{ExtGState, GraphicsStates}; #[cfg(feature = "raster-images")] use crate::graphics::image::Image; use crate::graphics::mask::Mask; +use crate::graphics::optional_content::{self, OpenMarks}; use crate::graphics::paint::{Fill, FillRule, InnerPaint, LineCap, LineJoin, Paint, Stroke}; use crate::graphics::shading_function::{ GradientProperties, GradientPropertiesExt, ShadingFunction, @@ -42,8 +43,8 @@ use crate::text::{Glyph, GlyphId}; use crate::util::{calculate_stroke_bbox, NameExt}; pub(crate) struct ContentBuilder { - rd_builder: ResourceDictionaryBuilder, - content: Content, + pub(crate) rd_builder: ResourceDictionaryBuilder, + pub(crate) content: Content, validation_errors: HashSet, root_transform: Transform, graphics_states: GraphicsStates, @@ -57,6 +58,7 @@ pub(crate) struct ContentBuilder { /// A temporary buffer that's reused across the builder. scratch: Vec, pub(crate) active_marked_content: bool, + pub(crate) optional_content: OpenMarks, } /// Stores either a device-specific color space, @@ -84,6 +86,7 @@ impl ContentBuilder { bbox: None, scratch: Vec::new(), active_marked_content: false, + optional_content: OpenMarks::default(), } } @@ -96,7 +99,8 @@ impl ContentBuilder { } } - pub(crate) fn finish(self, sc: &mut SerializeContext) -> Stream { + pub(crate) fn finish(mut self, sc: &mut SerializeContext) -> Stream { + optional_content::close_open_marks(&mut self, sc); let buf = self.content.finish(); sc.register_limits(buf.limits()); @@ -153,6 +157,7 @@ impl ContentBuilder { panic!("can't end marked content when none has been started"); } + self.optional_content.tag_ended(); self.content.end_marked_content(); self.active_marked_content = false; } diff --git a/vendor/krilla/src/error.rs b/vendor/krilla/src/error.rs index 4c582aeb..a19ebf62 100644 --- a/vendor/krilla/src/error.rs +++ b/vendor/krilla/src/error.rs @@ -34,6 +34,15 @@ pub enum KrillaError { /// /// [`MeshGradient`]: crate::paint::MeshGradient MixedMeshColorSpaces(Option), + /// [`Surface::start_optional_content`] was given an [`OcgId`] that this document did + /// not create. + /// + /// [`Surface::start_optional_content`]: crate::surface::Surface::start_optional_content + /// [`OcgId`]: crate::optional_content::OcgId + UnknownOptionalContentGroup(Option), + /// Optional content marks were not balanced: an end without a start, a mark still open + /// when its stream finished, or a mark overlapping a tagged section. + UnbalancedOptionalContent(Option), /// An error while attempting to embed a font. Font(Font, String), /// A list of validation errors. Can only occur if you set the `validator` in @@ -79,6 +88,14 @@ impl Display for KrillaError { f.write_str("a mesh gradient mixes color spaces that can't be written as one")?; write_location(f, *location) } + KrillaError::UnknownOptionalContentGroup(location) => { + f.write_str("an optional content group from another document was used")?; + write_location(f, *location) + } + KrillaError::UnbalancedOptionalContent(location) => { + f.write_str("optional content marks are not balanced")?; + write_location(f, *location) + } KrillaError::Font(_, message) => write!(f, "failed to embed font: {message}"), KrillaError::Validation(errors) => { let count = errors.len(); diff --git a/vendor/krilla/src/graphics/mod.rs b/vendor/krilla/src/graphics/mod.rs index 57e94060..3864d6eb 100644 --- a/vendor/krilla/src/graphics/mod.rs +++ b/vendor/krilla/src/graphics/mod.rs @@ -6,6 +6,7 @@ pub mod icc; #[cfg(feature = "raster-images")] pub mod image; pub mod mask; +pub mod optional_content; pub mod paint; pub(crate) mod device_n; pub(crate) mod separation; diff --git a/vendor/krilla/src/graphics/optional_content.rs b/vendor/krilla/src/graphics/optional_content.rs new file mode 100644 index 00000000..45c135f4 --- /dev/null +++ b/vendor/krilla/src/graphics/optional_content.rs @@ -0,0 +1,266 @@ +//! Optional content (layers). +//! +//! An optional content group (OCG) is a named layer that a viewer can show or hide. Create +//! groups on the [`Document`] with [`Document::add_optional_content_group`], then mark +//! content on any surface with [`Surface::start_optional_content`] and +//! [`Surface::end_optional_content`]. +//! +//! Every group is written once as `<< /Type /OCG /Name (…) >>`, and the catalog gets +//! `/OCProperties` listing all groups, also those that no page uses, with the hidden ones +//! in the default configuration's `/OFF` array. Marked content is written as +//! `/OC /ocN BDC … EMC`, with `/ocN` registered in the `/Properties` resources of the +//! stream that holds it. +//! +//! Optional content marks nest independently of tagged sections, but they must not overlap +//! them: a tagged section started inside a mark ends before the mark does, and a mark started +//! inside a tagged section ends before the section does. Marks are balanced per stream: a mark +//! started before [`Surface::push_group`] (or any other push that starts a new stream, such as +//! an opacity or a mask) can't be ended inside it. +//! +//! Optional content needs PDF 1.5 and is forbidden in PDF/A-1. +//! +//! [`Surface::push_group`]: crate::surface::Surface::push_group + +use std::collections::HashMap; + +use pdf_writer::types::PageMode; +use pdf_writer::writers::{self, Catalog}; +use pdf_writer::{Dict, Finish, Name, Ref, TextStr}; + +use crate::configure::validate::VersionedFeature; +use crate::configure::ValidationError; +use crate::content::ContentBuilder; +use crate::document::Document; +use crate::error::{KrillaError, KrillaResult}; +use crate::resource::{Resource, ResourceDictionaryBuilder, ResourceMapper}; +use crate::serialize::SerializeContext; +use crate::surface::{Location, Surface}; +use crate::util::NameExt; + +/// An optional content group of a [`Document`], returned by +/// [`Document::add_optional_content_group`]. +/// +/// An id is only meaningful for the document that created it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct OcgId(usize); + +struct Group { + ref_: Ref, + visible: bool, +} + +/// The document's optional content groups and settings. +#[derive(Default)] +pub(crate) struct OptionalContent { + groups: Vec, + page_mode: bool, + /// Where the first unbalanced or overlapping mark was found; + /// [`SerializeContext::finish`] fails with [`KrillaError::UnbalancedOptionalContent`]. + pub(crate) misuse: Option>, +} + +impl OptionalContent { + fn group_ref(&self, id: OcgId) -> Option { + self.groups.get(id.0).map(|g| g.ref_) + } + + fn record_misuse(&mut self, location: Option) { + self.misuse.get_or_insert(location); + } + + /// Writes `/OCProperties`, and `/PageMode /UseOC` when requested, if any group exists. + pub(crate) fn write_catalog_entries( + &self, + catalog: &mut Catalog<'_>, + remapper: &HashMap, + ) { + if self.groups.is_empty() { + return; + } + + let all: Vec = self + .groups + .iter() + .filter_map(|g| remapper.get(&g.ref_).copied()) + .collect(); + let hidden: Vec = self + .groups + .iter() + .filter(|g| !g.visible) + .filter_map(|g| remapper.get(&g.ref_).copied()) + .collect(); + + let mut properties = catalog.insert(Name(b"OCProperties")).dict(); + properties + .insert(Name(b"OCGs")) + .array() + .items(all.iter().copied()); + let mut config = properties.insert(Name(b"D")).dict(); + config.pair(Name(b"Name"), TextStr("Layers")); + config + .insert(Name(b"Order")) + .array() + .items(all.iter().copied()); + if !hidden.is_empty() { + config.insert(Name(b"OFF")).array().items(hidden); + } + config.finish(); + properties.finish(); + + if self.page_mode { + catalog.page_mode(PageMode::UseOC); + } + } +} + +impl Document { + /// Add an optional content group (a layer) named `name`, shown when the document opens + /// if `visible` is true. + /// + /// The group is listed in the catalog's `/OCProperties` even if no content uses it. + /// Names are text strings, so any Unicode name works; they need not be unique. + /// + /// Optional content needs PDF 1.5 and is forbidden in PDF/A-1: adding a group registers + /// [`ValidationError::OptionalContent`] and, below PDF 1.5, + /// [`ValidationError::RequiresNewerPdfVersion`]. + pub fn add_optional_content_group(&mut self, name: impl Into, visible: bool) -> OcgId { + let sc = &mut self.serializer_context; + let name = name.into(); + + sc.register_validation_error(ValidationError::OptionalContent(sc.location)); + if sc.serialize_settings().pdf_version() + < VersionedFeature::OptionalContent.minimum_pdf_version() + { + sc.register_validation_error(ValidationError::RequiresNewerPdfVersion( + VersionedFeature::OptionalContent, + sc.location, + )); + } + + let ref_ = sc.new_ref(); + let mut ocg = self + .chunk_container + .non_stream + .optional_content + .indirect(ref_) + .dict(); + ocg.pair(Name(b"Type"), Name(b"OCG")); + ocg.pair(Name(b"Name"), TextStr(&name)); + ocg.finish(); + + let groups = &mut sc.optional_content.groups; + groups.push(Group { ref_, visible }); + OcgId(groups.len() - 1) + } + + /// Whether the document opens with the viewer's layers panel showing + /// (`/PageMode /UseOC`). Only written when the document has optional content groups. + /// Off by default. + pub fn set_optional_content_page_mode(&mut self, use_oc: bool) { + self.serializer_context.optional_content.page_mode = use_oc; + } +} + +impl Surface<'_> { + /// Start content that belongs to the optional content group `group`, until the matching + /// [`Surface::end_optional_content`]. + /// + /// Marks nest, and a mark may contain tagged sections (see the [module + /// documentation](crate::optional_content) for the nesting rules). Content left open + /// when the surface finishes, or when a group, opacity or mask pushed after the start + /// is popped, is closed there, and [`Document::finish`] then fails with + /// [`KrillaError::UnbalancedOptionalContent`]. + /// + /// Returns [`KrillaError::UnknownOptionalContentGroup`] and writes nothing if `group` + /// does not belong to this document. + pub fn start_optional_content(&mut self, group: OcgId) -> KrillaResult<()> { + let (builder, sc) = self.content_and_context(); + let Some(ocg) = sc.optional_content.group_ref(group) else { + return Err(KrillaError::UnknownOptionalContentGroup(sc.location)); + }; + + let name = builder.rd_builder.register_resource(Properties(ocg)); + builder + .content + .begin_marked_content_with_properties(Name(b"OC")) + .properties_named(name.to_pdf_name()); + let tagged = builder.active_marked_content; + builder.optional_content.open.push(tagged); + Ok(()) + } + + /// End the innermost optional content started with [`Surface::start_optional_content`] + /// in the current stream. + /// + /// Returns [`KrillaError::UnbalancedOptionalContent`] and writes nothing if no optional + /// content is open in the current stream, or if a tagged section started inside it is + /// still open. + pub fn end_optional_content(&mut self) -> KrillaResult<()> { + let (builder, sc) = self.content_and_context(); + let tagged = builder.active_marked_content; + if builder.optional_content.open.last() != Some(&tagged) { + return Err(KrillaError::UnbalancedOptionalContent(sc.location)); + } + + builder.optional_content.open.pop(); + builder.content.end_marked_content(); + Ok(()) + } +} + +/// The optional content marks open in one content stream. Each entry records whether a +/// tagged section was open when the mark started. +#[derive(Default)] +pub(crate) struct OpenMarks { + open: Vec, + overlap: bool, +} + +impl OpenMarks { + /// Called when a tagged section ends: a mark started inside it would be closed by its + /// `EMC`. + pub(crate) fn tag_ended(&mut self) { + if self.open.last() == Some(&true) { + self.overlap = true; + } + } +} + +/// Close the marks still open in `builder` before its stream is finished, and record the +/// misuse for [`SerializeContext::finish`]. +pub(crate) fn close_open_marks(builder: &mut ContentBuilder, sc: &mut SerializeContext) { + let open = std::mem::take(&mut builder.optional_content.open); + for _ in &open { + builder.content.end_marked_content(); + } + if !open.is_empty() || builder.optional_content.overlap { + let location = sc.location; + sc.optional_content.record_misuse(location); + } +} + +/// An entry of a resource dictionary's `/Properties`: an optional content group. +#[derive(Debug, Eq, PartialEq, Hash, Clone)] +pub(crate) struct Properties(Ref); + +impl Resource for Properties { + fn new(ref_: Ref) -> Self { + Self(ref_) + } + + fn get_ref(&self) -> Ref { + self.0 + } + + fn get_dict<'a>(resources: &'a mut writers::Resources) -> Dict<'a> { + resources.insert(Name(b"Properties")).dict() + } + + fn get_prefix() -> &'static str { + "oc" + } + + fn get_mapper(b: &mut ResourceDictionaryBuilder) -> &mut ResourceMapper { + &mut b.properties + } +} diff --git a/vendor/krilla/src/lib.rs b/vendor/krilla/src/lib.rs index 099a9d42..cc608aa0 100644 --- a/vendor/krilla/src/lib.rs +++ b/vendor/krilla/src/lib.rs @@ -187,3 +187,6 @@ pub const CRAFT_UNWIND_POISONING: bool = true; /// Identifies the print color patches: CMYK blending groups, print output intent and /// PDF/X markers. pub const CRAFT_PRINT_COLOR: bool = true; + +/// Identifies the optional content patch: layers written as optional content groups. +pub const CRAFT_OPTIONAL_CONTENT: bool = true; diff --git a/vendor/krilla/src/resource.rs b/vendor/krilla/src/resource.rs index 56b8488f..da8ab834 100644 --- a/vendor/krilla/src/resource.rs +++ b/vendor/krilla/src/resource.rs @@ -7,6 +7,7 @@ use pdf_writer::types::ProcSet; use pdf_writer::writers; use pdf_writer::{Chunk, Dict, Finish, Name, Ref}; +use crate::graphics::optional_content::Properties; use crate::serialize::{Cacheable, SerializeContext}; use crate::util::NameExt; @@ -180,6 +181,7 @@ pub(crate) struct ResourceDictionaryBuilder { pub(crate) x_objects: ResourceMapper, pub(crate) shadings: ResourceMapper, pub(crate) fonts: ResourceMapper, + pub(crate) properties: ResourceMapper, } impl ResourceDictionaryBuilder { @@ -191,6 +193,7 @@ impl ResourceDictionaryBuilder { x_objects: ResourceMapper::new(), shadings: ResourceMapper::new(), fonts: ResourceMapper::new(), + properties: ResourceMapper::new(), } } @@ -209,6 +212,7 @@ impl ResourceDictionaryBuilder { x_objects: self.x_objects.into_resource_list(), shadings: self.shadings.into_resource_list(), fonts: self.fonts.into_resource_list(), + properties: self.properties.into_resource_list(), } } } @@ -221,6 +225,7 @@ pub(crate) struct ResourceDictionary { pub(crate) x_objects: ResourceList, pub(crate) shadings: ResourceList, pub(crate) fonts: ResourceList, + pub(crate) properties: ResourceList, } impl Default for ResourceDictionary { @@ -232,6 +237,7 @@ impl Default for ResourceDictionary { x_objects: ResourceList::empty(), shadings: ResourceList::empty(), fonts: ResourceList::empty(), + properties: ResourceList::empty(), } } } @@ -253,7 +259,8 @@ impl ResourceDictionary { || self.patterns.len() > 0 || self.x_objects.len() > 0 || self.shadings.len() > 0 - || self.fonts.len() > 0; + || self.fonts.len() > 0 + || self.properties.len() > 0; if !write_proc_sets && !has_resource_entries { // `Resources` dictionary is mandatory (or rather, it's mandatory if @@ -282,6 +289,7 @@ impl ResourceDictionary { write_resource_type::(&mut resources, &self.x_objects); write_resource_type::(&mut resources, &self.shadings); write_resource_type::(&mut resources, &self.fonts); + write_resource_type::(&mut resources, &self.properties); parent.set_resources(resources_ref); } } diff --git a/vendor/krilla/src/serialize.rs b/vendor/krilla/src/serialize.rs index 587e2c62..4f4af4c9 100644 --- a/vendor/krilla/src/serialize.rs +++ b/vendor/krilla/src/serialize.rs @@ -22,6 +22,7 @@ use crate::graphics::device_n::DeviceNColorSpace; use crate::graphics::icc::{ICCBasedColorSpace, ICCProfile}; #[cfg(feature = "raster-images")] use crate::graphics::image::Image; +use crate::graphics::optional_content::OptionalContent; use crate::graphics::separation::SeparationColorSpace; use crate::interactive::destination::{NamedDestination, XyzDestination}; use crate::interchange::embed::EmbeddedFile; @@ -356,6 +357,8 @@ pub(crate) struct SerializeContext { /// Where the first mesh gradient whose corner colors share no color space was drawn; /// [`SerializeContext::finish`] fails with [`KrillaError::MixedMeshColorSpaces`]. pub(crate) mixed_mesh_colors: Option>, + /// The document's optional content groups. + pub(crate) optional_content: OptionalContent, /// The ref of the page tree. page_tree_ref: Ref, /// PDF 2.0 namespaces. @@ -417,6 +420,7 @@ impl SerializeContext { Self { poisoned: false, mixed_mesh_colors: None, + optional_content: OptionalContent::default(), cached_mappings: HashMap::new(), pdf2_ns, global_objects: GlobalObjects::default(), @@ -631,6 +635,9 @@ impl SerializeContext { if let Some(location) = self.mixed_mesh_colors { return Err(KrillaError::MixedMeshColorSpaces(location)); } + if let Some(location) = self.optional_content.misuse { + return Err(KrillaError::UnbalancedOptionalContent(location)); + } // We need to be careful here that we serialize the objects in the right order, // as in some cases we use MaybeTake::take to remove an object, which means that diff --git a/vendor/krilla/src/surface.rs b/vendor/krilla/src/surface.rs index 15a728d5..b5b602d7 100644 --- a/vendor/krilla/src/surface.rs +++ b/vendor/krilla/src/surface.rs @@ -100,6 +100,11 @@ impl<'a> Surface<'a> { } } + /// The content stream being drawn and the serialization context, borrowed together. + pub(crate) fn content_and_context(&mut self) -> (&mut ContentBuilder, &mut SerializeContext) { + (self.bd.get_mut(), self.sc) + } + /// Return a `StreamBuilder` to allow drawing on a sub-context. pub fn stream_builder(&mut self) -> StreamBuilder<'_> { StreamBuilder::new(self.sc, self.chunk_container)