mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
a51083733e
commit
8046b9be84
@@ -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
|
||||
),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user