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 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 13:05:12 +07:00
co-authored by Claude Opus 5.5
parent a51083733e
commit 8046b9be84
10 changed files with 586 additions and 87 deletions
+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));
}