mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04: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 <[email protected]>
This commit is contained in:
1 parent
a51083733e
commit
8046b9be84
10 files changed
+586
-87
No files matched your search
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user