M4.62: PDF/.ai layers from optional content: named layers with visibility, print state and lock; art that is off as hidden layers

Optional content groups become layers (in paint order, shared by every page)
with their name, default visibility, print usage and lock; art outside them
goes to a layer per page. Art that is off isn't lost: an update appended to
the file drops its /OCProperties so the interpreter draws every group, and
the marked-content sequences found walking the content (checked by tag) route
the art to its group's layer, which comes in hidden. A form hidden by its own
/OC keeps the file as it is. document.open layers: false keeps one layer per
page of only what shows (Save then asks for a name).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 13:05:12 +07:00
1 parent a51083733e
commit 8046b9be84
10 files changed
+586 -87

No files matched your search

+1 -1
View File
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
+40 -12
View File
@@ -9,9 +9,9 @@ use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs};
use super::super::*;
use super::{err, source};
/// The `document.open` options besides the file. The PDF ones (pages, box, password, text) are
/// ignored by other formats.
#[derive(Clone, Debug, Default, PartialEq)]
/// The `document.open` options besides the file. The PDF ones (pages, box, password, text,
/// layers) are ignored by other formats.
#[derive(Clone, Debug, PartialEq)]
pub struct LoadOptions {
/// 1-based pages to import, such as `"2-3, 5"`; `None` = every page.
pub pages: Option<String>,
@@ -24,12 +24,21 @@ pub struct LoadOptions {
pub color_mode: Option<ColorMode>,
/// What a PDF's text becomes.
pub text_as: TextAs,
/// A PDF's optional content groups become layers (else one layer per page, without the art
/// that is off).
pub layers: bool,
}
impl Default for LoadOptions {
fn default() -> Self {
Self { pages: None, crop: CropTo::default(), password: None, color_mode: None, text_as: TextAs::default(), layers: true }
}
}
impl LoadOptions {
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) and
/// `textAs` (`text` | `outlines`).
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`),
/// `textAs` (`text` | `outlines`) and `layers` (true | false).
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
None => None,
@@ -47,13 +56,18 @@ impl LoadOptions {
Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))),
};
let text_as = choice(cmd, "textAs", str_param(p, "textAs"))?;
Ok(Self { pages, crop, password, color_mode, text_as })
let layers = match p.get("layers").filter(|v| !v.is_null()) {
None => true,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
};
Ok(Self { pages, crop, password, color_mode, text_as, layers })
}
/// Does the document read only part of the file, or read it differently (a page range, another
/// box, a password)? Writing it back would lose the rest, so Save doesn't (it asks for a name).
/// box, a password, without its hidden layers)? Writing it back would lose the rest, so Save
/// doesn't (it asks for a name).
pub fn is_partial(&self) -> bool {
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some()
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers
}
/// The PDF import options: `pages` resolved against the file's page count.
@@ -65,7 +79,14 @@ impl LoadOptions {
Some(parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?)
}
};
Ok(ImportOptions { pages, crop: self.crop, password: self.password.clone(), text_as: self.text_as, ..Default::default() })
Ok(ImportOptions {
pages,
crop: self.crop,
password: self.password.clone(),
text_as: self.text_as,
layers: self.layers,
..Default::default()
})
}
}
@@ -86,10 +107,17 @@ pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec<Str
Ok((r.document, r.warnings))
}
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box), its text as
/// outlines (it looks as printed), and the import notes.
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one
/// layer of what it shows (text as outlines: it looks as printed), and the import notes.
pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let opts = ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), text_as: TextAs::Outlines, ..Default::default() };
let opts = ImportOptions {
pages: Some(vec![page]),
crop: o.crop,
password: o.password.clone(),
text_as: TextAs::Outlines,
layers: false,
..Default::default()
};
vectorcraft_pdf::import_with_report(bytes, &opts).map(|r| (r.document, r.warnings)).map_err(err)
}
@@ -1,8 +1,9 @@
//! `document.open` PDF option `textAs`.
//! `document.open` PDF options `textAs` and `layers`.
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_pdf::TextAs;
use vectorcraft_testkit::pdf::{PdfPage, pdf};
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf, pdf_with_catalog};
use super::*;
@@ -38,9 +39,31 @@ fn text_opens_as_type_or_outlines() {
}
#[test]
fn load_options_read_text() {
let o = LoadOptions::from_params("x", &json!({"textAs": "Outlines"})).unwrap();
assert_eq!(o.text_as, TextAs::Outlines);
assert!(!o.is_partial());
assert_eq!(LoadOptions::default().text_as, TextAs::Text);
fn layers_option_keeps_or_flattens_optional_content() {
let (a, b) = (first_extra(1), first_extra(1) + 1);
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << /OFF [{b} 0 R] >> >> ");
let page = PdfPage {
resources: format!("/Properties << /A {a} 0 R /B {b} 0 R >>"),
..PdfPage::new(100.0, 100.0, "/OC /A BDC 0 g 0 0 10 10 re f EMC /OC /B BDC 0 g 20 20 10 10 re f EMC")
};
let bytes = pdf_with_catalog(&[page], &["<< /Type /OCG /Name (Ink) >>", "<< /Type /OCG /Name (Notes) >>"], &catalog, None);
let mut s = Session::new();
open(&mut s, &bytes, json!({})).unwrap();
let layers: Vec<(String, bool)> = s.active().unwrap().doc.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.visible)).collect();
assert_eq!(layers, [("Ink".to_string(), true), ("Notes".to_string(), false)]);
open(&mut s, &bytes, json!({"layers": false})).unwrap();
let doc = &s.active().unwrap().doc;
assert_eq!(doc.layers.len(), 1);
assert_eq!(doc.layers[0].children().unwrap().len(), 1, "the hidden art is left out");
assert!(matches!(doc.layers[0].kind, NodeKind::Layer { .. }));
assert!(open(&mut s, &bytes, json!({"layers": "no"})).unwrap_err().to_string().contains("layers must be true or false"));
}
#[test]
fn load_options_read_text_and_layers() {
let o = LoadOptions::from_params("x", &json!({"textAs": "Outlines", "layers": false})).unwrap();
assert_eq!((o.text_as, o.layers), (TextAs::Outlines, false));
assert!(o.is_partial(), "without its hidden layers, Save doesn't write the file back");
let d = LoadOptions::default();
assert_eq!((d.text_as, d.layers, d.is_partial()), (TextAs::Text, true, false));
}
+21 -10
View File
@@ -435,16 +435,18 @@ proptest! {
}
}
/// Hostile content over soft masks, isolated and knockout groups, tiling patterns, a patch mesh,
/// an unextended shading and fonts: the import's own readings of the file.
fn rich_resources_pdf(content: &str) -> Vec<u8> {
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
/// Hostile content over layers (optional content, one group off), soft masks, tiling patterns,
/// a patch mesh, an unextended shading and fonts: the import's own readings of the file.
fn rich_resources_pdf(content: &str, config: &str) -> Vec<u8> {
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
let x = first_extra(1);
let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len());
let extra = [
"<< /Type /OCG /Name (A) >>".to_string(),
"<< /Type /OCG /Name <FEFF> /Usage << /Print << /PrintState /OFF >> >> >>".to_string(),
stream(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB /I true /K true >>",
"1 g 0 0 50 50 re f /X BMC 0 g 0 0 5 5 re f EMC",
"1 g 0 0 50 50 re f /OC /MC0 BDC 0 g 0 0 5 5 re f EMC",
),
stream(
"/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 1e-9 10] /XStep 0.001 /YStep 10 /Resources << >>",
@@ -454,23 +456,29 @@ fn rich_resources_pdf(content: &str) -> Vec<u8> {
"/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode",
"000A0A0A250A3F0A5A255A3F5A5A5A5A3F5A255A0A3F0A250AFF000000FF000000FFFFFFFF>",
),
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /OC 6 0 R", "/X0 Do"),
];
let (form, pat, mesh) = (x, x + 1, x + 2);
let (a, b, form, pat, mesh, hidden) = (x, x + 1, x + 2, x + 3, x + 4, x + 5);
let resources = format!(
"/XObject << /X0 {form} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"
"/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"
);
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R 99 0 R] /D << {} >> >> ", config.replace("{B}", &format!("{b} 0 R")));
let extra: Vec<&str> = extra.iter().map(String::as_str).collect();
pdf_with(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &extra, None)
pdf_with_catalog(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &extra, &catalog, None)
}
fn arb_rich_op() -> impl Strategy<Value = String> {
prop_oneof![
arb_pdf_op(),
prop::sample::select(vec![
"/OC /MC0 BDC",
"/OC /MC1 BDC",
"/OC /Nope BDC",
"/Span << /MCID 3 >> BDC",
"/X BMC",
"EMC",
"/X0 Do",
"/X1 Do",
"/GS0 gs",
"/GS1 gs",
"/CS0 cs 1 0 0 /P0 scn",
@@ -492,12 +500,15 @@ proptest! {
#![proptest_config(config())]
#[test]
fn pdf_masks_patterns_and_text_never_panic(
fn pdf_layers_masks_patterns_and_text_never_panic(
ops in prop::collection::vec(arb_rich_op(), 0..40),
config in prop::sample::select(vec!["/OFF [{B}]", "/BaseState /OFF", "/BaseState /Unchanged /ON [{B}] /Locked [{B}]", "", "/OFF {B}"]),
layers in any::<bool>(),
outlines in any::<bool>(),
) {
let bytes = rich_resources_pdf(&ops.join("\n"));
let bytes = rich_resources_pdf(&ops.join("\n"), config);
let opts = vectorcraft_pdf::ImportOptions {
layers,
text_as: if outlines { vectorcraft_pdf::TextAs::Outlines } else { vectorcraft_pdf::TextAs::Text },
..Default::default()
};
+168 -31
View File
@@ -21,7 +21,7 @@ use vectorcraft_geom::{FillRule, PathData};
use crate::import_color::{Colors, Native};
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec};
use crate::import_scan::{Ocgs, Scan, scan_page};
use crate::import_scan::{Ocgs, Scan, all_on, hides_forms, scan_page};
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
use crate::import_text::{Families, Look, Placement, TextLine};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
@@ -38,12 +38,35 @@ pub fn import(bytes: &[u8]) -> Result<Document, PdfError> {
import_with_report(bytes, &ImportOptions::default()).map(|r| r.document)
}
/// A layer of the imported document, in paint order.
enum Slot {
/// A page's art outside optional content groups.
Page(Box<Node>),
/// An optional content group (index into [`Ocgs::list`]).
Group(usize),
}
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
let pdf = crate::pages::open(bytes, opts.password.as_deref())?;
let original = crate::pages::open(bytes, opts.password.as_deref())?;
let picked = crate::pages::picked(opts, original.pages().len())?;
let mut ocgs = Ocgs::read(&original);
let route = opts.layers && !ocgs.list.is_empty();
// Content that is off imports as hidden layers: an update of the file turns every group on
// (unless a form is hidden by its own group: its art couldn't be told apart).
let hide = route && ocgs.any_off();
let turned_on = (hide && !picked.iter().filter_map(|&n| original.pages().get(n)).any(|p| hides_forms(p, &mut ocgs)))
.then(|| all_on(bytes, &original))
.flatten()
.and_then(|b| crate::pages::open(&b, opts.password.as_deref()).ok())
.filter(|p| Ocgs::read(p).list.is_empty());
let mut notes = vec![];
if hide && turned_on.is_none() {
notes.push("the art of hidden layers couldn't be read and was left out".to_string());
}
let all_on = turned_on.is_some();
let pdf = turned_on.unwrap_or(original);
let pages = pdf.pages();
let picked = crate::pages::picked(opts, pages.len())?;
let ocgs = Ocgs::read(&pdf);
let sink: Arc<Mutex<Vec<String>>> = Arc::default();
let sink2 = sink.clone();
@@ -68,12 +91,15 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
doc.artboards.clear();
doc.layers.clear();
let taken: Vec<String> = doc.swatches_iter().map(|s| s.name.clone()).chain(doc.swatch_groups.iter().map(|g| g.name.clone())).collect();
let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken.clone()), opts.text_as);
let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken.clone()), opts.text_as, route);
b.taken = taken;
b.warnings = notes;
let cache = InterpreterCache::new();
let mut x = 0.0;
// Every page only a placeholder (text) over private data: the file's art isn't in its PDF part.
let mut placeholder = true;
let mut slots: Vec<Slot> = vec![];
let mut group_art: HashMap<usize, Vec<Arc<Node>>> = HashMap::new();
for (i, &number) in picked.iter().enumerate() {
let Some(page) = pages.get(number) else { continue };
// The chosen box sits at (x, 0); the page draws round it.
@@ -82,10 +108,11 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
let xf = Affine::translate((x - frame.x0, -frame.y0)) * init;
let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone());
b.page = ab;
let scan = scan_page(page, &ocgs, &mut b.fonts);
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts);
b.begin_page(scan);
interpret_page(page, &mut ctx, &mut b);
let children = b.end_page();
let parts = b.end_page();
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
placeholder &= crate::pages::has_private_data(page) && only_text(&children);
let mut right = ab.x1;
if opts.crop == CropTo::Bounding
@@ -102,15 +129,48 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
show_center_mark: false,
show_cross_hairs: false,
});
let mut layer = Node::layer(b.id(), &format!("Page {}", number + 1), LayerColor::Preset((i % 27) as u8));
if let NodeKind::Layer { children: c, .. } = &mut layer.kind {
*c = children;
let page_layer = |b: &mut Builder<'_>, art: Vec<Arc<Node>>| {
let mut layer = Node::layer(b.id(), &format!("Page {}", number + 1), LayerColor::Preset((i % 27) as u8));
if let NodeKind::Layer { children: c, .. } = &mut layer.kind {
*c = art;
}
Slot::Page(Box::new(layer))
};
if parts.is_empty() {
slots.push(page_layer(&mut b, vec![]));
}
for (key, art) in parts {
match key {
None => slots.push(page_layer(&mut b, art)),
Some(g) => {
let into = group_art.entry(g).or_insert_with(|| {
slots.push(Slot::Group(g));
vec![]
});
into.extend(art);
}
}
}
doc.layers.push(Arc::new(layer));
}
if placeholder {
return Err(PdfError::PlaceholderOnly);
}
for (n, slot) in slots.into_iter().enumerate() {
let layer = match slot {
Slot::Page(l) => *l,
Slot::Group(g) => {
let Some(ocg) = ocgs.list.get(g) else { continue };
let mut l = Node::layer(b.id(), &ocg.name, LayerColor::Preset((n % 27) as u8));
(l.visible, l.locked) = (ocg.on, ocg.locked);
if let NodeKind::Layer { children, printable, .. } = &mut l.kind {
*children = group_art.remove(&g).unwrap_or_default();
*printable = ocg.print;
}
l
}
};
doc.layers.push(Arc::new(layer));
}
for (k, blob) in b.images.drain() {
doc.images.insert(k, blob);
}
@@ -193,11 +253,13 @@ enum FrameKind {
struct Frame {
kind: FrameKind,
children: Vec<Arc<Node>>,
/// The first optional content group entered inside (where the frame's art goes).
group: Option<usize>,
}
impl Frame {
fn new(kind: FrameKind) -> Self {
Self { kind, children: vec![] }
Self { kind, children: vec![], group: None }
}
}
@@ -217,6 +279,7 @@ type PatternReadings = (u32, Vec<(Vec<Paint>, String)>);
/// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back.
struct Saved {
stack: Vec<Frame>,
root_groups: Vec<Option<usize>>,
blend: BlendMode,
mask: Option<Arc<MaskSpec>>,
page: Rect,
@@ -227,6 +290,8 @@ struct Builder<'p> {
colors: Colors<'p>,
next: u64,
stack: Vec<Frame>,
/// The optional content group of each child of the root frame.
root_groups: Vec<Option<usize>>,
blend: BlendMode,
page: Rect,
glyphs: Option<GlyphRun>,
@@ -249,9 +314,15 @@ struct Builder<'p> {
pattern_keys: HashMap<(u128, bool), PatternReadings>,
/// Swatch names in use.
taken: Vec<String>,
/// The page as walked by [`scan_page`], and how far the interpreter is through its groups.
/// The page as walked by [`scan_page`], and how far the interpreter is through it.
scan: Scan,
tag_at: usize,
group_at: usize,
aligned: bool,
/// Whether optional content groups become layers.
route: bool,
/// The marked-content sequences open: the group each marks.
marked: Vec<Option<usize>>,
/// A transparency group was just pushed: a form's (whose flags come next) or an image's.
pending: bool,
nested: u32,
@@ -329,11 +400,12 @@ fn bounds(nodes: &[Arc<Node>]) -> Option<Rect> {
}
impl<'p> Builder<'p> {
fn new(next: u64, colors: Colors<'p>, text_as: TextAs) -> Self {
fn new(next: u64, colors: Colors<'p>, text_as: TextAs, route: bool) -> Self {
Self {
colors,
next,
stack: vec![],
root_groups: vec![],
blend: BlendMode::Normal,
page: Rect::ZERO,
glyphs: None,
@@ -353,7 +425,11 @@ impl<'p> Builder<'p> {
pattern_keys: HashMap::new(),
taken: vec![],
scan: Scan::default(),
tag_at: 0,
group_at: 0,
aligned: true,
route,
marked: vec![],
pending: false,
nested: 0,
}
@@ -390,18 +466,29 @@ impl<'p> Builder<'p> {
fn begin_page(&mut self, scan: Scan) {
self.stack = vec![Frame::new(FrameKind::Root)];
self.root_groups.clear();
self.blend = BlendMode::Normal;
self.glyphs = None;
self.text = None;
self.mask = None;
self.masked.clear();
self.marked.clear();
self.pending = false;
(self.scan, self.group_at) = (scan, 0);
(self.scan, self.tag_at, self.group_at, self.aligned) = (scan, 0, 0, true);
}
/// The page's art.
fn end_page(&mut self) -> Vec<Arc<Node>> {
self.close_frames()
/// The page's art, split by optional content group (in the order they first paint).
fn end_page(&mut self) -> Vec<(Option<usize>, Vec<Arc<Node>>)> {
let children = self.close_frames();
let groups = std::mem::take(&mut self.root_groups);
let mut parts: Vec<(Option<usize>, Vec<Arc<Node>>)> = vec![];
for (n, g) in children.into_iter().zip(groups.into_iter().chain(std::iter::repeat(None))) {
match parts.iter_mut().find(|(k, _)| *k == g) {
Some((_, v)) => v.push(n),
None => parts.push((g, vec![n])),
}
}
parts
}
/// Finish the open runs and frames → the root frame's children.
@@ -413,10 +500,22 @@ impl<'p> Builder<'p> {
self.stack.pop().map(|f| f.children).unwrap_or_default()
}
/// Add `n` to the open frame.
fn emit(&mut self, n: Arc<Node>) {
if let Some(f) = self.stack.last_mut() {
f.children.push(n);
/// The group art drawn now belongs to (the innermost marked as one).
fn current_group(&self) -> Option<usize> {
self.marked.iter().rev().find_map(|g| *g)
}
/// Add `n` to the open frame; `group`: the optional content group it was drawn in, if it
/// knows one (else the current one).
fn emit(&mut self, n: Arc<Node>, group: Option<usize>) {
let root = self.stack.len() == 1;
let current = self.current_group();
let Some(f) = self.stack.last_mut() else { return };
f.children.push(n);
if root {
self.root_groups.push(group.or(current));
} else if f.group.is_none() {
f.group = group;
}
}
@@ -427,7 +526,7 @@ impl<'p> Builder<'p> {
if self.mask.is_some() {
self.masked.push(Arc::new(n));
} else {
self.emit(Arc::new(n));
self.emit(Arc::new(n), None);
}
}
@@ -445,7 +544,7 @@ impl<'p> Builder<'p> {
}
let art = std::mem::take(&mut self.masked);
let Some(spec) = self.mask.clone() else {
art.into_iter().for_each(|n| self.emit(n));
art.into_iter().for_each(|n| self.emit(n, None));
return;
};
match spec.as_ref() {
@@ -453,7 +552,7 @@ impl<'p> Builder<'p> {
MaskSpec::Opacity(a) => {
for mut n in art {
Arc::make_mut(&mut n).opacity *= a;
self.emit(n);
self.emit(n, None);
}
}
MaskSpec::Mask(m) => {
@@ -473,7 +572,7 @@ impl<'p> Builder<'p> {
g
}
};
self.emit(Arc::new(node));
self.emit(Arc::new(node), None);
}
}
}
@@ -490,10 +589,11 @@ impl<'p> Builder<'p> {
return;
}
let Some(f) = self.stack.pop() else { return };
let group = f.group;
let node = match f.kind {
FrameKind::Root => None,
FrameKind::Skip => {
f.children.into_iter().for_each(|c| self.emit(c));
f.children.into_iter().for_each(|c| self.emit(c, group));
None
}
FrameKind::Clip(clip) => {
@@ -502,7 +602,7 @@ impl<'p> Builder<'p> {
None
} else if noop.is_some_and(|r| bounds(&f.children).is_some_and(|b| contains(r, b))) {
// A rectangle around all of its art (a form's box) clips nothing.
f.children.into_iter().for_each(|c| self.emit(c));
f.children.into_iter().for_each(|c| self.emit(c, group));
None
} else {
let mut ch = vec![Arc::new(*clip)];
@@ -525,7 +625,7 @@ impl<'p> Builder<'p> {
if children.is_empty() {
None
} else if plain {
children.into_iter().for_each(|c| self.emit(c));
children.into_iter().for_each(|c| self.emit(c, group));
None
} else if let [only] = children.as_slice()
&& !only.is_container()
@@ -556,7 +656,7 @@ impl<'p> Builder<'p> {
}
};
if let Some(n) = node {
self.emit(Arc::new(n));
self.emit(Arc::new(n), group);
}
}
@@ -623,6 +723,7 @@ impl<'p> Builder<'p> {
self.flush();
let saved = Saved {
stack: std::mem::replace(&mut self.stack, vec![Frame::new(FrameKind::Root)]),
root_groups: std::mem::take(&mut self.root_groups),
blend: std::mem::replace(&mut self.blend, BlendMode::Normal),
mask: self.mask.take(),
page: std::mem::replace(&mut self.page, page),
@@ -632,7 +733,8 @@ impl<'p> Builder<'p> {
read(self);
let art = self.close_frames();
self.nested -= 1;
(self.stack, self.blend, self.mask, self.page, self.pending) = (saved.stack, saved.blend, saved.mask, saved.page, saved.pending);
(self.stack, self.root_groups, self.blend, self.mask, self.page, self.pending) =
(saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page, saved.pending);
Some(art)
}
@@ -1080,4 +1182,39 @@ impl<'a> Device<'a> for Builder<'_> {
self.pending = false;
self.pop_frame();
}
fn begin_marked_content(&mut self, tag: &[u8], _mcid: Option<i32>) {
if self.nested > 0 {
return;
}
self.flush();
let at = self.tag_at;
self.tag_at += 1;
let group = match self.scan.tags.get(at) {
Some((t, g)) if self.aligned && t.as_slice() == tag => *g,
_ => {
if self.route && self.aligned {
self.aligned = false;
self.warn("some art couldn't be told apart by layer and went to its page's layer");
}
None
}
}
.filter(|_| self.route);
if let Some(g) = group
&& self.stack.len() > 1
&& let Some(f) = self.stack.last_mut()
{
f.group.get_or_insert(g);
}
self.marked.push(group);
}
fn end_marked_content(&mut self) {
if self.nested > 0 {
return;
}
self.flush();
self.marked.pop();
}
}
+171 -21
View File
@@ -1,10 +1,14 @@
//! What the interpreter doesn't tell the import, read from the file itself: the isolate and
//! knockout flags of transparency groups, and the names of the fonts.
//! What the interpreter doesn't tell the import, read from the file itself: the optional content
//! groups (layers) with their states, which group each marked-content sequence belongs to, the
//! isolate and knockout flags of transparency groups, and the names of the fonts.
//!
//! hayro-interpret reports a transparency group without its flags. So each page's content is
//! hayro-interpret reports a marked-content sequence by its tag alone and a transparency group
//! without its flags, and skips the content of groups that are off. So each page's content is
//! walked here in the interpreter's order (page content, then form XObjects as they are drawn,
//! skipping what it skips, such as optional content that is off): the k-th form transparency
//! group it reports is the k-th group found here.
//! skipping what it skips): the k-th `BDC`/`BMC` it reports is the k-th one found here (its tag
//! checks that), and the k-th form transparency group is the k-th group found here. To import
//! content that is off as hidden layers, [`all_on`] gives the file an update that turns every
//! group on.
use std::collections::{HashMap, HashSet};
@@ -12,7 +16,7 @@ use hayro_interpret::CacheKey;
use hayro_syntax::Pdf;
use hayro_syntax::content::TypedIter;
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier, dict_or_stream};
use hayro_syntax::object::{Array, Dict, FromBytes, Name, Object, ObjectIdentifier, dict_or_stream};
use hayro_syntax::page::{Page, Resources};
/// The interpreter's limit on nested form XObjects.
@@ -22,9 +26,22 @@ const MAX_OPS: usize = 2_000_000;
/// The most groups read.
const MAX_GROUPS: usize = 10_000;
/// An optional content group: a layer.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Ocg {
pub name: String,
/// On in the file's default configuration (shown by viewers).
pub on: bool,
/// Printed (its print usage isn't off).
pub print: bool,
pub locked: bool,
}
/// The file's optional content groups.
#[derive(Default)]
pub(crate) struct Ocgs {
pub list: Vec<Ocg>,
index: HashMap<ObjectIdentifier, usize>,
/// The groups that are off (as the interpreter decides it).
off: HashSet<ObjectIdentifier>,
}
@@ -43,34 +60,101 @@ impl Ocgs {
.map(|a| a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).take(MAX_GROUPS).collect())
.unwrap_or_default()
};
let all = refs(&props, b"OCGs");
if config.get::<Name<'_>>(b"BaseState").is_some_and(|n| n.as_ref() == b"OFF") {
o.off.extend(refs(&props, b"OCGs"));
o.off.extend(all.iter().copied());
}
for id in refs(&config, b"ON") {
o.off.remove(&id);
}
o.off.extend(refs(&config, b"OFF"));
let locked: HashSet<_> = refs(&config, b"Locked").into_iter().collect();
for id in all {
if let Some(d) = xref.get::<Dict<'_>>(id) {
o.add(id, &d, locked.contains(&id));
}
}
o
}
/// Note group `id` (its dictionary `d`) → its index.
fn add(&mut self, id: ObjectIdentifier, d: &Dict<'_>, locked: bool) -> usize {
if let Some(i) = self.index.get(&id) {
return *i;
}
let name = d.get::<hayro_syntax::object::String<'_>>(b"Name").map(|s| text_string(s.as_bytes())).unwrap_or_default();
let print = d
.get::<Dict<'_>>(b"Usage")
.and_then(|u| u.get::<Dict<'_>>(b"Print"))
.and_then(|p| p.get::<Name<'_>>(b"PrintState"))
.is_none_or(|s| s.as_ref() != b"OFF");
let on = !self.off.contains(&id);
self.list.push(Ocg { name: if name.is_empty() { format!("Layer {}", self.list.len() + 1) } else { name }, on, print, locked });
self.index.insert(id, self.list.len() - 1);
self.list.len() - 1
}
/// Is any group off?
pub fn any_off(&self) -> bool {
self.list.iter().any(|g| !g.on)
}
/// The group content marked with `/OC` (`d`: the group or membership dictionary, `id` its
/// object) belongs to: a membership dictionary's first group.
fn group_of(&mut self, d: &Dict<'_>, id: ObjectIdentifier, xref: &hayro_syntax::xref::XRef) -> Option<usize> {
if d.get::<Name<'_>>(b"Type").is_some_and(|t| t.as_ref() == b"OCMD") {
let first = d.get::<Array<'_>>(b"OCGs").and_then(|a| a.raw_iter().find_map(|i| i.as_obj_ref())).or_else(|| d.get_ref(b"OCGs"))?;
let id = ObjectIdentifier::from(first);
return self.note(id, &xref.get::<Dict<'_>>(id)?);
}
self.note(id, d)
}
/// The index of group `id` (dictionary `d`), noted if new (up to [`MAX_GROUPS`]).
fn note(&mut self, id: ObjectIdentifier, d: &Dict<'_>) -> Option<usize> {
match self.index.get(&id) {
Some(i) => Some(*i),
None => (self.list.len() < MAX_GROUPS).then(|| self.add(id, d, false)),
}
}
}
/// A PDF text string: UTF-16 (with its byte order mark), UTF-8 (with one) or PDFDocEncoding
/// (read as Latin-1, which it matches for letters).
pub(crate) fn text_string(b: &[u8]) -> String {
if let Some(rest) = b.strip_prefix(&[0xFE, 0xFF]) {
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
return String::from_utf16_lossy(&units);
}
if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return String::from_utf8_lossy(rest).into_owned();
}
b.iter().map(|&c| c as char).collect()
}
/// One page as the interpreter walks it.
#[derive(Default)]
pub(crate) struct Scan {
/// Each marked-content sequence: its tag and the group (index into [`Ocgs::list`]) it marks.
pub tags: Vec<(Vec<u8>, Option<usize>)>,
/// Each form transparency group: isolated, knockout.
pub groups: Vec<(bool, bool)>,
}
struct Walker<'w> {
/// The groups that are off for the interpreter.
off: &'w HashSet<ObjectIdentifier>,
struct Walker<'w, 'p> {
ocgs: &'w mut Ocgs,
/// The groups that are off for the interpreter (none once [`all_on`] turned them on).
off: HashSet<ObjectIdentifier>,
xref: &'p hayro_syntax::xref::XRef,
fonts: &'w mut HashMap<u128, String>,
visible: Vec<bool>,
ops: usize,
out: Scan,
/// A form XObject hidden by its own optional content was met.
hidden_form: bool,
}
impl Walker<'_> {
impl Walker<'_, '_> {
fn is_visible(&self) -> bool {
self.visible.last().copied().unwrap_or(true)
}
@@ -130,12 +214,23 @@ impl Walker<'_> {
o => dict_or_stream(o)
.and_then(|(props, _)| props.get_ref(b"OC").map(|r| (props.get::<Dict<'_>>(b"OC").unwrap_or_default(), r))),
};
match oc {
Some((d, r)) => self.begin_oc(&d, Some(ObjectIdentifier::from(r))),
None => self.begin(true),
}
let group = match oc {
Some((d, r)) => {
let id = ObjectIdentifier::from(r);
self.begin_oc(&d, Some(id));
self.ocgs.group_of(&d, id, self.xref)
}
None => {
self.begin(true);
None
}
};
self.out.tags.push((bdc.0.to_vec(), group));
}
TypedInstruction::BeginMarkedContent(bmc) => {
self.begin(true);
self.out.tags.push((bmc.0.to_vec(), None));
}
TypedInstruction::BeginMarkedContent(_) => self.begin(true),
TypedInstruction::EndMarkedContent(_) => {
self.visible.pop();
}
@@ -152,6 +247,7 @@ impl Walker<'_> {
let oc = d.get::<Dict<'_>>(b"OC");
if let Some(oc) = &oc {
self.begin_oc(oc, d.get_ref(b"OC").map(ObjectIdentifier::from));
self.hidden_form |= !self.is_visible();
}
if self.is_visible() {
if let Some(g) = d.get::<Dict<'_>>(b"Group")
@@ -172,10 +268,64 @@ impl Walker<'_> {
}
}
/// Walk `page` as the interpreter will. Fonts found are noted in `fonts` (their cache key → base
/// font name).
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &Ocgs, fonts: &mut HashMap<u128, String>) -> Scan {
let mut w = Walker { off: &ocgs.off, fonts, visible: vec![], ops: 0, out: Scan::default() };
/// Walk `page` as the interpreter will: `all_on` when [`all_on`] turned every group on. Fonts
/// found are noted in `fonts` (their cache key → base font name).
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut HashMap<u128, String>) -> Scan {
walk_page(page, ocgs, all_on, fonts).out
}
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap<u128, String>) -> Walker<'w, 'p> {
let off = if all_on { HashSet::new() } else { ocgs.off.clone() };
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, out: Scan::default(), hidden_form: false };
w.walk(page.typed_operations(), page.resources(), 0);
w.out
w
}
/// Does `page` draw a form XObject that is off by its own optional content (not a marked-content
/// sequence)? Its art couldn't be told apart by group once every group is on.
pub(crate) fn hides_forms(page: &Page<'_>, ocgs: &mut Ocgs) -> bool {
walk_page(page, ocgs, false, &mut HashMap::new()).hidden_form
}
/// `bytes` with an update appended that drops the catalog's `/OCProperties`, so every group
/// draws; `None` when the file's structure can't take one.
pub(crate) fn all_on(bytes: &[u8], pdf: &Pdf) -> Option<Vec<u8>> {
let root = pdf.xref().root_id();
let catalog = pdf.xref().get::<Dict<'_>>(root)?;
let body = catalog.data().strip_suffix(b">>")?;
let prev = last_xref(bytes)?;
let trailer = trailer_at(bytes, prev)?;
let inner = trailer.strip_prefix(b"<<")?.strip_suffix(b">>")?;
let mut out = bytes.to_vec();
if !out.ends_with(b"\n") {
out.push(b'\n');
}
let at = out.len();
out.extend(format!("{} {} obj\n", root.obj_number, root.gen_number).bytes());
out.extend_from_slice(body);
// A later key wins: the groups' configuration is gone, so the interpreter draws them all.
out.extend_from_slice(b" /OCProperties null >>\nendobj\n");
let xref = out.len();
out.extend(format!("xref\n{} 1\n{at:010} {:05} n \ntrailer\n<<", root.obj_number, root.gen_number).bytes());
out.extend_from_slice(inner);
out.extend(format!(" /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n").bytes());
Some(out)
}
/// The offset the file's last `startxref` points to.
fn last_xref(bytes: &[u8]) -> Option<usize> {
let at = bytes.windows(9).rposition(|w| w == b"startxref")?;
let rest = bytes.get(at + 9..)?;
let digits: String = rest.iter().skip_while(|c| c.is_ascii_whitespace()).take_while(|c| c.is_ascii_digit()).map(|&c| c as char).collect();
digits.parse().ok().filter(|&p: &usize| p < bytes.len())
}
/// The trailer dictionary of the cross-reference section at `at` (a table's `trailer`, or a
/// cross-reference stream's dictionary), as written.
fn trailer_at(bytes: &[u8], at: usize) -> Option<&[u8]> {
let rest = bytes.get(at..)?;
let from = if rest.trim_ascii_start().starts_with(b"xref") { rest.windows(7).position(|w| w == b"trailer")? } else { 0 };
let open = from + rest.get(from..)?.windows(2).position(|w| w == b"<<")?;
let d = Dict::from_bytes(rest.get(open..)?)?;
Some(d.data())
}
+9 -2
View File
@@ -11,7 +11,9 @@
//! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve
//! Editing the native document rides along as an embedded file ([`editing()`]).
//! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a
//! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups, transparency groups (with isolation and knockout), soft
//! [`Document`]: one artboard per page, and a layer per page or per optional content group
//! (with its visibility, print state and lock; art that is off comes in as a hidden layer),
//! paths with fill/stroke, clip groups, transparency groups (with isolation and knockout), soft
//! masks → opacity masks, axial/radial shadings → gradients (stop opacity and unextended ends
//! kept), mesh shadings → gradient meshes, tiling patterns → pattern swatches, images (JPEG
//! passthrough, others re-encoded as PNG) and text as point type (or glyph outlines, see
@@ -84,11 +86,14 @@ pub struct ImportOptions {
pub password: Option<String>,
/// What text becomes.
pub text_as: TextAs,
/// Optional content groups become layers (else each page is one layer, without the art
/// that is off).
pub layers: bool,
}
impl Default for ImportOptions {
fn default() -> Self {
Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None, text_as: TextAs::default() }
Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None, text_as: TextAs::default(), layers: true }
}
}
@@ -188,6 +193,8 @@ mod tests_import_color;
#[cfg(test)]
mod tests_import_fidelity;
#[cfg(test)]
mod tests_import_layers;
#[cfg(test)]
mod tests_import_options;
#[cfg(test)]
mod tests_presets;
+136
View File
@@ -0,0 +1,136 @@
//! Optional content groups become layers: name, visibility, print state and lock; art that is
//! off comes in as a hidden layer.
use vectorcraft_color::Color;
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
use crate::tests_import_fidelity::stream;
use crate::*;
/// Groups "Shapes" and "Hidden" (not printed) as the first extra objects of a file of `pages`
/// pages, the catalog entries listing them with `config` (where `{A}` and `{B}` stand for them)
/// as the default configuration, and their object numbers.
fn groups(pages: usize, config: &str) -> ([String; 2], String, [usize; 2]) {
let (a, b) = (first_extra(pages), first_extra(pages) + 1);
let objs = [
"<< /Type /OCG /Name (Shapes) >>".to_string(),
"<< /Type /OCG /Name <FEFF00480069006400640065006E> /Usage << /Print << /PrintState /OFF >> >> >>".to_string(),
];
let catalog = format!(
"/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << /Order [{a} 0 R {b} 0 R] {} >> >> ",
config.replace("{A}", &format!("{a} 0 R")).replace("{B}", &format!("{b} 0 R"))
);
(objs, catalog, [a, b])
}
/// A page drawing red in "Shapes", blue in "Hidden" and green outside any group.
fn page(ids: [usize; 2]) -> PdfPage {
PdfPage {
resources: format!("/Properties << /MC0 {} 0 R /MC1 {} 0 R >>", ids[0], ids[1]),
..PdfPage::new(100.0, 100.0, "/OC /MC0 BDC 1 0 0 rg 10 10 30 30 re f EMC /OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC 0 1 0 rg 0 0 5 5 re f")
}
}
fn file(config: &str) -> Vec<u8> {
let (objs, catalog, ids) = groups(1, config);
pdf_with_catalog(&[page(ids)], &[&objs[0], &objs[1]], &catalog, None)
}
fn names(d: &Document) -> Vec<String> {
d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect()
}
/// The fill colours of a layer's art.
fn colors(l: &Node) -> Vec<Color> {
l.children().unwrap().iter().filter_map(|n| n.appearance.fill().and_then(|f| f.paint.color())).collect()
}
fn printable(l: &Node) -> bool {
matches!(l.kind, NodeKind::Layer { printable: true, .. })
}
#[test]
fn optional_content_groups_become_layers_and_off_art_a_hidden_layer() {
let d = import(&file("/OFF [{B}]")).unwrap();
// In paint order (bottom first): the groups, then the art outside them.
assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1"]);
let [shapes, hidden, rest] = [0, 1, 2].map(|i| d.layers[i].clone());
assert!(shapes.visible && printable(&shapes) && !shapes.locked);
assert_eq!(colors(&shapes), [Color::rgb(1.0, 0.0, 0.0)]);
// The group that is off is a hidden layer that still has its art; it doesn't print.
assert!(!hidden.visible && !printable(&hidden));
assert_eq!(colors(&hidden), [Color::rgb(0.0, 0.0, 1.0)]);
assert_eq!(colors(&rest), [Color::rgb(0.0, 1.0, 0.0)]);
}
#[test]
fn base_state_on_lists_and_locks_are_read() {
let d = import(&file("/BaseState /OFF /ON [{A}] /Locked [{A}]")).unwrap();
assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1"]);
assert!(d.layers[0].visible && d.layers[0].locked);
assert!(!d.layers[1].visible && !d.layers[1].locked);
}
#[test]
fn without_layers_each_page_is_one_layer_of_what_shows() {
let r = import_with_report(&file("/OFF [{B}]"), &ImportOptions { layers: false, ..Default::default() }).unwrap();
assert_eq!(names(&r.document), ["Page 1"]);
assert_eq!(colors(&r.document.layers[0]), [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 1.0, 0.0)]);
}
#[test]
fn pages_share_the_layers_of_their_groups() {
let (objs, catalog, ids) = groups(2, "/OFF [{B}]");
let bytes = pdf_with_catalog(&[page(ids), page(ids)], &[&objs[0], &objs[1]], &catalog, None);
let d = import(&bytes).unwrap();
assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1", "Page 2"]);
assert_eq!(colors(&d.layers[0]).len(), 2, "both pages' art");
assert_eq!(colors(&d.layers[1]).len(), 2);
// The second page's art sits on its artboard.
let x = d.layers[0].children().unwrap()[1].geometric_bounds().unwrap().x0;
assert!(x > d.artboards[1].rect.x0, "{x}");
}
#[test]
fn groups_marked_inside_forms_and_clips_reach_their_layers() {
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let form_no = first_extra(1) + 2;
let form = stream(
&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /Properties << /MC1 {} 0 R >> >>", ids[1]),
"/OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC",
);
let page = PdfPage {
resources: format!("/Properties << /MC0 {} 0 R >> /XObject << /X0 {form_no} 0 R >>", ids[0]),
..PdfPage::new(100.0, 100.0, "q 0 0 25 25 re W n /OC /MC0 BDC 1 0 0 rg 10 10 30 30 re f EMC Q /X0 Do")
};
let d = import(&pdf_with_catalog(&[page], &[&objs[0], &objs[1], &form], &catalog, None)).unwrap();
assert_eq!(names(&d), ["Shapes", "Hidden"]);
let shapes = &d.layers[0].children().unwrap()[0];
assert!(matches!(shapes.kind, NodeKind::Group { clip: true, .. }), "the clip group goes with its art");
assert!(!d.layers[1].visible);
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
}
#[test]
fn a_form_hidden_by_its_own_group_stays_hidden() {
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let form_no = first_extra(1) + 2;
let form = stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /OC {} 0 R", ids[1]), "0 0 1 rg 50 50 30 30 re f");
let page = PdfPage { resources: format!("/XObject << /X0 {form_no} 0 R >>"), ..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f /X0 Do") };
let r = import_with_report(&pdf_with_catalog(&[page], &[&objs[0], &objs[1], &form], &catalog, None), &ImportOptions::default()).unwrap();
assert_eq!(names(&r.document), ["Page 1"]);
assert_eq!(colors(&r.document.layers[0]), [Color::rgb(1.0, 0.0, 0.0)], "the hidden form isn't shown");
assert!(r.warnings.iter().any(|w| w.contains("hidden layers")), "{:?}", r.warnings);
}
#[test]
fn an_encrypted_file_keeps_its_hidden_layers() {
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let bytes = pdf_with_catalog(&[page(ids)], &[&objs[0], &objs[1]], &catalog, Some("pw"));
let r = import_with_report(&bytes, &ImportOptions { password: Some("pw".into()), ..Default::default() }).unwrap();
let d = r.document;
assert_eq!(d.layers.len(), 3, "{:?}", r.warnings);
assert!(d.layers[0].visible && !d.layers[1].visible);
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
}
+6 -1
View File
@@ -65,6 +65,11 @@ pub fn first_extra(pages: usize) -> usize {
/// [`pdf`] with `extra` objects (written as given, not encrypted: streams such as functions and
/// ICC profiles that resources refer to by number, from [`first_extra`] on).
pub fn pdf_with(pages: &[PdfPage], extra: &[&str], password: Option<&str>) -> Vec<u8> {
pdf_with_catalog(pages, extra, "", password)
}
/// [`pdf_with`] with more entries in the catalog (`/OCProperties << … >>`).
pub fn pdf_with_catalog(pages: &[PdfPage], extra: &[&str], catalog: &str, password: Option<&str>) -> Vec<u8> {
let id = md5(b"vectorcraft test file");
// (O, U, file key) of the standard security handler, revision 2, permissions -4.
let crypt = password.map(|pw| {
@@ -80,7 +85,7 @@ pub fn pdf_with(pages: &[PdfPage], extra: &[&str], password: Option<&str>) -> Ve
});
let mut objects: Vec<Vec<u8>> = vec![];
let kids: Vec<String> = (0..pages.len()).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
objects.push(b"<< /Type /Catalog /Pages 2 0 R >>".to_vec());
objects.push(format!("<< /Type /Catalog /Pages 2 0 R {catalog}>>").into_bytes());
objects.push(format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids.join(" "), pages.len()).into_bytes());
for (i, p) in pages.iter().enumerate() {
let mut d = format!("<< /Type /Page /Parent 2 0 R /MediaBox {} /Contents {} 0 R /Resources << {} >>", rect(p.media), 4 + 2 * i, p.resources);
+4 -2
View File
@@ -240,10 +240,12 @@ the backdrop colour gives Clip, an inverting transfer function Invert), transpar
tiling patterns become pattern swatches, patch and triangle mesh shadings become gradient meshes, and gradients keep
their stop opacity and stop where the shading doesn't extend. Text becomes point type, one object per run of a line in
the file's font (by name; fonts that aren't available are listed in `warnings` and show in the fallback font) —
`textAs: "outlines"` keeps glyph outlines instead:
`textAs: "outlines"` keeps glyph outlines instead. Optional content groups (the layers of PDF and PDF-compatible `.ai`
files) become layers with their name, visibility, print state and lock, art that is off coming in as a hidden layer;
art outside them goes to a layer per page. `layers: false` gives one layer per page of only what shows:
```json
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/map.pdf","textAs":"outlines"}}}
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/map.pdf","textAs":"outlines","layers":false}}}
```
Preserve Editing (`preserveEditing`, on in the `VectorCraft Default` preset) embeds the native document in the PDF as