mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 07:39:23 +00:00
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:
1 parent
e95a9c8127
commit
7895f5ed79
20 files changed
+1422
-32
No files matched your search
+13
-1
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
Vendored
+36
@@ -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`.
|
||||
Vendored
+18
-13
@@ -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
@@ -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)
|
||||
Vendored
+6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+8
-3
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+17
@@ -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();
|
||||
|
||||
Vendored
+1
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+3
@@ -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;
|
||||
Vendored
+9
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+7
@@ -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
|
||||
|
||||
Vendored
+5
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user