diff --git a/ROADMAP.md b/ROADMAP.md index 2a03e721..78a52a76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed. - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export. - - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). + - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). - Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index fd22a6e7..b8b3b025 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -20,7 +20,7 @@ use vectorcraft_doc::{ use vectorcraft_geom::{FillRule, PathData}; use crate::import_color::{Colors, Native}; -use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec}; +use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover}; 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, Upright}; @@ -112,6 +112,11 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result Result> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect(); - placeholder &= crate::pages::has_private_data(page) && only_text(&children); + placeholder &= ai && only_text(&children); let mut right = ab.x1; if opts.crop == CropTo::Bounding && let Some(art) = vectorcraft_doc::live::nodes_bounds(&children) @@ -347,6 +352,28 @@ struct FontInfo { face: Option>, } +/// The page a PDF-compatible `.ai` paints under its layers is the editor's page, not art: an opaque +/// white rectangle the size of the page (`page`, document space), outside every layer and before +/// any of them. Drop it from the page's art (`parts`, as [`Builder::end_page`] gives them). +fn drop_page_fill(parts: &mut Vec<(Option, Vec>)>, page: Rect) { + let layered = parts.iter().any(|(g, _)| g.is_some()); + let Some((None, art)) = parts.first_mut() else { return }; + // Not a spot colour: a white ink is art. + let fill = art.first().is_some_and(|n| { + layered + && n.blend == BlendMode::Normal + && white_cover(n, page) + && n.appearance.fill().is_some_and(|f| matches!(f.paint, Paint::Solid { swatch: None, .. })) + && n.geometric_bounds().is_some_and(|b| contains(page.inflate(0.5, 0.5), b)) + }); + if fill { + art.remove(0); + if art.is_empty() { + parts.remove(0); + } + } +} + /// Is this art text and nothing else (in groups and clips), with some text? fn only_text(nodes: &[Arc]) -> bool { fn walk(nodes: &[Arc], text: &mut bool) -> bool { diff --git a/crates/pdf/src/import_mask.rs b/crates/pdf/src/import_mask.rs index db446f01..eda4bdb4 100644 --- a/crates/pdf/src/import_mask.rs +++ b/crates/pdf/src/import_mask.rs @@ -42,6 +42,11 @@ fn cover(n: &Node, page: Rect) -> Option<(Color, f32)> { (covers && n.mask.is_none()).then_some((*color, f.opacity * n.opacity)) } +/// An opaque white rectangle covering `page`, filled only. +pub(crate) fn white_cover(n: &Node, page: Rect) -> bool { + cover(n, page).is_some_and(|(c, a)| a >= 0.999 && luminance(&c) > 0.999) +} + /// 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 @@ -105,7 +110,7 @@ pub(crate) fn mask_spec( 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) + && white_cover(last, page) { art.pop(); invert = !invert; diff --git a/crates/pdf/src/tests_import_layers.rs b/crates/pdf/src/tests_import_layers.rs index 5bcfd065..f7adc10d 100644 --- a/crates/pdf/src/tests_import_layers.rs +++ b/crates/pdf/src/tests_import_layers.rs @@ -134,3 +134,61 @@ fn an_encrypted_file_keeps_its_hidden_layers() { assert!(d.layers[0].visible && !d.layers[1].visible); assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]); } + +/// A page drawing `before`, white and red squares in "Shapes", then `after`; `ai`: with an +/// editor's private data (a PDF-compatible `.ai`). +fn page_fill(before: &str, after: &str, ai: bool) -> Document { + let (objs, catalog, ids) = groups(1, ""); + let page = PdfPage { + resources: format!( + "/Properties << /MC0 {} 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> /ColorSpace << /W [/Separation /White /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 0 0] /N 1 >>] >>", + ids[0] + ), + entries: if ai { "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into() } else { String::new() }, + ..PdfPage::new(200.0, 100.0, &format!("{before} /OC /MC0 BDC 1 1 1 rg 20 20 60 60 re f 1 0 0 rg 120 20 60 60 re f EMC {after}")) + }; + import(&pdf_with_catalog(&[page], &[&objs[0], &objs[1]], &catalog, None)).unwrap() +} + +fn path_count(d: &Document) -> usize { + let mut n = 0; + d.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Path { .. }))); + n +} + +#[test] +fn an_ai_files_white_page_outside_its_layers_is_not_art() { + // RGB, grey and CMYK white, the rectangle drawn either way round. + for fill in ["1 1 1 rg 0 0 200 100 re f", "1 g 0 100 200 -100 re f", "0 0 0 0 k 0 0 200 100 re f"] { + let d = page_fill(fill, "", true); + assert_eq!(names(&d), ["Shapes"], "{fill}"); + assert_eq!(path_count(&d), 2, "{fill}"); + assert_eq!(colors(&d.layers[0]), [Color::rgb(1.0, 1.0, 1.0), Color::rgb(1.0, 0.0, 0.0)], "{fill}"); + } +} + +#[test] +fn a_page_fill_that_is_art_stays() { + // A plain PDF's white background is art, as are an .ai's fills that aren't a plain white + // page under its layers: grey, smaller or larger than the page, stroked, translucent or a + // white ink. + let page = "1 1 1 rg 0 0 200 100 re f"; + let kept = [ + (page, false), + ("0.9 g 0 0 200 100 re f", true), + ("1 g 10 10 180 80 re f", true), + ("1 g -20 -20 240 140 re f", true), + ("1 g 0 G 0 0 200 100 re B", true), + ("/GS0 gs 1 g 0 0 200 100 re f", true), + ("/W cs 1 scn 0 0 200 100 re f", true), + ]; + for (fill, ai) in kept { + let d = page_fill(fill, "", ai); + assert_eq!(names(&d), ["Page 1", "Shapes"], "{fill}"); + assert_eq!(path_count(&d), 3, "{fill}"); + } + // Painted over the layers. + let d = page_fill("", page, true); + assert_eq!(names(&d), ["Shapes", "Page 1"]); + assert_eq!(path_count(&d), 3); +} diff --git a/docs/mcp.md b/docs/mcp.md index ed6f993e..02310708 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -366,7 +366,7 @@ their stop opacity and stop where the shading doesn't extend. Text becomes point 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: +art outside them goes to a layer per page (except the opaque white page a `.ai` paints under its layers, which isn't art). `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}}}