krilla: optional content groups (layers), patch 12 (#258)

Document::add_optional_content_group writes an OCG; Surface::start_optional_content and
end_optional_content mark content with /OC BDC/EMC, registering the group under the stream's
/Properties resources. Marks nest independently of tagging, and misuse returns or records a
KrillaError instead of panicking. The catalog gets /OCProperties (hidden groups in /OFF) and,
opt-in, /PageMode /UseOC. PDF/A-1 reports ValidationError::OptionalContent; below PDF 1.5 the
version requirement is registered too.
This commit is contained in:
aiongg authored and GitHub committed 2026-10-24 02:13:19 -04:00
1 parent e95a9c8127
commit 7895f5ed79
20 files changed
+1422 -32

No files matched your search

+13 -1
View File
@@ -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,
+3 -2
View File
@@ -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;
+287
View File
@@ -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<Vec<u8>, 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::<Vec<_>>()).unwrap(),
None => String::from_utf8(bytes.to_vec()).unwrap(),
}
}
fn ocg_name(ocg: &Dict<'_>) -> String {
assert_eq!(ocg.get::<Name<'_>>(b"Type".as_slice()).unwrap().as_str(), "OCG");
text(&ocg.get::<PdfString<'_>>(b"Name".as_slice()).unwrap())
}
fn names(array: &Array<'_>) -> Vec<String> {
array.iter::<Dict<'_>>().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::<Dict<'_>>(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<String> {
// 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<String, String> {
resources.keys().map(|key| (key.as_str().to_owned(), ocg_name(&resources.get::<Dict<'_>>(key.as_ref()).unwrap()))).collect()
}
/// Per page: the layer of each optional content mark, in content order.
fn page_layers(bytes: &[u8]) -> Vec<Vec<String>> {
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::<Dict<'_>>(b"OCProperties".as_slice()).unwrap();
let all = ["Walls", NOTES, "Unused"].map(str::to_owned).to_vec();
assert_eq!(names(&properties.get::<Array<'_>>(b"OCGs".as_slice()).unwrap()), all);
let config = properties.get::<Dict<'_>>(b"D".as_slice()).unwrap();
assert_eq!(text(&config.get::<PdfString<'_>>(b"Name".as_slice()).unwrap()), "Layers");
assert_eq!(names(&config.get::<Array<'_>>(b"Order".as_slice()).unwrap()), all);
assert_eq!(names(&config.get::<Array<'_>>(b"OFF".as_slice()).unwrap()), vec![NOTES.to_owned()]);
assert!(root.get::<Name<'_>>(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::<Name<'_>>(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::<Name<'_>>(b"PageMode".as_slice()).is_none());
assert!(root(&pdf).get::<Dict<'_>>(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<Stream<'_>> = x_objects.keys().filter_map(|key| x_objects.get::<Stream<'_>>(key.as_ref())).collect();
assert_eq!(forms.len(), 1);
let resources = forms[0].dict().get::<Dict<'_>>(b"Resources".as_slice()).unwrap();
let map = properties(&resources.get::<Dict<'_>>(b"Properties".as_slice()).unwrap());
let decoded = forms[0].decoded().unwrap();
let layers: Vec<String> = oc_marks(&decoded).iter().map(|mark| map[mark].clone()).collect();
assert_eq!(layers, vec![NOTES.to_owned()]);
}
fn validation_errors(version: PdfVersion, archival: Option<Archival>) -> Vec<ValidationError> {
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<Vec<u8>, 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<Vec<u8>, 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());
});
}
+36
View File
@@ -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`.
+18 -13
View File
@@ -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",
+709
View File
@@ -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<Location>),
/// A feature only available in a later PDF version was required.
RequiresNewerPdfVersion(VersionedFeature, Option<Location>),
+ /// 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<Location>),
}
/// 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<ValidationError>,
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<u8>,
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<Location>),
+ /// [`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<Location>),
+ /// 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<Location>),
/// 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<Group>,
+ page_mode: bool,
+ /// Where the first unbalanced or overlapping mark was found;
+ /// [`SerializeContext::finish`] fails with [`KrillaError::UnbalancedOptionalContent`].
+ pub(crate) misuse: Option<Option<Location>>,
+}
+
+impl OptionalContent {
+ fn group_ref(&self, id: OcgId) -> Option<Ref> {
+ self.groups.get(id.0).map(|g| g.ref_)
+ }
+
+ fn record_misuse(&mut self, location: Option<Location>) {
+ 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<Ref, Ref>,
+ ) {
+ if self.groups.is_empty() {
+ return;
+ }
+
+ let all: Vec<Ref> = self
+ .groups
+ .iter()
+ .filter_map(|g| remapper.get(&g.ref_).copied())
+ .collect();
+ let hidden: Vec<Ref> = 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<String>, 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<bool>,
+ 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<Properties> {
+ &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<XObject>,
pub(crate) shadings: ResourceMapper<Shading>,
pub(crate) fonts: ResourceMapper<Font>,
+ pub(crate) properties: ResourceMapper<Properties>,
}
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<XObject>,
pub(crate) shadings: ResourceList<Shading>,
pub(crate) fonts: ResourceList<Font>,
+ pub(crate) properties: ResourceList<Properties>,
}
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::<XObject>(&mut resources, &self.x_objects);
write_resource_type::<Shading>(&mut resources, &self.shadings);
write_resource_type::<Font>(&mut resources, &self.fonts);
+ write_resource_type::<Properties>(&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<Option<Location>>,
+ /// 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)
+6
View File
@@ -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(())
}
+2 -1
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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. 🟢
+1 -1
View File
@@ -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. 🟢
+25 -8
View File
@@ -159,6 +159,12 @@ pub enum ValidationError {
EmbeddedPDF(Option<Location>),
/// A feature only available in a later PDF version was required.
RequiresNewerPdfVersion(VersionedFeature, Option<Location>),
/// 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<Location>),
}
/// 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,
}
}
+8 -3
View File
@@ -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<ValidationError>,
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<u8>,
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;
}
+17
View File
@@ -34,6 +34,15 @@ pub enum KrillaError {
///
/// [`MeshGradient`]: crate::paint::MeshGradient
MixedMeshColorSpaces(Option<Location>),
/// [`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<Location>),
/// 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<Location>),
/// 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();
+1
View File
@@ -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;
+266
View File
@@ -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<Group>,
page_mode: bool,
/// Where the first unbalanced or overlapping mark was found;
/// [`SerializeContext::finish`] fails with [`KrillaError::UnbalancedOptionalContent`].
pub(crate) misuse: Option<Option<Location>>,
}
impl OptionalContent {
fn group_ref(&self, id: OcgId) -> Option<Ref> {
self.groups.get(id.0).map(|g| g.ref_)
}
fn record_misuse(&mut self, location: Option<Location>) {
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<Ref, Ref>,
) {
if self.groups.is_empty() {
return;
}
let all: Vec<Ref> = self
.groups
.iter()
.filter_map(|g| remapper.get(&g.ref_).copied())
.collect();
let hidden: Vec<Ref> = 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<String>, 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<bool>,
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<Properties> {
&mut b.properties
}
}
+3
View File
@@ -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;
+9 -1
View File
@@ -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<XObject>,
pub(crate) shadings: ResourceMapper<Shading>,
pub(crate) fonts: ResourceMapper<Font>,
pub(crate) properties: ResourceMapper<Properties>,
}
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<XObject>,
pub(crate) shadings: ResourceList<Shading>,
pub(crate) fonts: ResourceList<Font>,
pub(crate) properties: ResourceList<Properties>,
}
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::<XObject>(&mut resources, &self.x_objects);
write_resource_type::<Shading>(&mut resources, &self.shadings);
write_resource_type::<Font>(&mut resources, &self.fonts);
write_resource_type::<Properties>(&mut resources, &self.properties);
parent.set_resources(resources_ref);
}
}
+7
View File
@@ -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<Option<Location>>,
/// 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
+5
View File
@@ -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)