diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a0e8b3ce..0d28b3a3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -887,6 +887,14 @@ fn default_graphic_styles() -> Vec { .collect() } +impl Document { + /// Make the next id allocated at least `next`: ids handed out outside the layer tree (such + /// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see. + pub fn reserve_ids(&mut self, next: u64) { + self.next_id = self.next_id.max(next); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index e18efbbc..dd1d24db 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -60,7 +60,7 @@ pub fn specs() -> Vec { "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), 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 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 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 ), @@ -655,6 +655,8 @@ mod tests_pdfcolor; #[cfg(test)] mod tests_pdfedit; #[cfg(test)] +mod tests_pdffidelity; +#[cfg(test)] mod tests_pdfimport; #[cfg(test)] mod tests_svg; diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 5c600ef9..a1712acc 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -4,14 +4,14 @@ use serde_json::{Value, json}; use vectorcraft_doc::{ColorMode, Document}; -use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError}; +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) 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, @@ -22,11 +22,23 @@ pub struct LoadOptions { /// The colour mode the document opens in, its colours converted as Document Color Mode does /// (default: the file's; a PDF painted mostly in CMYK opens in CMYK). pub color_mode: Option, + /// 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` and `colorMode` (`rgb` | `cmyk`). + /// `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 { let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) { None => None, @@ -35,15 +47,7 @@ impl LoadOptions { Some(Value::Number(n)) => Some(n.to_string()), Some(v) => return Err(bad(cmd, format!("pages must be a range such as \"2-3\" or a page number, not {v}"))), }; - let crop = match str_param(p, "cropTo").or_else(|| str_param(p, "crop")) { - None => CropTo::default(), - Some(c) => CropTo::ALL - .iter() - .zip(CropTo::IDS) - .find(|(_, id)| id.eq_ignore_ascii_case(c)) - .map(|(c, _)| *c) - .ok_or_else(|| bad(cmd, format!("cropTo must be one of {}", CropTo::IDS.join(", "))))?, - }; + let crop = choice(cmd, "cropTo", str_param(p, "cropTo").or_else(|| str_param(p, "crop")))?; let password = str_param(p, "password").filter(|s| !s.is_empty()).map(str::to_string); let color_mode = match str_param(p, "colorMode").map(str::to_ascii_lowercase).as_deref() { None => None, @@ -51,13 +55,19 @@ impl LoadOptions { Some("cmyk") => Some(ColorMode::Cmyk), Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))), }; - Ok(Self { pages, crop, password, color_mode }) + let text_as = choice(cmd, "textAs", str_param(p, "textAs"))?; + 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. @@ -69,20 +79,45 @@ impl LoadOptions { Some(parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?) } }; - Ok(ImportOptions { pages, crop: self.crop, password: self.password.clone(), ..Default::default() }) + Ok(ImportOptions { + pages, + crop: self.crop, + password: self.password.clone(), + text_as: self.text_as, + layers: self.layers, + ..Default::default() + }) } } +/// The option of `T` named `id` (any case), for param `key`; not given: the default. +fn choice(cmd: &str, key: &str, id: Option<&str>) -> Result { + let Some(id) = id else { return Ok(T::default()) }; + T::ALL + .iter() + .zip(T::IDS) + .find(|(_, i)| i.eq_ignore_ascii_case(id)) + .map(|(t, _)| *t) + .ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", ")))) +} + /// Import the pages `o` pick → the document and import notes. pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec)> { let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?; Ok((r.document, r.warnings)) } -/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) 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)> { - let opts = ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), ..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) } diff --git a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs new file mode 100644 index 00000000..69c8bf3e --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs @@ -0,0 +1,69 @@ +//! `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, first_extra, pdf, pdf_with_catalog}; + +use super::*; + +fn open(s: &mut Session, bytes: &[u8], extra: Value) -> crate::Result { + let mut p = json!({"name": "art.pdf", "dataBase64": vectorcraft_format::base64_encode(bytes)}); + if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) { + o.extend(e); + } + s.execute("document.open", &p) +} + +fn kinds(s: &Session) -> Vec<&'static str> { + let mut out = vec![]; + s.active().unwrap().doc.walk(|n| out.push(n.kind_label())); + out +} + +#[test] +fn text_opens_as_type_or_outlines() { + let page = PdfPage { + resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(), + ..PdfPage::new(200.0, 100.0, "BT /F1 12 Tf 10 50 Td (Hello) Tj ET") + }; + let bytes = pdf(&[page], None); + let mut s = Session::new(); + open(&mut s, &bytes, json!({})).unwrap(); + assert!(kinds(&s).contains(&"Type"), "{:?}", kinds(&s)); + let r = open(&mut s, &bytes, json!({"textAs": "outlines"})).unwrap(); + assert!(!kinds(&s).contains(&"Type")); + assert!(r["warnings"].to_string().contains("outlines"), "{r}"); + let e = open(&mut s, &bytes, json!({"textAs": "glyphs"})).unwrap_err().to_string(); + assert!(e.contains("textAs must be one of text, outlines"), "{e}"); +} + +#[test] +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)); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index ca5816fa..98860b60 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -435,6 +435,87 @@ proptest! { } } +/// 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 { + 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 /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 /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 << >>", + "0 0 5 5 re f /P0 scn 0 0 1 1 re f", + ), + stream( + "/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 (a, b, form, pat, mesh, hidden) = (x, x + 1, x + 2, x + 3, x + 4, x + 5); + let resources = format!( + "/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_catalog(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &extra, &catalog, None) +} + +fn arb_rich_op() -> impl Strategy { + 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", + "/Pattern cs /P0 scn", + "/Sh0 sh", + "/Sh1 sh", + "BT /F1 12 Tf 10 10 Td (Hi there) Tj [(a) -900 (b)] TJ ET", + "7 Tr", + "q 10 10 50 50 re W n", + "Q", + "0 0 100 100 re f", + "10 10 20 20 re B", + ]) + .prop_map(String::from), + ] +} + +proptest! { + #![proptest_config(config())] + + #[test] + 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::(), + outlines in any::(), + ) { + 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() + }; + survive(&ops.join(" "), || vectorcraft_pdf::import_with_report(&bytes, &opts).ok().map(|r| r.document))?; + } +} + // ---------- library files ---------- /// The `data` of a library file `cmd` writes for the rich document. diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 0edd1d9f..05f6b8ea 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -4,37 +4,69 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use hayro_interpret::font::Glyph; -use hayro_interpret::pattern::Pattern; -use hayro_interpret::shading::ShadingType; +use hayro_interpret::hayro_cmap::BfString; +use hayro_interpret::pattern::{Pattern, TilingPattern}; use hayro_interpret::{ - ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData, PathDrawMode, - SoftMask, StrokeProps, interpret_page, + CacheKey, ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData, + MaskType, PathDrawMode, SoftMask, StrokeProps, interpret_page, }; use hayro_syntax::object::Name; -use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; -use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use kurbo::{Affine, BezPath, Rect, Shape}; +use vectorcraft_color::{BlendMode, Color, Paint, Swatch}; use vectorcraft_doc::{ - Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, - NodeKind, StrokeLayer, + Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin, + Node, NodeId, NodeKind, OpacityMask, PatternDef, StrokeLayer, }; use vectorcraft_geom::{FillRule, PathData}; use crate::import_color::{Colors, Native}; -use crate::{CropTo, ImportOptions, ImportReport, PdfError}; +use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec}; +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}; /// The name of the paths text imports as. const TEXT_OUTLINES: &str = ""; +/// The deepest soft masks, patterns and type 3 glyphs are read within each other. +const MAX_NESTED: u32 = 8; +/// A tiling pattern read this many times with the same art is taken to always draw it. +const PATTERN_REUSE: u32 = 16; /// Import a PDF (or PDF-compatible `.ai`) with default options. pub fn import(bytes: &[u8]) -> Result { 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), + /// 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 { - 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 sink: Arc>> = Arc::default(); let sink2 = sink.clone(); @@ -58,12 +90,16 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result = 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, 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 = vec![]; + let mut group_art: HashMap>> = 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. @@ -72,9 +108,11 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result> = 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 @@ -91,15 +129,48 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result, art: Vec>| { + 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); } @@ -109,10 +180,25 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result]) -> bool { *text = true; true } + NodeKind::Text(_) => { + *text = true; + true + } NodeKind::Group { children, .. } => walk(children, text), _ => false, }) @@ -154,13 +244,23 @@ enum FrameKind { Group { opacity: f32, blend: BlendMode, - masked: bool, + mask: Option>, + isolate: bool, + knockout: bool, }, } struct Frame { kind: FrameKind, children: Vec>, + /// The first optional content group entered inside (where the frame's art goes). + group: Option, +} + +impl Frame { + fn new(kind: FrameKind) -> Self { + Self { kind, children: vec![], group: None } + } } /// Glyphs drawn consecutively with the same paint, merged into one path. @@ -172,16 +272,60 @@ struct GlyphRun { scale: f64, } +/// How often a tiling pattern was read, and the art (as its paints) each reading drew with the +/// swatch made for it. +type PatternReadings = (u32, Vec<(Vec, String)>); + +/// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back. +struct Saved { + stack: Vec, + root_groups: Vec>, + blend: BlendMode, + mask: Option>, + page: Rect, + pending: bool, +} + struct Builder<'p> { colors: Colors<'p>, next: u64, stack: Vec, + /// The optional content group of each child of the root frame. + root_groups: Vec>, blend: BlendMode, page: Rect, glyphs: Option, + text: Option, + text_as: TextAs, images: HashMap, image_keys: HashMap, warnings: Vec, + /// Fonts by cache key → base font name (from [`scan_page`]). + fonts: HashMap, + font_names: HashMap, + families: Option, + missing_fonts: Vec, + /// The soft mask of the graphics state, the art drawn through it so far, and the masks read. + mask: Option>, + masked: Vec>, + masks: HashMap>, + /// Pattern swatches made, and per (pattern, stroke) the art signatures read and their names. + patterns: Vec, + pattern_keys: HashMap<(u128, bool), PatternReadings>, + /// Swatch names in use. + taken: Vec, + /// 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>, + /// A transparency group was just pushed: a form's (whose flags come next) or an image's. + pending: bool, + nested: u32, } fn blend(b: hayro_interpret::BlendMode) -> BlendMode { @@ -237,22 +381,57 @@ fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> Stro st } -fn round3(v: f32) -> f32 { +pub(crate) fn round3(v: f32) -> f32 { (v * 1000.0).round() / 1000.0 } +/// The paints of `nodes`' leaves, in order: what tells two readings of a pattern cell apart. +fn paints(nodes: &[Arc]) -> Vec { + let mut out = vec![]; + for n in nodes { + n.walk(&mut |c| out.extend(c.appearance.items.iter().map(|i| i.paint().clone()))); + } + out +} + +/// Visual bounds of `nodes`; `None` when one has none. +fn bounds(nodes: &[Arc]) -> Option { + nodes.iter().try_fold(None, |acc, n| n.visual_bounds().map(|b| Some(acc.map_or(b, |a: Rect| a.union(b)))))? +} + impl<'p> Builder<'p> { - fn new(next: u64, colors: Colors<'p>) -> 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, + text: None, + text_as, images: HashMap::new(), image_keys: HashMap::new(), warnings: vec![], + fonts: HashMap::new(), + font_names: HashMap::new(), + families: None, + missing_fonts: vec![], + mask: None, + masked: vec![], + masks: HashMap::new(), + patterns: vec![], + pattern_keys: HashMap::new(), + taken: vec![], + scan: Scan::default(), + tag_at: 0, + group_at: 0, + aligned: true, + route, + marked: vec![], + pending: false, + nested: 0, } } @@ -262,85 +441,222 @@ impl<'p> Builder<'p> { id } + /// A deep copy of `node` with fresh ids. + fn reid(&mut self, node: &Node) -> Node { + let mut n = node.clone(); + n.id = self.id(); + if let Some(ch) = n.children_mut() { + let old = std::mem::take(ch); + *ch = old.iter().map(|c| Arc::new(self.reid(c))).collect(); + } + n + } + + /// `m` with fresh ids for its art: a mask read once can mask several objects. + fn fresh_mask(&mut self, mut m: OpacityMask) -> OpacityMask { + m.art = Arc::new(self.reid(&m.art)); + m + } + fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } - fn begin_page(&mut self) { - self.stack = vec![Frame { kind: FrameKind::Root, children: vec![] }]; + 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.tag_at, self.group_at, self.aligned) = (scan, 0, 0, true); } - fn end_page(&mut self) -> Vec> { - self.flush_glyphs(); + /// The page's art, split by optional content group (in the order they first paint). + fn end_page(&mut self) -> Vec<(Option, Vec>)> { + let children = self.close_frames(); + let groups = std::mem::take(&mut self.root_groups); + let mut parts: Vec<(Option, Vec>)> = 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. + fn close_frames(&mut self) -> Vec> { + self.flush(); while self.stack.len() > 1 { self.pop_frame(); } self.stack.pop().map(|f| f.children).unwrap_or_default() } + /// The group art drawn now belongs to (the innermost marked as one). + fn current_group(&self) -> Option { + 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, group: Option) { + 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; + } + } + fn push_node(&mut self, mut n: Node) { if self.blend != BlendMode::Normal && !n.is_container() { n.blend = self.blend; } - if let Some(f) = self.stack.last_mut() { - f.children.push(Arc::new(n)); + if self.mask.is_some() { + self.masked.push(Arc::new(n)); + } else { + self.emit(Arc::new(n), None); } } + /// Finish the open glyph runs and the art drawn through the current soft mask. + fn flush(&mut self) { + self.flush_glyphs(); + self.flush_text(); + self.flush_masked(); + } + + /// The art drawn through the graphics state's soft mask so far, as one masked object. + fn flush_masked(&mut self) { + if self.masked.is_empty() { + return; + } + let art = std::mem::take(&mut self.masked); + let Some(spec) = self.mask.clone() else { + art.into_iter().for_each(|n| self.emit(n, None)); + return; + }; + match spec.as_ref() { + // Each object is drawn through the mask on its own. + MaskSpec::Opacity(a) => { + for mut n in art { + Arc::make_mut(&mut n).opacity *= a; + self.emit(n, None); + } + } + MaskSpec::Mask(m) => { + let m = self.fresh_mask(m.clone()); + let node = match <[Arc; 1]>::try_from(art) { + Ok([one]) if one.mask.is_none() => { + fold_stop_opacity(&one, &m).unwrap_or_else(|| Node { mask: Some(Box::new(m)), ..Arc::unwrap_or_clone(one) }) + } + Ok([one]) => { + let mut g = Node::group(self.id(), vec![one]); + g.mask = Some(Box::new(m)); + g + } + Err(all) => { + let mut g = Node::group(self.id(), all); + g.mask = Some(Box::new(m)); + g + } + }; + self.emit(Arc::new(node), None); + } + } + } + + /// A clip path node. + fn clip_node(&mut self, path: &BezPath, rule: FillRule) -> Node { + let mut clip = Node::new(self.id(), NodeKind::Path { path: PathData::from_bezpath(path), rule, live: None, clipping: true, guide: false }); + clip.name = Some("".into()); + clip + } + fn pop_frame(&mut self) { if self.stack.len() <= 1 { return; } let Some(f) = self.stack.pop() else { return }; + let group = f.group; let node = match f.kind { FrameKind::Root => None, FrameKind::Skip => { - if let Some(p) = self.stack.last_mut() { - p.children.extend(f.children); - } + f.children.into_iter().for_each(|c| self.emit(c, group)); None } FrameKind::Clip(clip) => { + let noop = clip.path_data().map(|p| p.to_bezpath()).filter(is_rectangle).map(|p| p.bounding_box().inflate(0.01, 0.01)); if f.children.is_empty() { 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, group)); + None } else { let mut ch = vec![Arc::new(*clip)]; ch.extend(f.children); Some(Node::new(self.id(), NodeKind::Group { children: ch, clip: true })) } } - FrameKind::Group { opacity, blend, masked } => { - if f.children.is_empty() { + FrameKind::Group { opacity, blend, mask, isolate, knockout } => { + let children = f.children; + // A constant alpha mask is opacity (of a group that isn't isolated). + let (opacity, mask) = match mask.as_deref() { + Some(MaskSpec::Opacity(a)) => (opacity * a, None), + Some(MaskSpec::Mask(m)) => (opacity, Some(m.clone())), + None => (opacity, None), + }; + // Isolation only shows with blending inside; knockout with several objects. + let isolate = isolate && Node::children_blend(&children, &mut |c| c.blends_through()); + let knockout = knockout && children.len() > 1; + let plain = opacity >= 0.999 && blend == BlendMode::Normal && mask.is_none() && !isolate && !knockout; + if children.is_empty() { None - } else if opacity >= 0.999 && blend == BlendMode::Normal && !masked { - if let Some(p) = self.stack.last_mut() { - p.children.extend(f.children); - } + } else if plain { + children.into_iter().for_each(|c| self.emit(c, group)); None - } else if f.children.len() == 1 && !f.children[0].is_container() && !masked { - // A single object in a group: fold opacity/blend into the object itself. - let mut only = (*f.children[0]).clone(); + } else if let [only] = children.as_slice() + && !only.is_container() + && !isolate + && !knockout + && (mask.is_none() || only.mask.is_none()) + { + // A single object in a group: fold opacity, blend and mask into the object. + let mut only = (**only).clone(); only.opacity *= opacity; if blend != BlendMode::Normal { only.blend = blend; } + if let Some(m) = mask { + let m = self.fresh_mask(m); + only = fold_stop_opacity(&only, &m).unwrap_or(Node { mask: Some(Box::new(m)), ..only }); + } Some(only) } else { - let mut g = Node::group(self.id(), f.children); - g.opacity = opacity; - g.blend = blend; + let mut g = Node::group(self.id(), children); + (g.opacity, g.blend, g.isolate) = (opacity, blend, isolate); + if knockout { + g.knockout = Knockout::On; + } + g.mask = mask.map(|m| Box::new(self.fresh_mask(m))); Some(g) } } }; - if let Some(n) = node - && let Some(p) = self.stack.last_mut() - { - p.children.push(Arc::new(n)); + if let Some(n) = node { + self.emit(Arc::new(n), group); } } @@ -364,9 +680,15 @@ impl<'p> Builder<'p> { self.push_node(n); } - /// Convert a hayro paint to a VectorCraft paint and opacity. `bbox` is the painted area in - /// document coordinates (for patterns). - fn paint(&mut self, p: &hayro_interpret::Paint<'_>) -> (Paint, f32) { + fn flush_text(&mut self) { + let Some((t, opacity)) = self.text.take().and_then(TextLine::finish) else { return }; + let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); + n.opacity = opacity; + self.push_node(n); + } + + /// Convert a hayro paint to a VectorCraft paint and opacity (`stroke`: for a stroke). + fn paint(&mut self, p: &hayro_interpret::Paint<'_>, stroke: bool) -> (Paint, f32) { match p { hayro_interpret::Paint::Color(c) => { let [r, g, b, a] = c.to_rgba().components(); @@ -375,20 +697,109 @@ impl<'p> Builder<'p> { } hayro_interpret::Paint::Pattern(pat) => match pat.as_ref() { Pattern::Shading(sp) => match shading_gradient(sp, &mut self.colors) { - Some(g) => (Paint::Gradient(Box::new(g)), sp.opacity), + Some((g, _)) => (Paint::Gradient(Box::new(g)), sp.opacity), None => { - self.warn("mesh/function shadings are imported as a flat colour"); + self.warn("function-based shadings (and very large meshes) are imported as a flat colour"); (Paint::solid(Color::rgb(0.5, 0.5, 0.5)), sp.opacity) } }, - Pattern::Tiling(_) => { - self.warn("tiling patterns are imported as a flat grey"); - (Paint::solid(Color::rgb(0.5, 0.5, 0.5)), 1.0) - } + Pattern::Tiling(t) => match self.tiling(t, stroke) { + Some(paint) => (paint, 1.0), + None => { + self.warn("tiling patterns nested too deeply are imported as a flat grey"); + (Paint::solid(Color::rgb(0.5, 0.5, 0.5)), 1.0) + } + }, }, } } + /// Read a soft mask, pattern cell or type 3 glyph (`read`) on its own: its art, in the + /// coordinates `page` has. `None` when nested too deeply. + fn nested(&mut self, page: Rect, read: impl FnOnce(&mut Self)) -> Option>> { + if self.nested >= MAX_NESTED { + return None; + } + 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), + pending: std::mem::take(&mut self.pending), + }; + self.nested += 1; + read(self); + let art = self.close_frames(); + self.nested -= 1; + (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) + } + + /// What soft mask `m` does (read once per mask and placement). + fn soft_mask(&mut self, m: &SoftMask<'_>) -> Arc { + let key = m.cache_key(); + if let Some(s) = self.masks.get(&key) { + return s.clone(); + } + let page = self.page; + let spec = match self.nested(page, |b| m.interpret(b)) { + Some(art) => { + let [r, g, b, _] = m.background_color().to_rgba().components(); + let backdrop = luminance(&Color::rgb(r, g, b)); + let inverted = m.transfer_function().is_some_and(|tf| tf.apply(0.0) > tf.apply(1.0)); + let id = self.id(); + mask_spec(art, m.mask_type() == MaskType::Alpha, backdrop, inverted, page, |all| Node::group(id, all)) + } + None => { + self.warn("soft masks nested too deeply were left out (content drawn unmasked)"); + MaskSpec::Opacity(1.0) + } + }; + let spec = Arc::new(spec); + self.masks.insert(key, spec.clone()); + spec + } + + /// Tiling pattern `t` as a pattern swatch's paint (the swatch made the first time). + fn tiling(&mut self, t: &TilingPattern<'_>, stroke: bool) -> Option { + let key = (t.cache_key(), stroke); + let tile = Rect::from_origin_size(t.bbox.origin(), (t.x_step.abs().max(1e-3) as f64, t.y_step.abs().max(1e-3) as f64)); + let xf = t.matrix * Affine::translate(tile.origin().to_vec2()); + // An uncoloured pattern draws in the current colour: read it again unless it always + // drew the same. + if let Some((n, seen)) = self.pattern_keys.get(&key) + && *n >= PATTERN_REUSE + && let [(_, name)] = seen.as_slice() + { + return Some(Paint::Pattern { pattern: name.clone(), xf }); + } + let art = self.nested(Rect::ZERO, |b| { + // A cell whose content can't be decoded is empty. + let _ = t.interpret(b, Affine::IDENTITY, stroke); + })?; + let sig = paints(&art); + let entry = self.pattern_keys.entry(key).or_default(); + entry.0 += 1; + let name = match entry.1.iter().find(|(s, _)| *s == sig) { + Some((_, name)) => name.clone(), + None => { + let name = (self.patterns.len() + 1..).map(|i| format!("Pattern {i}")).find(|n| !self.taken.contains(n)).unwrap_or_default(); + self.taken.push(name.clone()); + let mut def = PatternDef::new(&name, art); + def.tile = tile; + self.patterns.push(def); + if let Some(e) = self.pattern_keys.get_mut(&key) { + e.1.push((sig, name.clone())); + } + name + } + }; + Some(Paint::Pattern { pattern: name, xf }) + } + fn add_image(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine) { let (k, w, h) = if let Some(v) = self.image_keys.get(&key).cloned() { v @@ -406,70 +817,93 @@ impl<'p> Builder<'p> { let id = self.id(); self.push_node(Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() }))); } -} -/// Axial/radial shading → gradient paint (stops sampled from the shading function, in the -/// shading's own colour model). -fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern, colors: &mut Colors<'_>) -> Option { - let ShadingType::RadialAxial { coords, domain, function, axial, .. } = sp.shading.shading_type.as_ref() else { - return None; - }; - let m = sp.matrix; - let cs = &sp.shading.color_space; - let at = |t: f32| function.eval(&smallvec::smallvec![domain[0] + (domain[1] - domain[0]) * t]); - let space = colors.shading_space(cs, at(0.0)?.len()); - // RGB to decide which samples are stops; the stops keep the shading's model. - let sample = |t: f32| -> Option<(Color, f32)> { - let [r, g, b, a] = cs.to_rgba(&at(t)?, 1.0, false).components(); - Some((Color::rgb(round3(r), round3(g), round3(b)), a)) - }; - // Sample densely, then drop samples that linear interpolation reproduces. - const N: usize = 64; - let mut pts: Vec<(f32, [f32; 3])> = Vec::with_capacity(N + 1); - for i in 0..=N { - let t = i as f32 / N as f32; - let (c, _) = sample(t)?; - pts.push((t, c.to_rgb())); - } - let mut keep = vec![0usize]; - for i in 1..N { - let a = pts[keep.last().copied().unwrap_or(0)]; - let b = pts[i + 1]; - let u = (pts[i].0 - a.0) / (b.0 - a.0).max(1e-6); - let off = (0..3).map(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - pts[i].1[k]).abs()).fold(0.0f32, f32::max); - if off > 1.5 / 255.0 { - keep.push(i); + /// Mesh shadings filling `region` (document space): gradient meshes, clipped to the region + /// when they reach past it. + fn draw_meshes(&mut self, meshes: Vec, region: &BezPath, opacity: f32) { + let nodes: Vec> = meshes.into_iter().map(|m| Arc::new(Node::new(self.id(), NodeKind::Mesh(m)))).collect(); + let Some(art) = bounds(&nodes) else { return }; + let mut content = match <[Arc; 1]>::try_from(nodes) { + Ok([one]) => Arc::unwrap_or_clone(one), + Err(all) => Node::group(self.id(), all), + }; + content.opacity *= opacity; + if !contains(region.bounding_box().inflate(0.5, 0.5), art) { + let clip = self.clip_node(region, FillRule::NonZero); + content = clipped(clip, content, self.id()); } + self.push_node(content); } - keep.push(N); - let mut stop_at = |t: f32, offset: f32| -> GradientStop { - let (rgb, _) = sample(t).unwrap_or((Color::BLACK, 1.0)); - match at(t).and_then(|v| colors.native(space, &v)) { - Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) }, - Some(Native { color, link: None }) => GradientStop::new(offset, color), - None => GradientStop::new(offset, rgb), + + /// The family and style of font `key` (drawing glyph `o`). + fn font_name(&mut self, key: u128, o: &hayro_interpret::font::OutlineGlyph) -> (String, String) { + if let Some(n) = self.font_names.get(&key) { + return n.clone(); } - }; - let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64); - let (kind, geom, stops) = if *axial { - let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0, focal: None }; - (GradientKind::Linear, geom, keep.iter().map(|&i| stop_at(pts[i].0, pts[i].0)).collect::>()) - } else { - let (r0, r1) = (coords[2].max(0.0), coords[5].max(1e-6)); - // The end circle, mapped (as an ellipse) into the page; a start circle off its centre - // gives the focal point. - let p = |x: f32, y: f32| Point::new(x as f64, y as f64); - let centre = p(coords[3], coords[4]); - let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1 as f64, 0.0), aspect: 1.0, focal: None }; - geom.set_focal(Some(p(coords[0], coords[1]))); - geom.transform(m, GradientKind::Radial); - // Offsets are relative to the outer radius; an inner radius shifts them outwards. - let stops = keep.iter().map(|&i| stop_at(pts[i].0, (r0 + (r1 - r0) * pts[i].0) / r1)).collect(); - (GradientKind::Radial, geom, stops) - }; - let mut g = GradientPaint::new(Gradient { kind, stops }); - g.geom = Some(geom); - Some(g) + let data = o.font_data(); + let name = self.fonts.get(&key).cloned().or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone())); + let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic)); + let families = self.families.get_or_insert_with(Families::available); + let n = match name { + Some(name) => { + let (family, style, found) = families.resolve(&name, weight, italic); + if !found && !family.is_empty() && !self.missing_fonts.contains(&family) { + self.missing_fonts.push(family.clone()); + } + (family, style) + } + None => { + let d = vectorcraft_doc::CharStyle::default(); + (d.font_family, d.font_style) + } + }; + self.font_names.insert(key, n.clone()); + n + } + + /// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps + /// its outline (no Unicode text, mirrored, or not type mode). + fn type_glyph( + &mut self, + o: &hayro_interpret::font::OutlineGlyph, + m: Affine, + scale: f64, + paint: &hayro_interpret::Paint<'_>, + stroke: Option<&StrokeProps>, + ) -> bool { + if self.text_as != TextAs::Text || self.nested > 0 { + return false; + } + let text = match o.as_unicode() { + Some(BfString::Char(c)) => c.to_string(), + Some(BfString::String(s)) => s, + None => return false, + }; + let Some(at) = Placement::of(m) else { return false }; + if text.is_empty() || text.chars().any(|c| c.is_control()) { + return false; + } + self.flush_glyphs(); + let font = o.font_cache_key(); + let (paint, opacity) = self.paint(paint, stroke.is_some()); + let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale)); + // A stroke over the glyph just filled (fill and stroke rendering). + if let (Some(s), Some(line)) = (&stroke, &mut self.text) + && line.stroke_last(font, at, s.clone()) + { + return true; + } + let (family, style) = self.font_name(font, o); + let look = Look { font, family, style, size: at.size, h_scale: at.h_scale, fill: stroke.is_none().then_some(paint), stroke }; + let advance = o.advance_width().filter(|w| w.is_finite()).map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0); + if !self.text.as_mut().is_some_and(|l| l.push(&look, at, opacity, advance, &text)) { + self.flush_text(); + let mut line = TextLine::new(at, opacity); + line.push(&look, at, opacity, advance, &text); + self.text = Some(line); + } + true + } } fn rgba_png(rgba: Vec, w: u32, h: u32) -> Option> { @@ -491,24 +925,67 @@ fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec { impl<'a> Device<'a> for Builder<'_> { fn set_soft_mask(&mut self, mask: Option>) { - if mask.is_some() { - self.warn("soft masks are not imported (content drawn unmasked)"); + let spec = mask.map(|m| self.soft_mask(&m)); + let same = match (&spec, &self.mask) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + }; + if !same { + self.flush(); + self.mask = spec; } } fn set_blend_mode(&mut self, blend_mode: hayro_interpret::BlendMode) { - self.blend = blend(blend_mode); + let b = blend(blend_mode); + if b != self.blend { + self.flush_glyphs(); + self.flush_text(); + self.blend = b; + } } fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) { self.flush_glyphs(); + self.flush_text(); + self.pending = false; let mut bp = path.clone(); bp.apply_affine(transform); if bp.elements().is_empty() { return; } + let shading = match paint { + hayro_interpret::Paint::Pattern(p) => match p.as_ref() { + Pattern::Shading(sp) => Some(sp), + Pattern::Tiling(_) => None, + }, + hayro_interpret::Paint::Color(_) => None, + }; + // Patch and triangle meshes are gradient meshes. + if let (PathDrawMode::Fill(_), Some(sp)) = (draw_mode, shading) + && let Some(meshes) = mesh_shading(sp, &mut self.colors) + { + self.draw_meshes(meshes, &bp, sp.opacity); + return; + } let pd = PathData::from_bezpath(&bp); - let (paint, opacity) = self.paint(paint); + let stroke = matches!(draw_mode, PathDrawMode::Stroke(_)); + let (paint, opacity) = self.paint(paint, stroke); + // A gradient that doesn't extend past its ends paints only between them. + let width = match draw_mode { + PathDrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform), + PathDrawMode::Fill(_) => 0.0, + }; + let band = shading.and_then(|sp| extend_clip(sp, bp.bounding_box().inflate(width, width))); + let wrap = |b: &mut Self, n: Node| match &band { + Some(band) => { + let clip = b.clip_node(band, FillRule::EvenOdd); + let id = b.id(); + clipped(clip, n, id) + } + None => n, + }; match draw_mode { PathDrawMode::Fill(rule) => { let mut f = FillLayer::new(paint); @@ -517,14 +994,19 @@ impl<'a> Device<'a> for Builder<'_> { if let NodeKind::Path { rule: r, .. } = &mut n.kind { *r = fill_rule(*rule); } + let n = wrap(self, n); self.push_node(n); } PathDrawMode::Stroke(props) => { let st = stroke_layer(paint, opacity, props, mean_scale(transform)); // Fill-then-stroke of the same path (the `B` operator) becomes one object. let blend = self.blend; - if let Some(f) = self.stack.last_mut() - && let Some(last) = f.children.last_mut() + let last = match self.mask { + Some(_) => self.masked.last_mut(), + None => self.stack.last_mut().and_then(|f| f.children.last_mut()), + }; + if band.is_none() + && let Some(last) = last && last.blend == blend && last.path_data() == Some(&pd) && last.appearance.stroke().is_none() @@ -533,13 +1015,25 @@ impl<'a> Device<'a> for Builder<'_> { return; } let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); + let n = wrap(self, n); self.push_node(n); } } } fn push_clip_path(&mut self, clip_path: &ClipPath) { - self.flush_glyphs(); + self.flush(); + // A form's box right after its transparency group: the group's isolate and knockout + // flags are the next ones read from the file. + if std::mem::take(&mut self.pending) { + let flags = self.scan.groups.get(self.group_at).copied(); + self.group_at += 1; + if let Some((i, k)) = flags + && let Some(Frame { kind: FrameKind::Group { isolate, knockout, .. }, .. }) = self.stack.last_mut() + { + (*isolate, *knockout) = (i, k); + } + } let bb = clip_path.path.bounding_box(); // Clips that contain the whole page do nothing visible; don't create groups for them. let redundant = clip_path.path.elements().len() <= 6 @@ -548,32 +1042,15 @@ impl<'a> Device<'a> for Builder<'_> { && bb.x1 >= self.page.x1 - 0.01 && bb.y1 >= self.page.y1 - 0.01 && self.page.area() > 0.0; - let kind = if redundant { - FrameKind::Skip - } else { - let mut clip = Node::new( - self.id(), - NodeKind::Path { - path: PathData::from_bezpath(&clip_path.path), - rule: fill_rule(clip_path.fill), - live: None, - clipping: true, - guide: false, - }, - ); - clip.name = Some("".into()); - FrameKind::Clip(Box::new(clip)) - }; - self.stack.push(Frame { kind, children: vec![] }); + let kind = if redundant { FrameKind::Skip } else { FrameKind::Clip(Box::new(self.clip_node(&clip_path.path, fill_rule(clip_path.fill)))) }; + self.stack.push(Frame::new(kind)); } fn push_transparency_group(&mut self, opacity: f32, mask: Option>, blend_mode: hayro_interpret::BlendMode) { - self.flush_glyphs(); - let masked = mask.is_some(); - if masked { - self.warn("soft masks are not imported (content drawn unmasked)"); - } - self.stack.push(Frame { kind: FrameKind::Group { opacity, blend: blend(blend_mode), masked }, children: vec![] }); + self.flush(); + let mask = mask.map(|m| self.soft_mask(&m)); + self.stack.push(Frame::new(FrameKind::Group { opacity, blend: blend(blend_mode), mask, isolate: false, knockout: false })); + self.pending = self.nested == 0; // Blend mode applies to the group as a whole, not to its children. self.blend = BlendMode::Normal; } @@ -586,6 +1063,7 @@ impl<'a> Device<'a> for Builder<'_> { paint: &hayro_interpret::Paint<'a>, draw_mode: &GlyphDrawMode, ) { + self.pending = false; let stroke = match draw_mode { GlyphDrawMode::Invisible => return, GlyphDrawMode::Fill => None, @@ -593,10 +1071,14 @@ impl<'a> Device<'a> for Builder<'_> { }; match glyph { Glyph::Outline(o) => { + let scale = mean_scale(transform); + if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref()) { + return; + } + self.flush_text(); let mut bp = o.outline(); bp.apply_affine(transform * glyph_transform); - let (paint, opacity) = self.paint(paint); - let scale = mean_scale(transform); + let (paint, opacity) = self.paint(paint, stroke.is_some()); let same = self.glyphs.as_ref().is_some_and(|r| { r.paint == paint && r.opacity == opacity @@ -613,14 +1095,21 @@ impl<'a> Device<'a> for Builder<'_> { self.warn("text was converted to outlines"); } Glyph::Type3(t3) => { - self.flush_glyphs(); + if self.nested >= MAX_NESTED { + return; + } + self.flush(); + self.nested += 1; t3.interpret(self, transform, glyph_transform, paint); + self.nested -= 1; } } } fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { self.flush_glyphs(); + self.flush_text(); + self.pending = false; match image { Image::Raster(r) => { let key = hayro_interpret::CacheKey::cache_key(&r); @@ -683,12 +1172,49 @@ impl<'a> Device<'a> for Builder<'_> { } fn pop_clip_path(&mut self) { - self.flush_glyphs(); + self.flush(); + self.pending = false; self.pop_frame(); } fn pop_transparency_group(&mut self) { - self.flush_glyphs(); + self.flush(); + self.pending = false; self.pop_frame(); } + + fn begin_marked_content(&mut self, tag: &[u8], _mcid: Option) { + 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(); + } } diff --git a/crates/pdf/src/import_mask.rs b/crates/pdf/src/import_mask.rs new file mode 100644 index 00000000..db446f01 --- /dev/null +++ b/crates/pdf/src/import_mask.rs @@ -0,0 +1,128 @@ +//! Soft masks → opacity masks. A luminosity mask's art becomes the mask art; an alpha mask's art +//! is turned white (its opacity kept), so its luminance over the black backdrop is its alpha. +//! The backdrop colour gives Clip (black: outside the art is hidden), an inverting transfer +//! function gives Invert, and the rectangles the PDF export draws for those (a backdrop cover, a +//! white Difference cover) read back as the options rather than as art. An alpha mask that is one +//! rectangle of constant opacity over everything is plain opacity. + +use std::sync::Arc; + +use kurbo::{Rect, Shape}; +use vectorcraft_color::blend::MASK_LUM; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Node, NodeKind, OpacityMask}; + +/// What a soft mask does to what it masks. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum MaskSpec { + /// A constant opacity (a group drawn through it is not isolated). + Opacity(f32), + Mask(OpacityMask), +} + +/// The luminance of `c` (0 black … 1 white), as opacity masks weigh it. +pub(crate) fn luminance(c: &Color) -> f32 { + let rgb = c.to_rgb(); + rgb.iter().zip(MASK_LUM).map(|(v, w)| v * w).sum() +} + +/// A rectangle (at most 6 path elements, its area that of its bounds). +pub(crate) fn is_rectangle(p: &kurbo::BezPath) -> bool { + let b = p.bounding_box(); + p.elements().len() <= 6 && b.area() > 0.0 && (p.area().abs() - b.area()).abs() <= b.area() * 1e-3 +} + +/// A path covering `page` filled with one solid colour and no stroke → its colour and opacity. +fn cover(n: &Node, page: Rect) -> Option<(Color, f32)> { + let NodeKind::Path { path, .. } = &n.kind else { return None }; + let [AppearanceItem::Fill(f)] = n.appearance.items.as_slice() else { return None }; + let Paint::Solid { color, .. } = &f.paint else { return None }; + let b = path.to_bezpath(); + let covers = page.area() > 1.0 && contains(b.bounding_box(), page.inset(-0.5)) && is_rectangle(&b); + (covers && n.mask.is_none()).then_some((*color, f.opacity * n.opacity)) +} + +/// Does `outer` contain `inner`? +pub(crate) fn contains(outer: Rect, inner: Rect) -> bool { + outer.x0 <= inner.x0 && outer.y0 <= inner.y0 && outer.x1 >= inner.x1 && outer.y1 >= inner.y1 +} + +/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as a +/// luminosity mask's. +fn whiten(n: &mut Node) { + let white = |p: &mut Paint| match p { + Paint::Solid { color, swatch, tint } => { + (*color, *swatch, *tint) = (Color::WHITE, None, 1.0); + } + Paint::Gradient(g) => { + for s in &mut g.gradient.stops { + (s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0); + } + } + Paint::None | Paint::Pattern { .. } => {} + }; + for item in &mut n.appearance.items { + white(item.paint_mut()); + } + match &mut n.kind { + NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE), + NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| { + white(&mut r.style.fill); + white(&mut r.style.stroke); + }), + _ => {} + } + if let Some(ch) = n.children_mut() { + for c in ch { + whiten(Arc::make_mut(c)); + } + } +} + +/// How a soft mask drawing `art` acts: `alpha` for an alpha mask, `backdrop` the luminance outside +/// the art, `inverted` when its transfer function inverts it. `page`: the area masks cover. +pub(crate) fn mask_spec( + mut art: Vec>, + alpha: bool, + backdrop: f32, + inverted: bool, + page: Rect, + group: impl FnOnce(Vec>) -> Node, +) -> MaskSpec { + if alpha { + if let [only] = art.as_slice() + && let Some((_, a)) = cover(only, page) + { + return MaskSpec::Opacity(a); + } + art.iter_mut().for_each(|n| whiten(Arc::make_mut(n))); + } + let mut clip = alpha || backdrop < 0.5; + let mut invert = inverted; + // The PDF export's covers: a white Difference rectangle on top inverts, an opaque one at the + // bottom is the backdrop (white: no clip). + if !alpha { + if art.len() > 1 + && let Some(last) = art.last() + && last.blend == BlendMode::Difference + && cover(last, page).is_some_and(|(c, a)| a >= 0.999 && luminance(&c) > 0.999) + { + art.pop(); + invert = !invert; + } + if art.len() > 1 + && let Some(first) = art.first() + && first.blend == BlendMode::Normal + && let Some((c, a)) = cover(first, page) + && a >= 0.999 + { + clip = luminance(&c) < 0.5; + art.remove(0); + } + } + let art = match <[Arc; 1]>::try_from(art) { + Ok([one]) => Arc::unwrap_or_clone(one), + Err(all) => group(all), + }; + MaskSpec::Mask(OpacityMask { invert, ..OpacityMask::new(art, clip) }) +} diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs new file mode 100644 index 00000000..673a4fbf --- /dev/null +++ b/crates/pdf/src/import_scan.rs @@ -0,0 +1,331 @@ +//! 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 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): 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}; + +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, FromBytes, Name, Object, ObjectIdentifier, dict_or_stream}; +use hayro_syntax::page::{Page, Resources}; + +/// The interpreter's limit on nested form XObjects. +const MAX_DEPTH: u32 = 50; +/// The most content operators walked per page: past it the rest of the page isn't told apart. +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, + index: HashMap, + /// The groups that are off (as the interpreter decides it). + off: HashSet, +} + +impl Ocgs { + /// The groups listed in the catalog's `/OCProperties`, with their default states. + pub fn read(pdf: &Pdf) -> Self { + let mut o = Self::default(); + let xref = pdf.xref(); + let Some(props) = xref.get::>(xref.root_id()).and_then(|c| c.get::>(b"OCProperties")) else { + return o; + }; + let config = props.get::>(b"D").unwrap_or_default(); + let refs = |d: &Dict<'_>, key: &[u8]| -> Vec { + d.get::>(key) + .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::>(b"BaseState").is_some_and(|n| n.as_ref() == b"OFF") { + 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::>(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::>(b"Name").map(|s| text_string(s.as_bytes())).unwrap_or_default(); + let print = d + .get::>(b"Usage") + .and_then(|u| u.get::>(b"Print")) + .and_then(|p| p.get::>(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 { + if d.get::>(b"Type").is_some_and(|t| t.as_ref() == b"OCMD") { + let first = d.get::>(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::>(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 { + 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 = 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, Option)>, + /// Each form transparency group: isolated, knockout. + pub groups: Vec<(bool, bool)>, +} + +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, + xref: &'p hayro_syntax::xref::XRef, + fonts: &'w mut HashMap, + visible: Vec, + ops: usize, + out: Scan, + /// A form XObject hidden by its own optional content was met. + hidden_form: bool, +} + +impl Walker<'_, '_> { + fn is_visible(&self) -> bool { + self.visible.last().copied().unwrap_or(true) + } + + fn begin(&mut self, on: bool) { + let v = self.is_visible() && on; + self.visible.push(v); + } + + /// Enter optional content `d` (object `id`): a group, or a membership dictionary whose policy + /// decides from its groups. + fn begin_oc(&mut self, d: &Dict<'_>, id: Option) { + let membership = id.is_none() || d.get::>(b"Type").is_some_and(|t| t.as_ref() == b"OCMD"); + let on = match id { + Some(id) if !membership => !self.off.contains(&id), + _ => { + let ids: Vec = match d.get::>(b"OCGs") { + Some(a) => a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect(), + None => d.get_ref(b"OCGs").map(ObjectIdentifier::from).into_iter().collect(), + }; + let on = |i: &ObjectIdentifier| !self.off.contains(i); + ids.is_empty() + || match d.get::>(b"P").as_deref() { + Some(b"AllOn") => ids.iter().all(on), + Some(b"AnyOff") => !ids.iter().all(on), + Some(b"AllOff") => !ids.iter().any(on), + _ => ids.iter().any(on), + } + } + }; + self.begin(on); + } + + fn note_fonts(&mut self, res: &Resources<'_>) { + for (key, _) in res.fonts.entries() { + if let Some(f) = res.fonts.get::>(key.as_ref()) + && let Some(name) = f.get::>(b"BaseFont") + { + self.fonts.entry(f.cache_key()).or_insert_with(|| String::from_utf8_lossy(name.as_ref()).into_owned()); + } + } + } + + fn walk(&mut self, mut iter: TypedIter<'_>, res: &Resources<'_>, depth: u32) { + self.note_fonts(res); + while let Some(op) = iter.next() { + self.ops += 1; + if self.ops > MAX_OPS { + return; + } + match op { + TypedInstruction::BeginMarkedContentWithProperties(bdc) => { + let oc = match bdc.1 { + Object::Name(n) => { + res.properties.get_ref(n.as_ref()).map(|r| (res.properties.get::>(n.as_ref()).unwrap_or_default(), r)) + } + o => dict_or_stream(o) + .and_then(|(props, _)| props.get_ref(b"OC").map(|r| (props.get::>(b"OC").unwrap_or_default(), r))), + }; + 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::EndMarkedContent(_) => { + self.visible.pop(); + } + TypedInstruction::XObject(x) => { + if !self.is_visible() || depth >= MAX_DEPTH { + continue; + } + let Some(s) = res.get_x_object(x.0) else { continue }; + let d = s.dict(); + if d.get::>(b"Subtype").as_deref() != Some(b"Form") || d.get::<[f32; 4]>(b"BBox").is_none() { + continue; + } + let Ok(data) = s.decoded() else { continue }; + let oc = d.get::>(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::>(b"Group") + && self.out.groups.len() < MAX_GROUPS + { + self.out.groups.push((g.get::(b"I").unwrap_or(false), g.get::(b"K").unwrap_or(false))); + } + let inner = Resources::from_parent(d.get::>(b"Resources").unwrap_or_default(), res.clone()); + self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1); + } + if oc.is_some() { + self.visible.pop(); + } + } + _ => {} + } + } + } +} + +/// 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) -> 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) -> 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 +} + +/// 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> { + let root = pdf.xref().root_id(); + let catalog = pdf.xref().get::>(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 { + 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()) +} diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs new file mode 100644 index 00000000..86c0f6dd --- /dev/null +++ b/crates/pdf/src/import_shading.rs @@ -0,0 +1,311 @@ +//! Shadings: axial and radial ones become gradients (their stops sampled from the shading +//! function in its own colour model; an end that doesn't extend clips the painted area), patch +//! and triangle meshes become gradient meshes, and a gradient drawn through a luminosity mask that +//! is the same gradient in grey (how stop opacity is written) gets its opacity stops back. + +use std::sync::Arc; + +use hayro_interpret::pattern::ShadingPattern; +use hayro_interpret::shading::{ShadingFunction, ShadingType}; +use kurbo::{BezPath, Circle, Point, Rect, Shape, Vec2}; +use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::live::{GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, lerp_color}; +use vectorcraft_doc::{Node, NodeKind, OpacityMask}; + +use crate::import::round3; +use crate::import_color::{Colors, Native}; +use crate::import_mask::luminance; + +/// The most mesh patches (or triangles) imported per shading: past it, the shading is a flat +/// colour. +const MAX_PATCHES: usize = 4096; + +/// An axial or radial shading as a gradient paint, with whether it extends past its start and +/// its end. +pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Option<(GradientPaint, [bool; 2])> { + let ShadingType::RadialAxial { coords, domain, function, axial, extend } = sp.shading.shading_type.as_ref() else { + return None; + }; + let m = sp.matrix; + let cs = &sp.shading.color_space; + let at = |t: f32| function.eval(&smallvec::smallvec![domain[0] + (domain[1] - domain[0]) * t]); + let space = colors.shading_space(cs, at(0.0)?.len()); + // RGB to decide which samples are stops; the stops keep the shading's model. + let sample = |t: f32| -> Option { + let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components(); + Some(Color::rgb(round3(r), round3(g), round3(b))) + }; + // Sample densely, then drop samples that linear interpolation reproduces. + const N: usize = 64; + let mut pts: Vec<(f32, [f32; 3])> = Vec::with_capacity(N + 1); + for i in 0..=N { + let t = i as f32 / N as f32; + pts.push((t, sample(t)?.to_rgb())); + } + let mut keep = vec![0usize]; + for w in 1..N { + let (Some(a), Some(p), Some(b)) = (keep.last().and_then(|&k| pts.get(k)), pts.get(w), pts.get(w + 1)) else { continue }; + let u = (p.0 - a.0) / (b.0 - a.0).max(1e-6); + let off = (0..3).map(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - p.1[k]).abs()).fold(0.0f32, f32::max); + if off > 1.5 / 255.0 { + keep.push(w); + } + } + keep.push(N); + let ts: Vec = keep.iter().filter_map(|&k| pts.get(k)).map(|p| p.0).collect(); + let mut stop_at = |t: f32, offset: f32| -> GradientStop { + let rgb = sample(t).unwrap_or(Color::BLACK); + match at(t).and_then(|v| colors.native(space, &v)) { + Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) }, + Some(Native { color, link: None }) => GradientStop::new(offset, color), + None => GradientStop::new(offset, rgb), + } + }; + let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64); + let (kind, geom, stops) = if *axial { + let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0, focal: None }; + (GradientKind::Linear, geom, ts.iter().map(|&t| stop_at(t, t)).collect::>()) + } else { + let (r0, r1) = (coords[2].max(0.0), coords[5].max(1e-6)); + // The end circle, mapped (as an ellipse) into the page; a start circle off its centre + // gives the focal point. + let p = |x: f32, y: f32| Point::new(x as f64, y as f64); + let centre = p(coords[3], coords[4]); + let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1 as f64, 0.0), aspect: 1.0, focal: None }; + geom.set_focal(Some(p(coords[0], coords[1]))); + geom.transform(m, GradientKind::Radial); + // Offsets are relative to the outer radius; an inner radius shifts them outwards. + let stops = ts.iter().map(|&t| stop_at(t, (r0 + (r1 - r0) * t) / r1)).collect(); + (GradientKind::Radial, geom, stops) + }; + let mut g = GradientPaint::new(Gradient { kind, stops }); + g.geom = Some(geom); + Some((g, *extend)) +} + +/// Where an axial or radial shading that doesn't extend at both ends paints, as a clip path in +/// document space (even-odd) for art filling `bounds`; `None` when it extends at both. +pub(crate) fn extend_clip(sp: &ShadingPattern, bounds: Rect) -> Option { + let ShadingType::RadialAxial { coords, axial, extend, .. } = sp.shading.shading_type.as_ref() else { + return None; + }; + if extend[0] && extend[1] { + return None; + } + let m = sp.matrix; + let inv = m.inverse(); + let local = inv.transform_rect_bbox(bounds); + if !local.is_finite() || m.determinant().abs() < 1e-12 { + return None; + } + let f = |i: usize| coords.get(i).copied().unwrap_or(0.0) as f64; + let mut out = BezPath::new(); + if *axial { + let (p0, p1) = (Point::new(f(0), f(1)), Point::new(f(2), f(3))); + let len = (p1 - p0).hypot(); + if len < 1e-9 { + return None; + } + let u = (p1 - p0) / len; + let n = Vec2::new(-u.y, u.x); + let corners = [local.origin(), Point::new(local.x1, local.y0), Point::new(local.x1, local.y1), Point::new(local.x0, local.y1)]; + let along = corners.map(|c| (c - p0).dot(u)); + let across = corners.map(|c| (c - p0).dot(n)); + let min = |v: [f64; 4]| v.iter().copied().fold(f64::INFINITY, f64::min); + let max = |v: [f64; 4]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let lo = if extend[0] { min(along) } else { min(along).max(0.0) }; + let hi = if extend[1] { max(along) } else { max(along).min(len) }; + let (b0, b1) = (min(across), max(across)); + if hi > lo { + let q = |a: f64, b: f64| m * (p0 + u * a + n * b); + out.move_to(q(lo, b0)); + out.line_to(q(hi, b0)); + out.line_to(q(hi, b1)); + out.line_to(q(lo, b1)); + out.close_path(); + } + return Some(out); + } + let start = Circle::new((f(0), f(1)), f(2).max(0.0)); + let end = Circle::new((f(3), f(4)), f(5).max(0.0)); + // Past the end: the end circle bounds the paint; before the start: the start circle is a hole. + let outer = if extend[1] { local.inflate(1.0, 1.0).to_path(0.1) } else { end.to_path(0.1) }; + out.extend(m * outer); + if !extend[0] && start.radius > 1e-9 { + out.extend(m * start.to_path(0.1)); + } + Some(out) +} + +/// The colour of shading components `v` (the input of the shading's function, when it has one). +fn mesh_color(v: &[f32], function: Option<&ShadingFunction>, sp: &ShadingPattern, colors: &mut Colors<'_>) -> Color { + let comps = match function { + Some(f) => f.eval(&v.iter().copied().collect()).map(|c| c.to_vec()).unwrap_or_default(), + None => v.to_vec(), + }; + let space = colors.shading_space(&sp.shading.color_space, comps.len()); + colors.native(space, &comps).map(|n| n.color).unwrap_or_else(|| { + let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0, false).components(); + Color::rgb(round3(r), round3(g), round3(b)) + }) +} + +/// A 1×1 gradient mesh from a patch's four corners (bottom-left, top-left, top-right, +/// bottom-right in the patch's u/v order), each with its handles towards its neighbours. +fn patch_mesh(corners: [(Point, [Vec2; 4], Color); 4]) -> GradientMesh { + let point = |(p, handles, color): (Point, [Vec2; 4], Color)| MeshPoint { p, color, opacity: 1.0, handles }; + let [a, b, c, d] = corners; + // Row 0 is v = 0 (corners a, d), row 1 is v = 1 (b, c); columns follow u. + GradientMesh { rows: 1, cols: 1, points: vec![point(a), point(d), point(b), point(c)] } +} + +/// Handles of a mesh corner: right, left, down, up (unused ones stay at the point). +fn handles(right: Vec2, left: Vec2, down: Vec2, up: Vec2) -> [Vec2; 4] { + let mut h = [Vec2::ZERO; 4]; + (h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (right, left, down, up); + h +} + +/// A 1×1 mesh from a patch's twelve boundary points (in the PDF's order: u runs cp0 → cp9 along +/// cp11 and cp10, v runs cp0 → cp3 along cp1 and cp2) and its corner colours (at cp0, cp3, cp6, +/// cp9); `None` if a point isn't finite. +fn boundary_mesh(cp: &[Point], colors: [Color; 4]) -> Option { + let cp: &[Point; 12] = cp.get(..12)?.try_into().ok()?; + if !cp.iter().all(|p| p.is_finite()) { + return None; + } + let h = |a: usize, b: usize| cp[b] - cp[a]; + let [c0, c1, c2, c3] = colors; + Some(patch_mesh([ + (cp[0], handles(h(0, 11), Vec2::ZERO, h(0, 1), Vec2::ZERO), c0), + (cp[3], handles(h(3, 4), Vec2::ZERO, Vec2::ZERO, h(3, 2)), c1), + (cp[6], handles(Vec2::ZERO, h(6, 5), Vec2::ZERO, h(6, 7)), c2), + (cp[9], handles(Vec2::ZERO, h(9, 10), h(9, 8), Vec2::ZERO), c3), + ])) +} + +/// A patch mesh or triangle mesh shading as gradient meshes in document space (one per patch; +/// a triangle is a patch with two corners together). `None`: not a mesh, or too big. +pub(crate) fn mesh_shading(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Option> { + let m = sp.matrix; + let mut out = vec![]; + match sp.shading.shading_type.as_ref() { + ShadingType::CoonsPatchMesh { patches, function } => { + if patches.len() > MAX_PATCHES { + return None; + } + for p in patches { + let colors = [0, 1, 2, 3].map(|i| mesh_color(p.colors.get(i).map_or(&[][..], |c| c.as_slice()), function.as_ref(), sp, colors)); + out.extend(boundary_mesh(&p.control_points.map(|q| m * q), colors)); + } + } + // A tensor patch's boundary is a Coons patch's (its four inner points are dropped). + ShadingType::TensorProductPatchMesh { patches, function } => { + if patches.len() > MAX_PATCHES { + return None; + } + for p in patches { + let colors = [0, 1, 2, 3].map(|i| mesh_color(p.colors.get(i).map_or(&[][..], |c| c.as_slice()), function.as_ref(), sp, colors)); + out.extend(boundary_mesh(&p.control_points.map(|q| m * q), colors)); + } + } + ShadingType::TriangleMesh { triangles, function } => { + if triangles.len() > MAX_PATCHES { + return None; + } + for t in triangles { + let v = [&t.p0, &t.p1, &t.p2].map(|v| (m * v.point, mesh_color(v.colors.as_slice(), function.as_ref(), sp, colors))); + if !v.iter().all(|(p, _)| p.is_finite()) { + continue; + } + let [a, b, c] = v; + out.push(patch_mesh([ + (a.0, [Vec2::ZERO; 4], a.1), + (b.0, [Vec2::ZERO; 4], b.1), + (c.0, [Vec2::ZERO; 4], c.1), + (c.0, [Vec2::ZERO; 4], c.1), + ])); + } + } + _ => return None, + } + Some(out) +} + +/// The only painted leaf of mask art (through groups of one object and clip groups of one). +fn only_leaf(n: &Node) -> Option<&Node> { + match &n.kind { + NodeKind::Group { children, clip } => { + let rest = if *clip { children.get(1..)? } else { children.as_slice() }; + match rest { + [one] if n.opacity >= 0.999 && n.mask.is_none() => only_leaf(one), + _ => None, + } + } + NodeKind::Path { .. } => Some(n), + _ => None, + } +} + +/// The gradient a path paints with its only fill or stroke. +fn only_gradient(n: &Node) -> Option<&GradientPaint> { + match n.appearance.items.as_slice() { + [item] => match item.paint() { + Paint::Gradient(g) => Some(g), + _ => None, + }, + _ => None, + } +} + +fn same_geom(a: &GradientGeom, b: &GradientGeom) -> bool { + let near = |p: Point, q: Point| p.distance(q) < 0.5; + near(a.start, b.start) && near(a.end, b.end) && near(a.focal_point(), b.focal_point()) && (a.aspect - b.aspect).abs() < 0.01 +} + +/// `content` (a path painting one gradient) drawn through `mask` (clipped, not inverted, the same +/// gradient in shades of grey: how gradient stop opacity is written) as one gradient with the +/// mask's luminance as stop opacity. +pub(crate) fn fold_stop_opacity(content: &Node, mask: &OpacityMask) -> Option { + if !mask.clip || mask.invert || mask.disabled || content.is_container() { + return None; + } + let g = only_gradient(content)?; + let leaf = only_leaf(&mask.art)?; + let mg = only_gradient(leaf)?; + let opaque = |n: &Node| n.opacity >= 0.999 && n.appearance.items.iter().all(|i| i.opacity() >= 0.999); + if mg.gradient.kind != g.gradient.kind || !opaque(leaf) || !same_geom(g.geom.as_ref()?, mg.geom.as_ref()?) { + return None; + } + let mut offsets: Vec = g.gradient.stops.iter().chain(&mg.gradient.stops).map(|s| s.offset).collect(); + offsets.sort_by(f32::total_cmp); + offsets.dedup_by(|a, b| (*a - *b).abs() < 1e-4); + let stops = offsets + .iter() + .map(|&t| { + let (mc, mo) = mg.gradient.sample(t); + let alpha = round3(luminance(&mc) * mo); + let mut s = match g.gradient.stops.iter().find(|s| (s.offset - t).abs() < 1e-4) { + Some(s) => s.clone(), + None => { + let (c, o) = g.gradient.sample_with(t, lerp_color); + GradientStop { opacity: o, ..GradientStop::new(t, c) } + } + }; + s.opacity = round3(s.opacity * alpha); + s + }) + .collect(); + let mut out = content.clone(); + let mut paint = (*g).clone(); + paint.gradient.stops = stops; + if let Some(item) = out.appearance.items.first_mut() { + *item.paint_mut() = Paint::Gradient(Box::new(paint)); + } + Some(out) +} + +/// `node` (filling `bounds`) clipped to `clip`: a clip group. +pub(crate) fn clipped(clip: Node, node: Node, id: vectorcraft_doc::NodeId) -> Node { + Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(node)], clip: true }) +} diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs new file mode 100644 index 00000000..5230d5c3 --- /dev/null +++ b/crates/pdf/src/import_text.rs @@ -0,0 +1,225 @@ +//! Text as editable point type: glyphs drawn one after another on the same baseline make one +//! point text object of their Unicode text, a run per font, size and paint (a gap wider than a +//! fifth of the size reads as a space). Fonts are named from the file's base font name (its +//! subset prefix dropped, the family matched against the fonts available); a font that isn't +//! available keeps its name, and the text shows in the fallback font until it is. + +use std::collections::HashMap; + +use kurbo::{Affine, Point, Vec2}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{CharStyle, TextObject, TextRun}; + +/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Placement { + pub origin: Point, + /// Unit vector along the baseline. + pub dir: Vec2, + /// Font size in points (the em's height). + pub size: f64, + /// Width of the em ÷ its height, in percent. + pub h_scale: f64, +} + +impl Placement { + /// Where a glyph drawn with `m` (glyph space, 1000 units per em, y up → document) sits; + /// `None` when it is mirrored, degenerate or not finite (those keep their outlines). + pub fn of(m: Affine) -> Option { + let origin = m * Point::ORIGIN; + let ex = m * Point::new(1000.0, 0.0) - origin; + let ey = m * Point::new(0.0, 1000.0) - origin; + let (w, size) = (ex.hypot(), ey.hypot()); + // Upright in y-down document space: x to the right of up. + let upright = ex.cross(ey) < 0.0; + (origin.is_finite() && w.is_finite() && size.is_finite() && w > 0.01 && size > 0.01 && size < 1e5 && upright).then(|| Self { + origin, + dir: ex / w, + size, + h_scale: w / size * 100.0, + }) + } + + fn up(&self) -> Vec2 { + Vec2::new(self.dir.y, -self.dir.x) + } +} + +/// What a run of type is drawn with: its font (cache key, family and style), size, horizontal +/// scale, fill and stroke (paint and width). +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Look { + pub font: u128, + pub family: String, + pub style: String, + pub size: f64, + pub h_scale: f64, + pub fill: Option, + pub stroke: Option<(Paint, f64)>, +} + +impl Look { + /// Can a glyph drawn with `other` join a run drawn with this? A fill joins a run whose + /// glyphs were also stroked over (fill and stroke rendering draws each glyph twice). + fn takes(&self, other: &Look) -> bool { + self.font == other.font + && (self.size - other.size).abs() <= self.size * 0.01 + && (self.h_scale - other.h_scale).abs() < 0.5 + && self.fill == other.fill + && (other.stroke.is_none() || self.stroke == other.stroke) + } + + fn style(&self) -> CharStyle { + let (stroke, stroke_width) = self.stroke.clone().unwrap_or((Paint::None, 0.0)); + CharStyle { + font_family: self.family.clone(), + font_style: self.style.clone(), + size: round(self.size), + h_scale: round(self.h_scale), + fill: self.fill.clone().unwrap_or(Paint::None), + stroke, + stroke_width, + ..CharStyle::default() + } + } +} + +/// A line of type being gathered: runs of glyphs on one baseline. +pub(crate) struct TextLine { + /// The first glyph's placement. + at: Placement, + opacity: f32, + /// Where the next glyph would start without spacing. + next: Point, + /// The last glyph's origin. + last: Point, + runs: Vec<(Look, String)>, +} + +impl TextLine { + pub fn new(at: Placement, opacity: f32) -> Self { + Self { at, opacity, next: at.origin, last: at.origin, runs: vec![] } + } + + /// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if + /// it continues this line; `false`: it starts another. + pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool { + if let Some((last, run)) = self.runs.last_mut() { + let size = self.at.size.max(at.size); + let gap = (at.origin - self.next).dot(self.at.dir); + let on_line = opacity == self.opacity + && at.dir.dot(self.at.dir) > 0.9995 + && (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15 + && gap > -size * 0.3 + && gap < size * 3.0; + if !on_line { + return false; + } + // A gap wider than a fifth of an em reads as a space. + if gap > size * 0.2 && !run.ends_with(' ') && !text.starts_with(' ') { + run.push(' '); + } + if last.takes(look) { + run.push_str(text); + } else { + self.runs.push((look.clone(), text.to_string())); + } + } else { + self.runs.push((look.clone(), text.to_string())); + } + self.next = at.origin + self.at.dir * advance; + self.last = at.origin; + true + } + + /// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill + /// and stroke rendering): the run is stroked. `false`: it isn't that glyph. + pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64)) -> bool { + let Some((look, _)) = self.runs.last_mut().filter(|(l, _)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else { + return false; + }; + look.stroke = Some(stroke); + true + } + + /// The point type object and its opacity. + pub fn finish(mut self) -> Option<(TextObject, f32)> { + if let Some((_, last)) = self.runs.last_mut() { + last.truncate(last.trim_end().len()); + } + self.runs.retain(|(_, t)| !t.is_empty()); + if self.runs.iter().all(|(_, t)| t.trim().is_empty()) { + return None; + } + let mut runs = self.runs.into_iter().map(|(look, text)| TextRun { text, style: look.style() }); + let first = runs.next()?; + let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style); + t.runs.extend(runs); + t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(self.at.dir.atan2()); + Some((t, self.opacity)) + } +} + +fn round(v: f64) -> f64 { + (v * 1000.0).round() / 1000.0 +} + +/// Lower-case letters and digits only (`"Source Sans 3"` → `"sourcesans3"`). +fn norm(s: &str) -> String { + s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect() +} + +/// `"TimesNewRoman"` → `"Times New Roman"`, `"SourceSans3"` → `"Source Sans 3"`. +fn spaced(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 4); + let mut prev: Option = None; + for c in s.chars() { + if let Some(p) = prev + && ((p.is_ascii_lowercase() && c.is_ascii_uppercase()) || (p.is_ascii_alphabetic() && c.is_ascii_digit())) + { + out.push(' '); + } + out.push(c); + prev = Some(c); + } + out +} + +/// `name` without a PostScript name's trailing `PSMT`, `MT` or `PS`. +fn strip_ps(name: &str) -> &str { + ["PSMT", "MT", "PS"].iter().find_map(|s| name.strip_suffix(s).filter(|r| !r.is_empty())).unwrap_or(name) +} + +/// The families available, by [`norm`]ed name. +pub(crate) struct Families(HashMap); + +impl Families { + pub fn available() -> Self { + Self(vectorcraft_text::FontDb::global().families().into_iter().map(|f| (norm(&f), f)).collect()) + } + + /// The family and style of a font with base (PostScript) name `name`, or of weight `weight` + /// and slant `italic` when the name has no style; whether the family is available. + pub fn resolve(&self, name: &str, weight: Option, italic: bool) -> (String, String, bool) { + // A subset's six-letter tag. + let name = match name.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest, + _ => name, + }; + let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, "")); + let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned()); + let style = match spaced(strip_ps(style)).as_str() { + "" | "Roman" | "Book" | "Normal" | "Plain" => match (weight.is_some_and(|w| w >= 600) || style.contains("Bold"), italic) { + (true, true) => "Bold Italic".to_string(), + (true, false) => "Bold".to_string(), + (false, true) => "Italic".to_string(), + (false, false) => "Regular".to_string(), + }, + s => s.to_string(), + }; + match found { + Some(f) => (f, style, true), + None => (spaced(strip_ps(fam)).trim().to_string(), style, false), + } + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 614e5862..36237249 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -11,9 +11,13 @@ //! 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, axial/radial shadings → gradients, images (JPEG passthrough, others -//! re-encoded as PNG) and text as glyph outlines. Colours keep their model: CMYK, Gray, and spot +//! [`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 +//! [`TextAs`]). Colours keep their model: CMYK, Gray, and spot //! inks (Separation, DeviceN) as spot swatches at a tint; a file painted mostly in CMYK opens as a //! CMYK document. [`ImportOptions`] pick the pages, the box each //! artboard gets ([`CropTo`]) and the password; [`info`] lists the pages and their boxes. @@ -26,6 +30,10 @@ mod export; mod images; mod import; mod import_color; +mod import_mask; +mod import_scan; +mod import_shading; +mod import_text; mod lab_spot; mod pages; mod presets; @@ -76,14 +84,29 @@ pub struct ImportOptions { pub crop: CropTo, /// The (user) password of an encrypted PDF. pub password: Option, + /// 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 } + Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None, text_as: TextAs::default(), layers: true } } } +settings::choice! { + /// What imported text becomes. + TextAs { + /// Point type, one object per line run, in the file's fonts (by name). + Text = "text", "Text"; + /// Paths of the glyphs' outlines (looks the same without the fonts). + Outlines = "outlines", "Outlines"; + } default Text +} + settings::choice! { /// The page box an imported or placed page is cropped to: its artboard (or placed frame). CropTo { @@ -168,6 +191,10 @@ mod tests_fx; #[cfg(test)] 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; diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs new file mode 100644 index 00000000..83329b3b --- /dev/null +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -0,0 +1,361 @@ +//! Import fidelity: soft masks → opacity masks, isolated and knockout groups, tiling patterns → +//! pattern swatches, text → point type, mesh shadings → gradient meshes, gradient stop opacity +//! and shading Extend flags. + +use std::sync::Arc; + +use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Knockout, Node, NodeId, NodeKind, OpacityMask}; +use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; + +use crate::*; + +/// A stream object with dictionary entries `dict`. +pub(crate) fn stream(dict: &str, data: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +/// A 100 × 100 pt page drawing `content` with `resources`, plus `extra` objects. +fn one_page(content: &str, resources: &str, extra: &[&str]) -> Vec { + let page = PdfPage { resources: resources.into(), ..PdfPage::new(100.0, 100.0, content) }; + pdf_with(&[page], extra, None) +} + +fn open(bytes: &[u8]) -> Document { + import(bytes).unwrap() +} + +fn open_with(bytes: &[u8], f: impl FnOnce(&mut ImportOptions)) -> ImportReport { + let mut o = ImportOptions::default(); + f(&mut o); + import_with_report(bytes, &o).unwrap() +} + +/// Every node of the document, depth first. +fn all(d: &Document) -> Vec { + let mut out = vec![]; + d.walk(|n| out.push(n.clone())); + out +} + +fn rect(r: Rect, c: Color) -> Node { + Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(c), Paint::None, 0.0)) +} + +fn add(d: &mut Document, mut n: Node) { + n.id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), usize::MAX, n).unwrap(); +} + +fn roundtrip(d: &Document) -> Document { + open(&export(d, &PdfOptions::default()).unwrap()) +} + +/// The fill colour of a node's first fill. +fn fill_color(n: &Node) -> Option { + n.appearance.fill().and_then(|f| f.paint.color()) +} + +fn near(a: Rect, b: Rect) -> bool { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 0.6) +} + +// ---------- soft masks ---------- + +/// A page filling a red square through soft mask `smask` (its group, object 5, draws `mask`). +fn masked(smask: &str, mask: &str) -> Vec { + let g = first_extra(1); + let group = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >>", mask); + one_page("/GS0 gs 1 0 0 rg 0 0 100 100 re f", &format!("/ExtGState << /GS0 << /SMask << /Type /Mask {smask} /G {g} 0 R >> >> >>"), &[&group]) +} + +fn the_mask(d: &Document) -> (Node, OpacityMask) { + let n = all(d).into_iter().find(|n| n.mask.is_some()).expect("a masked object"); + let m = n.mask.as_deref().unwrap().clone(); + (n, m) +} + +#[test] +fn a_luminosity_soft_mask_becomes_an_opacity_mask() { + let d = open(&masked("/S /Luminosity", "1 1 1 rg 0 0 50 100 re f")); + let (n, m) = the_mask(&d); + assert!(n.path_data().is_some(), "the mask is on the square itself"); + assert!(m.clip && !m.invert); + assert_eq!(fill_color(&m.art).map(|c| c.to_rgb()), Some([1.0, 1.0, 1.0])); + assert!(near(m.art.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 100.0)), "{:?}", m.art.geometric_bounds()); + // A white backdrop: outside the art stays visible (no clip); an inverting transfer function inverts. + let (_, m) = the_mask(&open(&masked("/S /Luminosity /BC [1 1 1]", "0 0 0 rg 0 0 50 100 re f"))); + assert!(!m.clip); + let tr = "/TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>"; + let (_, m) = the_mask(&open(&masked(&format!("/S /Luminosity {tr}"), "1 1 1 rg 0 0 50 100 re f"))); + assert!(m.invert && m.clip); +} + +#[test] +fn an_alpha_soft_mask_becomes_an_opacity_mask_of_its_shape() { + // The art is painted white with its opacity kept: its luminance is its alpha. + let bytes = { + let g = first_extra(1); + let group = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /ExtGState << /GA << /ca 0.5 >> >> >>", + "/GA gs 0 0 1 rg 0 0 50 100 re f", + ); + one_page( + "/GS0 gs 1 0 0 rg 0 0 100 100 re f", + &format!("/ExtGState << /GS0 << /SMask << /Type /Mask /S /Alpha /G {g} 0 R >> >> >>"), + &[&group], + ) + }; + let (_, m) = the_mask(&open(&bytes)); + assert!(m.clip && !m.invert); + let f = m.art.appearance.fill().unwrap(); + assert_eq!(f.paint.color().map(|c| c.to_rgb()), Some([1.0, 1.0, 1.0])); + assert!((f.opacity * m.art.opacity - 0.5).abs() < 0.01); +} + +#[test] +fn opacity_masks_round_trip_through_pdf() { + for (clip, invert) in [(true, false), (false, false), (true, true), (false, true)] { + let mut d = Document::new(100.0, 100.0); + let mut n = rect(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0)); + let art = rect(Rect::new(10.0, 10.0, 50.0, 90.0), Color::gray(0.25)); + n.mask = Some(Box::new(OpacityMask { invert, ..OpacityMask::new(art, clip) })); + add(&mut d, n); + let back = roundtrip(&d); + let (n, m) = the_mask(&back); + assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0))); + assert_eq!((m.clip, m.invert), (clip, invert), "clip {clip}, invert {invert}"); + assert!(near(m.art.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 50.0, 90.0)), "{:?}", m.art.geometric_bounds()); + let lum = crate::import_mask::luminance(&fill_color(&m.art).unwrap()); + assert!((lum - 0.75).abs() < 0.02, "{lum}"); + } +} + +// ---------- isolation and knockout ---------- + +/// A 50% group (isolated or not) holding a Multiply square over a backdrop. +fn half_group(isolate: bool) -> Document { + let mut d = Document::new(100.0, 100.0); + add(&mut d, rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.8, 0.6, 0.2))); + let mut m = rect(Rect::new(20.0, 20.0, 80.0, 80.0), Color::rgb(0.5, 0.5, 1.0)); + (m.id, m.blend) = (d.alloc_id(), BlendMode::Multiply); + let mut g = Node::group(NodeId(0), vec![Arc::new(m)]); + (g.opacity, g.isolate) = (0.5, isolate); + add(&mut d, g); + d +} + +#[test] +fn isolated_and_non_isolated_groups_round_trip() { + let back = roundtrip(&half_group(true)); + let groups: Vec = all(&back).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: false, .. })).collect(); + let [g] = groups.as_slice() else { panic!("one group: {groups:?}") }; + assert!(g.isolate && g.mask.is_none()); + assert!((g.opacity - 0.5).abs() < 0.01, "{}", g.opacity); + assert_eq!(g.children().unwrap()[0].blend, BlendMode::Multiply); + // Not isolated: its 50% (a constant alpha mask in the file) is opacity again, here on the + // only object inside, which blends with the backdrop as before. + let back = roundtrip(&half_group(false)); + let nodes = all(&back); + assert!(nodes.iter().all(|n| !n.isolate && n.mask.is_none())); + let m = nodes.iter().find(|n| n.blend == BlendMode::Multiply).unwrap(); + let opacity: f32 = + nodes.iter().filter(|n| n.children().is_some_and(|c| c.iter().any(|c| c.id == m.id))).map(|g| g.opacity).product::() * m.opacity; + assert!((opacity - 0.5).abs() < 0.01, "{opacity}"); +} + +#[test] +fn isolate_and_knockout_flags_of_a_group_are_kept() { + let x = first_extra(1); + let form = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true /K true >> /Resources << /ExtGState << /M << /BM /Multiply >> >> >>", + "/M gs 1 0 0 rg 10 10 50 50 re f 0 0 1 rg 30 30 50 50 re f", + ); + let d = open(&one_page("0 1 0 rg 0 0 100 100 re f /X0 Do", &format!("/XObject << /X0 {x} 0 R >>"), &[&form])); + let groups: Vec = all(&d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { .. })).collect(); + let [g] = groups.as_slice() else { panic!("one group: {groups:?}") }; + assert!(g.isolate); + assert_eq!(g.knockout, Knockout::On); + assert_eq!(g.children().unwrap().len(), 2); + // Without the flags, the form's group is just its art. + let plain = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>", + "1 0 0 rg 10 10 50 50 re f 0 0 1 rg 30 30 50 50 re f", + ); + let d = open(&one_page("/X0 Do", &format!("/XObject << /X0 {x} 0 R >>"), &[&plain])); + assert!(all(&d).iter().all(|n| !matches!(n.kind, NodeKind::Group { .. }))); +} + +// ---------- tiling patterns ---------- + +#[test] +fn tiling_patterns_become_pattern_swatches() { + let p = first_extra(1); + let colored = stream("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", "1 0 0 rg 0 0 5 5 re f"); + let d = open(&one_page("/Pattern cs /P0 scn 0 0 100 50 re f 0 50 100 50 re f", &format!("/Pattern << /P0 {p} 0 R >>"), &[&colored])); + assert_eq!(d.patterns.len(), 1, "one swatch for both fills"); + let def = &d.patterns[0]; + assert!(near(def.tile, Rect::new(0.0, 0.0, 10.0, 10.0)), "{:?}", def.tile); + assert_eq!(def.art.len(), 1); + assert_eq!(fill_color(&def.art[0]), Some(Color::rgb(1.0, 0.0, 0.0))); + assert!(d.swatches.iter().any(|s| matches!(&s.paint, Paint::Pattern { pattern, .. } if *pattern == def.name)), "a pattern swatch"); + let fills: Vec = all(&d).iter().filter_map(|n| n.appearance.fill().map(|f| f.paint.clone())).collect(); + assert_eq!(fills.len(), 2); + assert!(fills.iter().all(|f| matches!(f, Paint::Pattern { pattern, .. } if *pattern == def.name)), "{fills:?}"); + // An uncoloured pattern draws in the current colour: one swatch per colour. + let stencil = stream("/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", "0 0 5 5 re f"); + let d = open(&one_page( + "/CS0 cs 1 0 0 /P0 scn 0 0 100 50 re f 0 0 1 /P0 scn 0 50 100 50 re f", + &format!("/Pattern << /P0 {p} 0 R >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"), + &[&stencil], + )); + let colors: Vec = d.patterns.iter().filter_map(|p| fill_color(&p.art[0])).collect(); + assert_eq!(colors, vec![Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]); +} + +// ---------- text ---------- + +const HELVETICA: &str = + "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> /F2 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >>"; + +fn texts(d: &Document) -> Vec { + all(d).into_iter().filter_map(|n| if let NodeKind::Text(t) = n.kind { Some(*t) } else { None }).collect() +} + +#[test] +fn text_becomes_point_type() { + let bytes = one_page( + "BT /F1 24 Tf 1 0 0 rg 10 30 Td (Hello) Tj [(Wor) -20 (ld)] TJ ET BT /F1 12 Tf 10 70 Td [(Next) -1000 (line)] TJ /F2 12 Tf 0 0 1 rg ( in bold) Tj ET", + HELVETICA, + &[], + ); + let r = open_with(&bytes, |_| {}); + let t = texts(&r.document); + assert_eq!(t.len(), 2, "{t:?}"); + let text = |o: &vectorcraft_doc::TextObject| o.runs.iter().map(|r| r.text.as_str()).collect::(); + assert_eq!(text(&t[0]), "HelloWorld"); + let st = &t[0].runs[0].style; + assert_eq!((st.font_family.as_str(), st.font_style.as_str(), st.size), ("Helvetica", "Regular", 24.0)); + assert_eq!(st.fill.color(), Some(Color::rgb(1.0, 0.0, 0.0))); + // The baseline origin, in y-down page space. + let o = t[0].xf * kurbo::Point::ORIGIN; + assert!((o.x - 10.0).abs() < 0.01 && (o.y - 70.0).abs() < 0.01, "{o:?}"); + // A gap of an em between words reads as a space; another font on the line is another run. + assert_eq!(text(&t[1]), "Next line in bold"); + let runs: Vec<(&str, &str)> = t[1].runs.iter().map(|r| (r.text.as_str(), r.style.font_style.as_str())).collect(); + assert_eq!(runs, [("Next line", "Regular"), (" in bold", "Bold")]); + assert_eq!(t[1].runs[1].style.fill.color(), Some(Color::rgb(0.0, 0.0, 1.0))); + assert!(!r.warnings.iter().any(|w| w.contains("outlines")), "{:?}", r.warnings); + // As outlines: paths, no type. + let r = open_with(&bytes, |o| o.text_as = TextAs::Outlines); + assert!(texts(&r.document).is_empty()); + assert!(all(&r.document).iter().any(|n| n.name.as_deref() == Some(""))); +} + +#[test] +fn font_names_resolve_to_available_families() { + let f = crate::import_text::Families::available(); + let (family, style, found) = f.resolve("ABCDEF+SourceSans3-Bold", None, false); + assert_eq!((family.as_str(), style.as_str(), found), ("Source Sans 3", "Bold", true)); + let (family, style, found) = f.resolve("TimesNewRomanPS-BoldItalicMT", None, false); + assert_eq!((family.as_str(), style.as_str()), ("Times New Roman", "Bold Italic")); + let _ = found; + let (family, style, found) = f.resolve("MissingSansPro", Some(700), true); + assert_eq!((family.as_str(), style.as_str(), found), ("Missing Sans Pro", "Bold Italic", false)); +} + +// ---------- meshes, stops and extend ---------- + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02X}")).collect::>().join("") + ">" +} + +#[test] +fn a_coons_patch_mesh_becomes_a_gradient_mesh() { + // One patch over (10, 10)–(90, 90): corner colours red (0, 0), green (0, 1), blue (1, 1), + // white (1, 0) in u/v. + let pts: [(u8, u8); 12] = + [(10, 10), (10, 37), (10, 63), (10, 90), (37, 90), (63, 90), (90, 90), (90, 63), (90, 37), (90, 10), (63, 10), (37, 10)]; + let mut data = vec![0u8]; + for (x, y) in pts { + data.extend([x, y]); + } + data.extend([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]); + let s = first_extra(1); + let sh = stream( + "/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode", + &hex(&data), + ); + let d = open(&one_page("/Sh0 sh", &format!("/Shading << /Sh0 {s} 0 R >>"), &[&sh])); + let meshes: Vec = + all(&d).into_iter().filter_map(|n| if let NodeKind::Mesh(m) = n.kind { Some(m) } else { None }).collect(); + let [m] = meshes.as_slice() else { panic!("one mesh: {meshes:?}") }; + assert_eq!((m.rows, m.cols), (1, 1)); + let at = |r, c| &m.points[m.idx(r, c)]; + // Row 0 is v = 0 (PDF y 10 → page y 90), column 1 is u = 1. + assert!((at(0, 0).p - kurbo::Point::new(10.0, 90.0)).hypot() < 0.01, "{:?}", at(0, 0).p); + assert!((at(1, 1).p - kurbo::Point::new(90.0, 10.0)).hypot() < 0.01, "{:?}", at(1, 1).p); + assert_eq!(at(0, 0).color.to_rgb(), [1.0, 0.0, 0.0]); + assert_eq!(at(1, 0).color.to_rgb(), [0.0, 1.0, 0.0]); + assert_eq!(at(1, 1).color.to_rgb(), [0.0, 0.0, 1.0]); + assert_eq!(at(0, 1).color.to_rgb(), [1.0, 1.0, 1.0]); + assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })), "the mesh lies inside the page"); +} + +#[test] +fn gradient_stop_opacity_round_trips() { + let mut d = Document::new(100.0, 100.0); + let mut stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + stops[1].opacity = 0.2; + let g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), + Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0), + ); + add(&mut d, n); + let back = roundtrip(&d); + let nodes = all(&back); + assert!(nodes.iter().all(|n| n.mask.is_none()), "the opacity is in the stops, not a mask"); + let g = nodes.iter().find_map(|n| match n.appearance.fill().map(|f| &f.paint) { + Some(Paint::Gradient(g)) => Some(g.gradient.clone()), + _ => None, + }); + let g = g.expect("a gradient fill"); + let first = g.stops.first().unwrap(); + let last = g.stops.last().unwrap(); + assert!((first.opacity - 1.0).abs() < 0.02 && (last.opacity - 0.2).abs() < 0.02, "{:?}", g.stops); + assert_eq!(last.color.to_rgb(), [0.0, 0.0, 1.0]); +} + +#[test] +fn a_shading_that_does_not_extend_paints_only_between_its_ends() { + let shading = |extend: &str| { + format!( + "/Shading << /Sh0 << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [20 0 80 0] /Extend [{extend}] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >>" + ) + }; + let d = open(&one_page("/Sh0 sh", &shading("false false"), &[])); + let clips: Vec = all(&d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect(); + let [c] = clips.as_slice() else { panic!("one clip group: {clips:?}") }; + let b = c.geometric_bounds().unwrap(); + assert!((b.x0 - 20.0).abs() < 0.01 && (b.x1 - 80.0).abs() < 0.01 && b.y0 <= 0.0 && b.y1 >= 100.0, "{b:?}"); + let d = open(&one_page("/Sh0 sh", &shading("true true"), &[])); + assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. }))); + // Extended at the start only: from the left edge to the end. + let d = open(&one_page("/Sh0 sh", &shading("true false"), &[])); + let b = all(&d).into_iter().find(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).unwrap().geometric_bounds().unwrap(); + assert!(b.x0 <= 0.0 && (b.x1 - 80.0).abs() < 0.01, "{b:?}"); +} + +#[test] +fn appearance_items_are_untouched_for_plain_art() { + // Plain art keeps importing as before: no masks, groups or clips appear. + let d = open(&one_page("q 1 0 0 rg 10 10 30 30 re f Q 0 0 1 RG 2 w 50 50 30 30 re S", "", &[])); + let nodes = all(&d); + assert_eq!(nodes.iter().filter(|n| n.path_data().is_some()).count(), 2); + assert!(nodes.iter().all(|n| n.mask.is_none() && !matches!(n.kind, NodeKind::Group { .. }))); + assert!(matches!(nodes.iter().find(|n| n.appearance.stroke().is_some()).unwrap().appearance.items[0], AppearanceItem::Stroke(_))); +} diff --git a/crates/pdf/src/tests_import_layers.rs b/crates/pdf/src/tests_import_layers.rs new file mode 100644 index 00000000..5bcfd065 --- /dev/null +++ b/crates/pdf/src/tests_import_layers.rs @@ -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 /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 { + let (objs, catalog, ids) = groups(1, config); + pdf_with_catalog(&[page(ids)], &[&objs[0], &objs[1]], &catalog, None) +} + +fn names(d: &Document) -> Vec { + 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 { + 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)]); +} diff --git a/crates/testkit/src/pdf.rs b/crates/testkit/src/pdf.rs index 2dccda88..88a8085d 100644 --- a/crates/testkit/src/pdf.rs +++ b/crates/testkit/src/pdf.rs @@ -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 { + 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 { 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![]; let kids: Vec = (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); diff --git a/docs/mcp.md b/docs/mcp.md index 0230e53b..50781e20 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -235,6 +235,19 @@ and Separation and DeviceN inks become spot swatches the art links to at its tin PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) can't be opened yet and say so, as does an `.ai` whose PDF part is only a placeholder page. +What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art; +the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout, +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. 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","layers":false}}} +``` + Preserve Editing (`preserveEditing`, on in the `VectorCraft Default` preset) embeds the native document in the PDF as an embedded file (`vectorcraft-editing.vectorcraft`; files named `drawcraft-editing.drawcraft` are read too) with a hash of the pages it was written with. `open_file` / `document.open` of such a PDF, `.ai` or `.ait` (no `pages` picked) restores the