From fd50bd6f843e80e80b4db1ad7c25c863bff90a33 Mon Sep 17 00:00:00 2001 From: dwetscher <24957205+dwetscher@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:54:36 +0200 Subject: [PATCH] =?UTF-8?q?AF.5:=20Affinity=20import=20=E2=80=94=20strokes?= =?UTF-8?q?,=20text=20decoration,=20text=20placement,=20outline=20effects,?= =?UTF-8?q?=20CMYK=20photos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strokes set to None no longer import as black 1px strokes. - Underline and strikethrough are kept. - Frame text starts at cap height; lines hanging out of a frame make it taller; mixed-alignment artistic text lines up within its longest line; line breaks inside a paragraph get no paragraph spacing. - Table cell text taller than its cell is centred and stays visible. - Outline effects on stroked shapes are drawn around the stroke. - CMYK pixel layers go through the document's embedded CMYK profile; without one, opaque layers stay CMYK. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 5 +- ROADMAP.md | 3 +- crates/affinity/README.md | 43 +- crates/affinity/src/container.rs | 15 + crates/affinity/src/lib.rs | 1 + crates/affinity/src/model.rs | 580 ++++++++- crates/affinity/src/overlay.rs | 543 +++++++++ crates/affinity/src/paint.rs | 9 +- crates/affinity/src/raster.rs | 301 ++++- crates/affinity/src/text.rs | 380 ++++-- crates/affinity/tests/raster.rs | 11 +- .../tests/support/affinity3-signatures.txt | 66 +- crates/affinity/tests/support/features.rs | 5 + crates/affinity/tests/support/text_fixture.rs | 2 +- crates/affinity/tests/text.rs | 22 +- crates/engine/src/cmd/fileio/affinity.rs | 982 ++++++++++++++-- crates/engine/tests/affinity_features.rs | 1043 +++++++++++++++++ crates/engine/tests/affinity_local.rs | 84 ++ crates/engine/tests/affinity_text.rs | 18 +- crates/text/src/fontdb.rs | 5 + docs/gaps.md | 3 +- docs/target-app-parity.md | 7 +- 22 files changed, 3744 insertions(+), 384 deletions(-) create mode 100644 crates/affinity/src/overlay.rs create mode 100644 crates/engine/tests/affinity_features.rs create mode 100644 crates/engine/tests/affinity_local.rs diff --git a/README.md b/README.md index 63e38c481..4b07b8ab6 100644 --- a/README.md +++ b/README.md @@ -242,8 +242,9 @@ Pathfinder and Shape Builder, paint, gradients, appearance and transparency, typ files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package). Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping -and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the -import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft +and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master +pages, shadows, glows, outlines and overlays, embedded Affinity documents and images, with what didn't come in +(adjustments, blurs, bevels, brushes…) listed in the import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs. [Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md). diff --git a/ROADMAP.md b/ROADMAP.md index 003316707..d6e11b577 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -142,6 +142,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. | | 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). | | 2026-10-19 | About 90 community PRs landed in batches through the full gate, with our fixes on top (formatting, catalog rows, small correctness fixes). New: Objects on Path (#3066), Object › Path › Simplify's slider with Auto-Simplify and More Options (#2555), Pencil Edit Selected Paths redraws the middle of a selected path (#2905), Pencil Tool Options' Alt key toggles to Smooth Tool (#2685), 3D solids built from each stroke's outline (#3049), Live Paint Selection Tool Options (#3052), Locate Selected Objects in the Layers panel (#3033), Snap to Unit for tab stops (#3039), DXF circles and arcs as native entities (#2962), Targa files (#2765), a Georgian interface (#2045), Traditional Chinese status and error messages (#2784), the web page's `window.vectorcraft` control channel (#2943), `select.color` (#2939), reproducible export bytes with `SOURCE_DATE_EPOCH` (#2947), Spacing Guides on resizes (#2966) and measurement labels on more tools. Fixed: number fields step to the next whole unit (#2916), a click away from edited type makes no new text (#2922), inch rulers in halves (#2951), Recent cards in New Document (#2904), Japanese W-weights in SVG (#2896), the point-type baseline marker (#2897) and the Reshape tool's falloff (#2900). Headline numbers unchanged. | +| 2026-10-21 | Affinity import (AF.5), brought over from DesignCraft's (storytold/designcraft#291): master pages show on the pages that use them (a locked layer at the bottom); tables open as a text frame per cell and a line per edge; each paragraph keeps its alignment, indents, spacing and leading; frame text takes its text scale, not its frame's; drop shadows and outer glows become live effects, outlines strokes, colour and gradient overlays are baked into the paint; embedded Affinity documents open as editable content, and their artboards no longer become artboards; large pixel layers come in at a reduced resolution; text placed as Affinity places it (lines of differently aligned paragraphs, a frame's first baseline at cap height, last lines whose descenders hang out of the frame), underlines and strikethrough, line breaks inside paragraphs, table cells taller than their row, no stroke on shapes whose stroke style is None, CMYK pixel layers (phone photos in CMYK documents) shown through the document's own CMYK profile instead of the profile-free formula and its blue cast, outlines on stroked shapes. Headline numbers unchanged. | | 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. | | 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from the out port (#2713, part of #2572), a panel per swatch library with Persistent (#2714, #2603), installed plug-ins kept across restarts (#2695, #2541). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). | | 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5). diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 4ffdbabd8..06af3d877 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -11,6 +11,7 @@ Affinity files. | Affinity | VectorCraft | |---|---| | Pages, Publisher spreads (one or two pages) | artboards, spreads side by side | +| Master pages (`MpCh`) shown on pages through master page instances (`MPIN`: master, page offset, master page, count) | a locked "Master A, B…" layer at the bottom holding each master page's objects on the pages that show it | | Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke | | Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups | | Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) | @@ -21,22 +22,24 @@ Affinity files. | Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask | | Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) | | Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) | -| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance | +| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance | | Fill layers (`FRst`) | filled rectangles | -| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type | +| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) | +| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) | | Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images | -| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin | embedded PNG images | -| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them | +| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin; the document's CMYK colour profile (`CMYP`) | embedded PNG images; CMYK layers through the document's profile (relative colorimetric, black-point compensation, as Affinity converts them), or, without one, CMYK images that keep their inks | +| Embedded Affinity documents (`EmbN` with an `EmDc` entry), four deep at most | their first page's content, editable (their artboards only clip, they don't become artboards); other embedded documents and symbols as the picture Affinity cached of them | +| Layer effects: drop shadows, outer glows, inner shadows; outlines; colour and gradient overlays in any blend mode | Stylize › Drop Shadow and Outer Glow, Inner Glow at the edge (approximated); one more stroke on shapes (around their own stroke), the characters' stroke on text; overlays baked into fill, stroke, text colours and pixels | | Opacity, visibility, lock, names, the blend modes VectorCraft has | the same | Everything else is reported in the import warnings, one line per kind with a count, never -dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes, -transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial), +dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes, +transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial), special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner types other than round or straight, stars with rounded points, several fills or strokes on one object (the -active one is used), outlined or scaled text, text fields such as page numbers, frames with -columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the -document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal). +active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with +columns or a curved outline, table cell fills, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels with transparency in a document +without a CMYK profile (converted through VectorCraft's working CMYK profile) and Affinity-only blend modes (Add, Linear Light… as Normal). When the native document can't be read at all, the engine opens the embedded PNG preview instead, with a warning that names the reason; that preview can't be placed, used as a template or mined @@ -78,10 +81,22 @@ of CC0 sample files. * **Read-only.** Nothing is written in Affinity's format; export waits until someone can check written files in Affinity itself. -The reader also has an independent copy in PhotoCraft's `photocraft-affinity`. +The reader also has independent copies in PhotoCraft's `photocraft-affinity` and DesignCraft's +`designcraft-affinity`. Tables, paragraph formats, text scale, master pages, layer effects, +overlays, embedded documents, large pixel layers and HEIC photos were added in DesignCraft +([storytold/designcraft#291](https://github.com/storytold/designcraft/pull/291)) and brought back +here, from the same public fixtures and the thumbnails of documents their owners made, used only +locally. Automatic leading (the font size plus the font's line gap, for artistic and frame text) +was fitted on the thumbnails of one such local document with 56 artboards and on the public Patchy +text-frame fixture. ## Validation +* `engine/tests/affinity_features.rs` covers pages, several artboards per canvas and per spread, + master pages, tables, paragraph formats, text scale, layer effects, overlays and embedded + documents (whose artboards don't become artboards) on synthetic documents, and mutates them. + `engine/tests/affinity_local.rs` compares every artboard of local documents with their thumbnail + when `AFFINITY_SAMPLES` points at a folder (never in CI; nothing is committed). * Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries, checksums, budgets, cycles, hostile streams, every truncation) and property tests of random mutations. @@ -132,8 +147,12 @@ anything is allocated: a value takes about 40 bytes in memory however few it too those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000 and cumulative geometry work at four million, rejecting cycles and partial results on limits. -Pie sweeps normalize finite angles in constant time. Pixel layers are limited to 64 megapixels -(cropped to their content first); gradients to 1024 stops. Text collection is bounded to a +Pie sweeps normalize finite angles in constant time. Pixel layers are decoded at no more than 64 +megapixels each (cropped to their content first; larger ones are read at a half, a quarter… of +their resolution) and 512 megapixels for the whole document, embedded documents included; pixel +masks are read only over what they mask. Embedded documents nest four deep at most and count +against the importing document's limits. Tables have at most 4096 rows and columns, and 64 layer +effects per node are read. Gradients are limited to 1024 stops. Text collection is bounded to a million characters per node before allocation, with a cumulative 16 MiB budget for owned font names, features and gradient stops. Exhaustion warns and uses default attributes for remaining text, preserving its characters. diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 31179a3a0..6b1df9510 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -263,6 +263,21 @@ impl<'a> Archive<'a> { } /// Extract an entry by name: decompress, undo the byte predictor and verify the CRC. + /// What is left of the limits for documents embedded in this one. + pub(crate) fn remaining(&self) -> Limits { + Limits { max_entry: self.limits.max_entry, max_total: self.limits.max_total.saturating_sub(self.extracted) } + } + + /// Count `n` bytes extracted elsewhere (from embedded documents) against this import. + pub(crate) fn charge(&mut self, n: usize) -> Result<(), Error> { + let total = self.extracted.checked_add(n).ok_or(Error::Limit("document too large"))?; + if total > self.limits.max_total { + return Err(Error::Limit("document too large")); + } + self.extracted = total; + Ok(()) + } + pub fn read(&mut self, name: &str) -> Result, Error> { let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone(); let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?; diff --git a/crates/affinity/src/lib.rs b/crates/affinity/src/lib.rs index 4be46c777..38d86a899 100644 --- a/crates/affinity/src/lib.rs +++ b/crates/affinity/src/lib.rs @@ -14,6 +14,7 @@ mod access; pub mod container; mod geometry; pub mod model; +pub mod overlay; pub mod paint; pub mod preview; mod raster; diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 298e32be2..87d68699d 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -1,4 +1,4 @@ -//! The document model VectorCraft imports: spreads, artboards, layers, groups, curves, shapes, +//! The document model the Craft apps import: spreads, artboards, layers, groups, curves, shapes, //! text and images, with transforms composed into document space. Anything else is reported //! by [`Document::warnings`] instead of being dropped silently. @@ -175,17 +175,54 @@ pub struct TextRun { /// Boolean OpenType feature overrides, as tags or `-tag` for disabled features. pub features: Vec, pub all_caps: bool, + /// Underlined characters (`Ints` slot 0 is 1; identified on underlined words in a document its + /// owner made, all other characters there and in the public corpus have 0). + pub underline: bool, + /// Struck-through characters (`Ints` slot 1 is 1; identified the same way, on prices struck + /// through in another document its owner made). + pub strikethrough: bool, /// Font size in document pixels, before the node's transform. pub size: f64, /// Tracking in em. pub tracking: f64, + /// Horizontal scale of the glyphs (1 = as designed). + pub h_scale: f64, /// Fixed line pitch in document pixels, when the run overrides automatic leading. pub leading: Option, pub fill: Paint, + /// Outlined characters: the outline's paint and width (node space). + pub stroke: Option, + /// The run's paragraph: alignment, indents and spacing. + pub paragraph: Paragraph, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Paragraph alignment, indents, spacing and leading, in node space (document pixels before the +/// node's text scale). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Paragraph { + pub align: Align, + /// Indent of every line but the first, from the left. + pub left: f64, + pub right: f64, + /// Indent of the first line, from the left. + pub first: f64, + pub space_before: f64, + pub space_after: f64, + /// Fixed distance between baselines, when the paragraph sets one. + pub leading: Option, + /// Automatic leading as a multiple of the font's own line height (ascent plus descent). + pub auto_leading: f64, +} + +impl Default for Paragraph { + fn default() -> Self { + Self { align: Align::Left, left: 0.0, right: 0.0, first: 0.0, space_before: 0.0, space_after: 0.0, leading: None, auto_leading: 1.0 } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Align { + #[default] Left, Center, Right, @@ -202,12 +239,67 @@ pub struct Text { pub frame: Option, /// Node space to document pixels. pub transform: Affine, + /// Node space to document pixels for the type (its linear part matters): artistic text scales + /// with its node, frame text only with its frame's text scale. + pub text_transform: Affine, +} + +/// Vertical alignment of a table cell's text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VAlign { + Top, + Center, + Bottom, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TableCell { + pub runs: Vec, + /// Space between the cell edges and its text, in node space: left, top, right, bottom. + pub inset: [f64; 4], + pub valign: VAlign, + /// Part of the cell to its left (merged): it has no text of its own. + pub merged_left: bool, +} + +/// A table: a grid of cells with text, and a line on every cell edge. +#[derive(Debug, Clone, PartialEq)] +pub struct Table { + /// Column boundaries (one more than the columns), increasing, in node space. + pub columns: Vec, + /// Row boundaries (one more than the rows), increasing, in node space. + pub rows: Vec, + /// Row by row. + pub cells: Vec, + /// Vertical edges row by row, one more per row than the columns; `None` draws no line. + pub vertical: Vec>, + /// Horizontal edges boundary by boundary (one more than the rows), one per column. + pub horizontal: Vec>, + /// Node space to document pixels. + pub transform: Affine, + /// Node space to document pixels for the cells' type: resizing a table moves its grid, not + /// its type. + pub text_transform: Affine, +} + +impl Table { + pub fn ncols(&self) -> usize { + self.columns.len().saturating_sub(1) + } + pub fn nrows(&self) -> usize { + self.rows.len().saturating_sub(1) + } } #[derive(Debug, Clone, PartialEq)] pub enum Pixels { /// Straight-alpha RGBA, 8 bits per channel, row by row. Rgba8(Vec), + /// A CMYK pixel layer: cyan, magenta, yellow and black ink amounts (0 none, 255 full) and + /// straight alpha, 8 bits each, row by row. Left in CMYK for the importer to show through a + /// colour profile: the profile-free formula turns the extra cyan of a separation's neutrals + /// into a blue cast. + Cmyka8(Vec), /// The original file a placed image was made from (PNG, JPEG…), as embedded. Encoded(Vec), } @@ -246,11 +338,78 @@ pub enum Kind { even_odd: bool, }, Text(Text), + Table(Table), Image(Image), + /// A master page shown on this spread's pages: pages `page..page + count` show the master + /// spread [`Document::masters`]`[master]`'s pages from `master_page` on. + MasterInstance { + master: usize, + page: usize, + master_page: usize, + count: usize, + }, /// Something this reader does not import; its children are still imported. Unsupported, } +/// A layer effect. Lengths are document pixels; `angle` is the direction the shadow is offset in +/// (radians, clockwise from +x as y points down: π/2 puts it below). +#[derive(Debug, Clone, PartialEq)] +pub enum Effect { + DropShadow { + color: crate::paint::Color, + opacity: f64, + radius: f64, + distance: f64, + angle: f64, + }, + InnerShadow { + color: crate::paint::Color, + opacity: f64, + radius: f64, + distance: f64, + angle: f64, + }, + OuterGlow { + color: crate::paint::Color, + opacity: f64, + radius: f64, + }, + /// An outline of `width` around the node's shape: `align` 0 outside, 1 inside, 2 centred. + Outline { + color: crate::paint::Color, + opacity: f64, + width: f64, + align: u16, + }, + /// Bevel and emboss: lit and shaded edges `size` wide. `angle` is the light's azimuth + /// (radians, counter-clockwise from +x with y up), `depth` Affinity's depth (5 by default); + /// `inner` is false for the outer, emboss and pillow kinds. + Bevel { + size: f64, + depth: f64, + angle: f64, + highlight: crate::paint::Color, + highlight_opacity: f64, + shadow: crate::paint::Color, + shadow_opacity: f64, + inner: bool, + }, + /// Colour overlay: everything the node paints takes `color`, laid over it in `blend` mode at + /// `opacity` (the node's own alpha is kept). + ColorOverlay { + color: crate::paint::Color, + opacity: f64, + blend: Blend, + }, + /// Gradient overlay: a colour overlay whose colour comes from `gradient` (document pixels). + GradientOverlay { + gradient: crate::paint::Gradient, + opacity: f64, + blend: Blend, + }, +} + #[derive(Debug, Clone, PartialEq)] pub struct Node { pub class: Tag, @@ -264,6 +423,8 @@ pub struct Node { pub mask: Option, /// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden. pub pixel_mask: Option, + /// Layer effects the importer can show or bake in (others are reported). + pub effects: Vec, /// Bottom to top. pub children: Vec, } @@ -285,12 +446,21 @@ pub struct Document { /// Pixels per inch: all geometry is in document pixels at this resolution. pub dpi: f64, pub spreads: Vec, + /// Master spreads, which [`Kind::MasterInstance`] nodes show on pages. + pub masters: Vec, /// Application that last saved the file, as stored (`Affinity 3.0.2`), when present. pub saved_by: Option, /// Content that was not imported or was approximated, one line per kind, deduplicated. pub warnings: Vec, + /// The document's CMYK colour profile (an ICC file), when it embeds one: the root's `CMYP`, + /// which each CMYK pixel layer's colour settings (`Prof`) also name, with relative + /// colorimetric intent and black-point compensation in every document checked. + pub cmyk_profile: Option>, } +/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes). +const MAX_PROFILE_BYTES: usize = 32 << 20; + /// Deepest layer nesting followed; deeper content is reported, not imported. const MAX_TREE_DEPTH: usize = 128; /// Total layer nodes imported. @@ -300,6 +470,9 @@ const MAX_NODES: usize = 500_000; /// count the bytes extracted from it, and a layer of one flat colour needs none (#1704). const MAX_LAYER_PIXELS: u64 = 512 << 20; +/// Embedded documents inside embedded documents are followed this deep. +const MAX_EMBED_DEPTH: usize = 4; + /// Read a document: archive, `doc.dat` and the model. pub fn read(bytes: &[u8], limits: Limits) -> Result { read_within(bytes, limits, MAX_LAYER_PIXELS) @@ -307,35 +480,76 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result { /// [`read`], its pixel layers holding at most `pixels` pixels together. pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result { + read_at(bytes, limits, pixels, Affine::IDENTITY, 0).map(|r| r.document) +} + +/// A document read by [`read_at`], with what it used of its budgets. +struct ReadAt { + document: Document, + /// Bytes its archive extracted. + extracted: usize, + /// Pixels its pixel layers left of the allowance. + pixels_left: u64, +} + +/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth` +/// deep), its pixel layers holding at most `pixels` pixels together. +fn read_at(bytes: &[u8], limits: Limits, pixels: u64, base: Affine, depth: usize) -> Result { let mut archive = Archive::open(bytes, limits)?; let doc = archive.read("doc.dat")?; let s = stream::parse(&doc)?; - let mut reader = Reader::new(&s, &mut archive); + let mut reader = Reader::new(&s, &mut archive, base, depth); reader.pixels_left = pixels; - reader.document() + let (document, pixels_left) = reader.document()?; + Ok(ReadAt { document, extracted: archive.extracted(), pixels_left }) } pub(crate) struct Reader<'s, 'a, 'b> { pub(crate) s: &'s Stream, pub(crate) archive: &'b mut Archive<'a>, pub(crate) dpi: f64, - warnings: BTreeMap, + pub(crate) warnings: BTreeMap, nodes: usize, active: HashSet, + /// Where the spreads go: the identity, or an embedding document's placement. + base: Affine, + /// The linear part of `base`, which also scales frame text. + pub(crate) type_base: Affine, + /// How deep inside embedded documents this one is. + depth: usize, + /// An embedded document's content, waiting to become its node's children. + pending: Option>, + /// The master spreads, in the order of [`Document::masters`]. + master_ids: Vec, /// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all). pub(crate) pixels_left: u64, } impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { - fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self { - Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS } + pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self { + let [a, b, c, d, ..] = base.0; + Self { + s, + archive, + dpi: 72.0, + warnings: BTreeMap::new(), + nodes: 0, + active: HashSet::new(), + base, + type_base: Affine([a, b, c, d, 0.0, 0.0]), + depth, + pending: None, + master_ids: Vec::new(), + pixels_left: MAX_LAYER_PIXELS, + } } pub(crate) fn warn(&mut self, what: impl Into) { *self.warnings.entry(what.into()).or_default() += 1; } - fn document(mut self) -> Result { + /// The document, and the pixels its pixel layers left of the allowance. + fn document(mut self) -> Result<(Document, u64), Error> { let s = self.s; let root = s.root; self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0); @@ -347,40 +561,65 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { } }); let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?; + self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect(); let mut spreads = Vec::new(); for spread in s.objs(doc, b"Chld") { - if !s.is(spread, b"Sprd") { - self.warn("an unknown kind of page"); - continue; + if let Some(sp) = self.spread(doc, spread)? { + spreads.push(sp); } - let pages = pages(s, spread); - let default_size = - s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); - let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { - x0: 0.0, - y0: 0.0, - x1: 0.0, - y1: 0.0, - }); - let transparent = s.bool(spread, b"SprT").unwrap_or(false); - let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?; - if mask.is_some() { - self.warn("mask layers directly on a page (imported without them)"); - } - spreads.push(Spread { bounds, pages, transparent, nodes }); } if spreads.is_empty() { return Err(Error::Malformed("document has no pages")); } - if !s.objs(doc, b"MpCh").is_empty() { - self.warn("master pages (their content is not placed on the pages that use them)"); + let mut masters = Vec::new(); + for master in self.master_ids.clone() { + match self.spread(doc, master)? { + Some(sp) => masters.push(sp), + // Keep the indices of `MasterInstance::master` valid. + None => masters.push(Spread { + bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 }, + pages: Vec::new(), + transparent: true, + nodes: Vec::new(), + }), + } } + let cmyk_profile = s + .obj(root, b"CMYP") + .and_then(|p| s.bytes(p, b"Data")) + .filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice())) + .map(<[u8]>::to_vec); let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect(); - Ok(Document { dpi: self.dpi, spreads, saved_by, warnings }) + Ok((Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile }, self.pixels_left)) } - /// The children of `parent`, and the pixel mask a mask layer among them puts on the parent. - fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option), Error> { + /// A spread (`Sprd`) of the document `doc`, or of its master spreads. + fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result, Error> { + let s = self.s; + if !s.is(spread, b"Sprd") { + self.warn("an unknown kind of page"); + return Ok(None); + } + let pages = pages(s, spread); + let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); + let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { + x0: 0.0, + y0: 0.0, + x1: 0.0, + y1: 0.0, + }); + let transparent = s.bool(spread, b"SprT").unwrap_or(false); + let (nodes, mask) = self.children(spread, self.base, 0)?; + if mask.is_some() { + self.warn("mask layers directly on a page (imported without them)"); + } + Ok(Some(Spread { bounds, pages, transparent, nodes })) + } + + /// The children of `parent`, and the mask layer among them that masks the parent (with its + /// placement), read later over just what it masks. + #[allow(clippy::type_complexity)] + fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option<(ObjId, Affine)>), Error> { let s = self.s; let mut out = Vec::new(); let mut mask = None; @@ -391,10 +630,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { continue; } let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - match crate::raster::mask(self, c, local.then(world))? { - Some(m) if mask.is_none() => mask = Some(m), - Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), - None => {} + if mask.is_none() { + mask = Some((c, local.then(world))); + } else { + self.warn("several pixel masks on one layer (only the first is used)"); } continue; } @@ -442,14 +681,28 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { self.kind(id, class, world, parent)? }; // A compound's children are its operands, already merged into its outline. - let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? }; - let (mask, attached_mask) = self.attached_masks(id, world)?; + let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? }; + if let Some(embedded) = self.pending.take() { + children.extend(embedded); + } + // Pixel masks are read only over what they mask (they can span a whole spread at print + // resolution, a gigapixel). + let over = match (kind_bounds(&kind), nodes_bounds(&children)) { + (Some(a), Some(b)) => Some(union(a, b)), + (a, b) => a.or(b), + }; + let child_mask = match child_mask { + Some((c, at)) => crate::raster::mask(self, c, at, over)?, + None => None, + }; + let (mask, attached_mask) = self.attached_masks(id, world, over)?; let pixel_mask = attached_mask.or(child_mask); self.active.remove(&id); - if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) { - self.warn("layer effects (shadows, glows, outlines, bevels…)"); - } - Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children })) + let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children }; + // Gradient overlays are laid out over the node's bounds. + let bounds = nodes_bounds(std::slice::from_ref(&node)); + node.effects = self.effects(id, world, bounds); + Ok(Some(node)) } fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result { @@ -471,6 +724,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let (fills, strokes) = paint::node_paint(self, id, world); Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } } + b"TxtT" => match crate::text::table(self, id, world) { + Some(t) => Kind::Table(t), + None => { + self.warn("tables that could not be read"); + Kind::Unsupported + } + }, b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) { Some(t) => Kind::Text(t), None => { @@ -482,15 +742,35 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { Some(img) => Kind::Image(img), None => Kind::Unsupported, }, - b"EmbN" => match crate::raster::embedded(self, id, world)? { - Some(img) => { - self.warn("embedded documents and symbols (imported as pictures of them)"); - Kind::Image(img) + b"MPIN" => { + // `SLnk` links the instance with the master spread it shows. + let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default(); + let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o)); + let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096); + match master { + Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) }, + None => { + self.warn("master page instances without their master"); + Kind::Unsupported + } } - None => { - self.warn("embedded documents and symbols without a cached picture"); - Kind::Unsupported + } + b"EmbN" => match self.embedded_document(id, world) { + // The embedded document itself, as editable content. + Some(nodes) => { + self.pending = Some(nodes); + Kind::Group } + None => match crate::raster::embedded(self, id, world)? { + Some(img) => { + self.warn("embedded documents and symbols (imported as pictures of them)"); + Kind::Image(img) + } + None => { + self.warn("embedded documents and symbols without a cached picture"); + Kind::Unsupported + } + }, }, b"FRst" => { // A fill layer paints its whole bitmap area; its gradient lives in page space. @@ -515,9 +795,89 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { }) } + /// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the + /// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below. + fn effects(&mut self, id: ObjId, world: Affine, bounds: Option) -> Vec { + let s = self.s; + let mut out = Vec::new(); + for e in s.objs(id, b"FiEf").into_iter().take(64) { + if s.bool(e, b"Enab") == Some(false) { + continue; + } + // "Scale with object": lengths follow the node's transform; otherwise they are absolute. + let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 }; + let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k; + let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0); + let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0); + let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c)); + let class = s.class(e).map(|c| c.0.to_be_bytes()); + match (class.as_ref(), color) { + (Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), + (Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), + (Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }), + (Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => { + let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a); + out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align }); + } + (Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"), + (Some(b"BevE" | b"EmbE"), _) => { + // No radius, no bevel (as Affinity draws it). + let size = len(b"Radi"); + if size > 0.0 { + let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; + let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }; + let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white); + let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black); + let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0); + let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0); + let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0); + let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0); + out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner }); + } + } + (Some(b"ColO"), Some(color)) => { + let blend = self.effect_blend(e); + out.push(Effect::ColorOverlay { color, opacity, blend }); + } + (Some(b"GrdO"), _) => { + let blend = self.effect_blend(e); + // The gradient's unit space spans the node's bounding box from -1 to 1, centred + // on it (y down): checked against the stored previews of real documents. + let space = + bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0])); + match (s.obj(e, b"GrFl"), space) { + (Some(fill), Some(space)) => match paint::descriptor(self, fill, space) { + Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }), + Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }), + _ => {} + }, + // Nothing visible to lay it over. + (Some(_), None) => {} + (None, _) => self.warn("gradient overlay effects that could not be read (left out)"), + } + } + (Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"), + (Some(b"Gaus"), _) => self.warn("blur effects (left out)"), + _ => self.warn("layer effects of other kinds (left out)"), + } + } + out + } + + /// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned). + fn effect_blend(&mut self, e: ObjId) -> Blend { + match self.s.enumeration(e, b"BlnM") { + None => Blend::Normal, + Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { + self.warn("an unknown blend mode (imported as Normal)"); + Blend::Normal + }), + } + } + /// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines; /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported. - fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option, Option), Error> { + fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option) -> Result<(Option, Option), Error> { let s = self.s; let attached = s.objs(id, b"AdCh"); let mut mask: Option = None; @@ -534,7 +894,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { } } else if class == Tag::of(b"MRst") { let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); - match crate::raster::mask(self, a, local)? { + match crate::raster::mask(self, a, local, over)? { Some(m) if pixels.is_none() => pixels = Some(m), Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), None => {} @@ -546,6 +906,68 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { Ok((mask, pixels)) } + /// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds + /// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes, + /// placed so the centre of its page, first artboard or content is the node's origin. + fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option> { + let s = self.s; + let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string(); + if entry.is_empty() { + return None; + } + if self.depth >= MAX_EMBED_DEPTH { + self.warn("embedded documents nested more than four deep"); + return None; + } + let data = self.archive.read(&entry).ok()?; + let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?; + if !crate::is_affinity(inner) { + return None; + } + // A first reading finds its page and resolution, the second places it. + let probe = match read_at(inner, self.archive.remaining(), self.pixels_left, Affine::IDENTITY, self.depth + 1) { + Ok(r) => r, + Err(e) => { + self.warn(format!("embedded documents that could not be read ({e})")); + return None; + } + }; + let used = probe.extracted; + let spread = probe.document.spreads.first()?; + // `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page. + let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2); + let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None }); + let page = if content { None } else { spread.pages.first().copied().or(first_board) }; + let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds); + // Its document pixels are the node's units, whatever either document's resolution. + let k = 1.0; + let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0); + let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world); + if !base.is_finite() { + self.warn("embedded documents with an invalid placement"); + return None; + } + if self.archive.charge(used).is_err() { + self.warn("embedded documents beyond the size limit (left out)"); + return None; + } + let placed = read_at(inner, self.archive.remaining(), self.pixels_left, base, self.depth + 1).ok()?; + // The probe's pixels were only looked at; the placed content's stay. + self.pixels_left = placed.pixels_left; + let doc = placed.document; + if self.archive.charge(placed.extracted).is_err() { + self.warn("embedded documents beyond the size limit (left out)"); + return None; + } + for w in doc.warnings { + self.warn(format!("{w} [in embedded documents]")); + } + if doc.spreads.len() > 1 { + self.warn("embedded documents with several pages (the first is used)"); + } + doc.spreads.into_iter().next().map(|s| s.nodes) + } + fn artboard(&mut self, id: ObjId, world: Affine) -> Kind { let s = self.s; let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else { @@ -580,6 +1002,64 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { } } +/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames +/// and tables), in document pixels. +pub fn nodes_bounds(nodes: &[Node]) -> Option { + fn walk(nodes: &[Node], depth: usize, out: &mut Option) { + if depth > MAX_TREE_DEPTH { + return; + } + for n in nodes.iter().filter(|n| n.visible) { + kind_points(&n.kind, &mut |p| add_point(out, p)); + walk(&n.children, depth + 1, out); + } + } + let mut out = None; + walk(nodes, 0, &mut out); + out +} + +/// The bounds of what one node's own kind draws (not its children). +pub(crate) fn kind_bounds(kind: &Kind) -> Option { + let mut out = None; + kind_points(kind, &mut |p| add_point(&mut out, p)); + out +} + +fn add_point(out: &mut Option, p: Point) { + if p.x.is_finite() && p.y.is_finite() { + let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y }; + *out = Some(out.map_or(r, |o| union(o, r))); + } +} + +fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) { + let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y })); + match kind { + Kind::Shape { path, .. } | Kind::Artboard { path, .. } => { + for sp in &path.subpaths { + add(sp.start); + for seg in &sp.segments { + seg.iter().for_each(|p| add(*p)); + } + } + } + Kind::Image(img) => { + corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add) + } + Kind::Text(t) => match t.frame { + Some(f) => corners(f, t.transform).into_iter().for_each(add), + None => add(t.transform.apply(t.anchor)), + }, + Kind::Table(t) => { + if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) { + corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add); + } + } + Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} + } +} + /// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` /// equal pages (Publisher 1 facing spreads). fn pages(s: &Stream, spread: ObjId) -> Vec { diff --git a/crates/affinity/src/overlay.rs b/crates/affinity/src/overlay.rs new file mode 100644 index 000000000..3cf98fab4 --- /dev/null +++ b/crates/affinity/src/overlay.rs @@ -0,0 +1,543 @@ +//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what +//! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's +//! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps). +//! +//! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid +//! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow, +//! Darker/Lighter Color) use their usual definitions. + +use std::borrow::Cow; + +use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point}; +use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke}; + +/// Deepest subtree an overlay is baked into (the reader bounds trees well below this). +const MAX_DEPTH: usize = 256; + +/// Most pixels an overlay is baked into per image. +const MAX_PIXELS: usize = 64 << 20; + +#[derive(Clone, Copy)] +enum Source<'a> { + Solid(Color), + Gradient(&'a Gradient), +} + +#[derive(Clone, Copy)] +struct Overlay<'a> { + source: Source<'a>, + opacity: f64, + blend: Blend, +} + +/// `node` with its colour and gradient overlays baked in (borrowed unchanged when it has none). +/// `warn` hears what could only be approximated. +pub fn apply<'a>(node: &'a Node, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> { + let overlays: Vec = node + .effects + .iter() + .filter_map(|e| match e { + Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }), + Effect::GradientOverlay { gradient, opacity, blend } => { + Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend }) + } + _ => None, + }) + .filter(|o| o.opacity > 0.0) + .collect(); + if overlays.is_empty() { + return Cow::Borrowed(node); + } + let mut out = node.clone(); + out.effects.retain(|e| !matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. })); + for o in &overlays { + if matches!(o.blend, Blend::Erase | Blend::PassThrough) { + warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)"); + } + bake_node(&mut out, o, 0, warn); + } + Cow::Owned(out) +} + +fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) { + if depth > MAX_DEPTH { + return; + } + match &mut node.kind { + Kind::Shape { fills, strokes, .. } => { + for f in fills.iter_mut() { + *f = paint(f, o, warn); + } + strokes.iter_mut().for_each(|s| stroke(s, o, warn)); + } + Kind::Artboard { background, strokes, .. } => { + for f in background.iter_mut() { + *f = paint(f, o, warn); + } + strokes.iter_mut().for_each(|s| stroke(s, o, warn)); + } + Kind::Text(t) => { + for r in &mut t.runs { + r.fill = paint(&r.fill, o, warn); + if let Some(s) = &mut r.stroke { + stroke(s, o, warn); + } + } + } + Kind::Table(t) => { + for c in &mut t.cells { + for r in &mut c.runs { + r.fill = paint(&r.fill, o, warn); + } + } + for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() { + stroke(s, o, warn); + } + } + Kind::Image(img) => { + if !pixels(img, o) { + warn("overlay effects on images too large to recolour (left out)"); + } + } + Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} + } + for e in &mut node.effects { + match e { + Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o), + Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {} + Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {} + } + } + for c in &mut node.children { + bake_node(c, o, depth + 1, warn); + } +} + +fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) { + s.paint = paint(&s.paint, o, warn); +} + +/// A paint with the overlay over it. +fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint { + match (p, o.source) { + (Paint::None, _) => Paint::None, + (Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)), + (Paint::Gradient(g), Source::Solid(s)) => { + Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() }) + } + // Over one colour, the result is the overlay's gradient with each stop laid over it. + (Paint::Solid(c), Source::Gradient(g)) => { + Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() }) + } + (Paint::Gradient(base), Source::Gradient(g)) => { + if !(o.blend == Blend::Normal && o.opacity >= 1.0) { + warn("gradient overlays on gradients (approximated)"); + } + // Exact when the overlay covers it; otherwise over the base gradient's average colour. + let avg = average(base); + Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() }) + } + } +} + +impl Source<'_> { + /// One colour for the source (a gradient's average) where only one fits. + fn at_solid(&self) -> Color { + match self { + Source::Solid(c) => *c, + Source::Gradient(g) => average(g), + } + } +} + +fn average(g: &Gradient) -> Color { + let n = g.stops.len().max(1) as f64; + let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0); + for s in &g.stops { + let [sr, sg, sb] = rgb(s.color); + r += sr; + gr += sg; + b += sb; + a += s.color.alpha(); + } + Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n } +} + +/// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept. +fn over(base: Color, source: Color, o: &Overlay) -> Color { + let k = (o.opacity * source.alpha()).clamp(0.0, 1.0); + let alpha = base.alpha(); + // Normal mixes within the colour model when both share it, so CMYK stays CMYK. + if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) { + let mix = |a: f64, b: f64| a + (b - a) * k; + match (base, source) { + (Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => { + return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha }; + } + (Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha }, + (_, s) if k >= 1.0 => return with_alpha(s, alpha), + _ => {} + } + } + let b = rgb(base); + let s = rgb(source); + let blended = blend(o.blend, b, s); + let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k); + Color::Rgb { r, g, b: bl, a: alpha } +} + +fn with_alpha(c: Color, a: f64) -> Color { + match c { + Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a }, + Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a }, + Color::Gray { v, .. } => Color::Gray { v, a }, + Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a }, + } +} + +/// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the +/// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour. +fn rgb(c: Color) -> [f64; 3] { + let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + match c { + Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)], + Color::Gray { v, .. } => [f(v); 3], + Color::Cmyk { c, m, y, k, .. } => { + let w = 1.0 - f(k); + [(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w] + } + Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f), + } +} + +/// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65. +fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { + let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; + let (l, a, b) = (finite(l), finite(a), finite(b)); + let fy = (l + 16.0) / 116.0; + let (fx, fz) = (fy + a / 500.0, fy - b / 200.0); + let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) }; + // D50 white. + let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz)); + // XYZ (D50) to linear sRGB, Bradford-adapted. + let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z; + let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z; + let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z; + let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 }; + [gamma(r), gamma(g), gamma(bl)] +} + +/// Bake the overlay into an image's pixels. False if they couldn't be read or are too many. +fn pixels(img: &mut Image, o: &Overlay) -> bool { + let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false }; + let Some(len) = n.checked_mul(4) else { return false }; + let mut px = match &img.pixels { + Pixels::Rgba8(p) => match p.get(..len) { + Some(p) => p.to_vec(), + None => return false, + }, + Pixels::Encoded(bytes) => match image::load_from_memory(bytes) { + Ok(d) if d.width() == img.width && d.height() == img.height => d.to_rgba8().into_raw(), + _ => return false, + }, + // An overlay is blended in RGB: CMYK pixels take the profile-free formula here. + Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) { + Some(p) if p.len() == n * 5 => p + .as_chunks::<5>() + .0 + .iter() + .flat_map(|[c, m, y, k, a]| { + let w = 255 - u16::from(*k); + let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8; + [ch(*c), ch(*m), ch(*y), *a] + }) + .collect(), + _ => return false, + }, + }; + let width = img.width.max(1) as usize; + // A solid source is converted once; a gradient is sampled at each pixel's centre. + let solid = match o.source { + Source::Solid(c) => Some((rgb(c), c.alpha())), + Source::Gradient(_) => None, + }; + let to_unit = match o.source { + Source::Gradient(g) => match invert(g.transform) { + Some(m) => Some((img.transform.then(m), g)), + None => return false, + }, + Source::Solid(_) => None, + }; + for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() { + if p[3] == 0 { + continue; + } + let (s, sa) = match (solid, &to_unit) { + (Some(s), _) => s, + (None, Some((m, g))) => { + let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 }); + let c = sample(g, q); + (rgb(c), c.alpha()) + } + (None, None) => continue, + }; + let k = (o.opacity * sa).clamp(0.0, 1.0); + let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0); + let m = blend(o.blend, b, s); + for c in 0..3 { + p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8; + } + } + img.pixels = Pixels::Rgba8(px); + true +} + +/// The gradient's colour at unit-space point `q`. +fn sample(g: &Gradient, q: Point) -> Color { + let t = match g.kind { + GradientKind::Linear => q.x, + GradientKind::Radial => q.x.hypot(q.y), + GradientKind::Conical => q.y.atan2(q.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU, + }; + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) else { return Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } }; + if t <= first.offset { + return first.color; + } + for w in g.stops.windows(2) { + let [a, b] = [&w[0], &w[1]]; + if t <= b.offset { + let span = b.offset - a.offset; + let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 }; + // Affinity's midpoint bias (see `Stop::half_point`). + let k = 1.0 + 8.0 * (a.bias - 0.5).abs(); + let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) }; + let (x, y) = (rgb(a.color), rgb(b.color)); + let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u); + return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u }; + } + } + last.color +} + +fn invert(m: Affine) -> Option { + let [a, b, c, d, e, f] = m.0; + let det = a * d - b * c; + if !(det.is_finite() && det.abs() > 1e-12) { + return None; + } + let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); + Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)])) +} + +/// The blend of backdrop `b` and source `s` (sRGB, 0..1). +fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] { + use Blend::*; + let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0)); + match mode { + Normal | PassThrough | Erase => s, + Darken => sep(f64::min), + Lighten => sep(f64::max), + Multiply => sep(|b, s| b * s), + Screen => sep(screen), + ColorBurn => sep(burn), + ColorDodge => sep(dodge), + Blend::Overlay => sep(|b, s| hard_light(s, b)), + HardLight => sep(hard_light), + SoftLight => sep(soft_light), + Difference => sep(|b, s| (b - s).abs()), + Exclusion => sep(|b, s| b + s - 2.0 * b * s), + Add => sep(|b, s| b + s), + Subtract => sep(|b, s| b - s), + VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }), + LinearLight => sep(|b, s| b + 2.0 * s - 1.0), + PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }), + HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }), + Average => sep(|b, s| (b + s) / 2.0), + Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()), + Reflect => sep(reflect), + Glow => sep(|b, s| reflect(s, b)), + DarkerColor => { + if lum(s) < lum(b) { + s + } else { + b + } + } + LighterColor => { + if lum(s) > lum(b) { + s + } else { + b + } + } + Hue => set_lum(set_sat(s, sat(b)), lum(b)), + Saturation => set_lum(set_sat(b, sat(s)), lum(b)), + Blend::Color => set_lum(s, lum(b)), + Luminosity => set_lum(b, lum(s)), + } +} + +fn screen(b: f64, s: f64) -> f64 { + b + s - b * s +} + +fn burn(b: f64, s: f64) -> f64 { + if b >= 1.0 { + 1.0 + } else if s <= 0.0 { + 0.0 + } else { + 1.0 - ((1.0 - b) / s).min(1.0) + } +} + +fn dodge(b: f64, s: f64) -> f64 { + if b <= 0.0 { + 0.0 + } else if s >= 1.0 { + 1.0 + } else { + (b / (1.0 - s)).min(1.0) + } +} + +fn hard_light(b: f64, s: f64) -> f64 { + if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) } +} + +fn soft_light(b: f64, s: f64) -> f64 { + if s <= 0.5 { + b - (1.0 - 2.0 * s) * b * (1.0 - b) + } else { + let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; + b + (2.0 * s - 1.0) * (d - b) + } +} + +fn reflect(b: f64, s: f64) -> f64 { + if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) } +} + +fn lum(c: [f64; 3]) -> f64 { + 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] +} + +fn clip_color(c: [f64; 3]) -> [f64; 3] { + let l = lum(c); + let n = c[0].min(c[1]).min(c[2]); + let x = c[0].max(c[1]).max(c[2]); + c.map(|v| { + let mut v = v; + if n < 0.0 && l - n > 1e-12 { + v = l + (v - l) * l / (l - n); + } + if x > 1.0 && x - l > 1e-12 { + v = l + (v - l) * (1.0 - l) / (x - l); + } + v.clamp(0.0, 1.0) + }) +} + +fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] { + let d = l - lum(c); + clip_color(c.map(|v| v + d)) +} + +fn sat(c: [f64; 3]) -> f64 { + c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) +} + +fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] { + let max = c[0].max(c[1]).max(c[2]); + let min = c[0].min(c[1]).min(c[2]); + if max - min <= 1e-12 { + return [0.0; 3]; + } + c.map(|v| (v - min) * s / (max - min)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(c: Color, opacity: f64, blend: Blend) -> Node { + Node { + class: crate::stream::Tag::of(b"ShpN"), + name: String::new(), + visible: true, + locked: false, + opacity: 1.0, + blend: Blend::Normal, + kind: Kind::Shape { + path: Default::default(), + fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })], + strokes: vec![], + even_odd: false, + }, + mask: None, + pixel_mask: None, + effects: vec![Effect::ColorOverlay { color: c, opacity, blend }], + children: vec![], + } + } + + fn fill(n: &Node) -> Color { + match &n.kind { + Kind::Shape { fills, .. } => match fills.first() { + Some(Paint::Solid(c)) => *c, + other => panic!("{other:?}"), + }, + other => panic!("{other:?}"), + } + } + + #[test] + fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() { + let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; + let n = apply(&solid(white, 1.0, Blend::Normal), &mut |_| {}).into_owned(); + assert_eq!(fill(&n), white); + assert!(n.effects.is_empty(), "the overlay is baked, not kept"); + // Half way, mixed in RGB. + let n = apply(&solid(white, 0.5, Blend::Normal), &mut |_| {}).into_owned(); + assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 }); + } + + #[test] + fn blend_modes_follow_their_formulas() { + let b = [0.25, 0.5, 1.0]; + assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]); + assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]); + assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]); + assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]); + // Color keeps the backdrop's luminosity with the source's hue and saturation. + let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]); + assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}"); + } + + #[test] + fn overlays_recolour_pixels_inside_their_alpha() { + let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal); + n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY }); + let out = apply(&n, &mut |_| {}).into_owned(); + let Kind::Image(img) = &out.kind else { panic!() }; + assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0])); + } + + #[test] + fn a_gradient_overlay_over_one_colour_becomes_that_gradient() { + let g = Gradient { + kind: GradientKind::Linear, + stops: vec![ + Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 }, + Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 }, + ], + transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]), + }; + let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal); + n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }]; + let out = apply(&n, &mut |_| {}).into_owned(); + let Kind::Shape { fills, .. } = &out.kind else { panic!() }; + assert_eq!(fills.first(), Some(&Paint::Gradient(g))); + } +} diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index c4ebff844..ab235cac7 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -283,7 +283,7 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec Option { +pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option { let s = r.s; let paint = descriptor(r, pen, local)?; if paint == Paint::None { @@ -295,6 +295,13 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) let scales = s.bool(ldsc, b"LDSc").unwrap_or(true); let width = if scales { weight * local.scale() } else { weight }; let data = s.bytes(lsty, b"Data").unwrap_or_default(); + // Byte 10 is the stroke's style: 0 none, 1 solid, 2 dashed, 3 a brush. A shape without a + // stroke still stores one (black, 1 px wide), with style 0. Identified on two documents their + // owner made, where every such stroke is invisible in Affinity's own PDF export, and checked + // against the public corpus's thumbnails. + if data.get(10) == Some(&0) { + return None; + } let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0); // Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample; // (2, 2) ends plain lines square and brush lines round; joins import round, which never diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index 98334717d..760e7276a 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -39,6 +39,9 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result {} Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })), Err(Error::Limit(e)) => return Err(Error::Limit(e)), Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"), @@ -66,8 +69,17 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result Result, Error> { +/// An ISO base media file holding a HEIF image (HEIC, AVIF): `ftyp` with an image brand. +fn is_heif(bytes: &[u8]) -> bool { + bytes.get(4..8) == Some(b"ftyp".as_slice()) + && bytes.get(8..12).is_some_and(|brand| { + [b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1", b"avif", b"avis"].iter().any(|b| brand == b.as_slice()) + }) +} + +/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides), read +/// over `over` (document pixels) when given; outside its pixels a mask hides everything. +pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine, over: Option) -> Result, Error> { let s = r.s; let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) }; let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); @@ -76,7 +88,41 @@ pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result c, + // What it masks lies outside it: all hidden, which one black pixel says as well. + Some(None) => return Ok(Some(Image { width: 1, height: 1, pixels: Pixels::Rgba8(vec![0, 0, 0, 255]), transform: world })), + None => (0, 0, width, height), + }; + decode(r, bitmap, crop, world) +} + +/// The bitmap pixels (x0, y0, x1, y1) of a `width` × `height` bitmap placed by `world` that cover +/// document rectangle `over`, one pixel wider all round; `None` if they don't overlap. +fn crop_to(over: crate::model::Rect, world: Affine, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> { + let inv = invert(world)?; + let pts = [(over.x0, over.y0), (over.x1, over.y0), (over.x1, over.y1), (over.x0, over.y1)].map(|(x, y)| inv.apply(crate::model::Point { x, y })); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in pts { + if !(p.x.is_finite() && p.y.is_finite()) { + return Some((0, 0, width, height)); + } + (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)); + } + let clamp = |v: f64, max: u32| v.clamp(0.0, f64::from(max)) as u32; + let (cx0, cy0, cx1, cy1) = + (clamp(x0.floor() - 1.0, width), clamp(y0.floor() - 1.0, height), clamp(x1.ceil() + 1.0, width), clamp(y1.ceil() + 1.0, height)); + (cx1 > cx0 && cy1 > cy0).then_some((cx0, cy0, cx1, cy1)) +} + +fn invert(m: Affine) -> Option { + let [a, b, c, d, e, f] = m.0; + let det = a * d - b * c; + if !(det.is_finite() && det.abs() > 1e-12) { + return None; + } + let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); + Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)])) } /// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches. @@ -99,18 +145,29 @@ pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result Result, Error> { + decode_within(r, bitmap, crop, world, MAX_PIXELS) +} + +/// [`decode`] with at most `max_pixels` decoded pixels. +fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine, max_pixels: u64) -> Result, Error> { let s = r.s; let (x0, y0, x1, y1) = crop; if x1 <= x0 || y1 <= y0 { return Ok(None); } let (w, h) = (x1 - x0, y1 - y0); - let pixels = u64::from(w) * u64::from(h); - if pixels > MAX_PIXELS { - r.warn("pixel layers larger than 64 megapixels"); - return Ok(None); + // Larger layers (mostly masks of whole spreads) are read at a half, a quarter… of their + // resolution: every `step`th pixel of every `step`th row. + let mut step = 1u32; + while u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)) > max_pixels { + step *= 2; + if step > 1 << 12 { + r.warn("pixel layers too large to read (left out)"); + return Ok(None); + } } // Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract. + let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)); let Some(left) = r.pixels_left.checked_sub(pixels) else { r.warn("pixel layers beyond the pixels one document may hold"); return Ok(None); @@ -122,10 +179,7 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi Some(1) => (4, 2), Some(6) => (1, 1), Some(7) => (1, 2), - Some(4) => { - r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)"); - (5, 1) - } + Some(4) => (5, 1), _ => { r.warn("pixel layers in grey, Lab or 32-bit formats"); return Ok(None); @@ -152,11 +206,48 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi } else { None }; + // Tiles kept only in an original that can't be decoded here are left empty (transparent). Where + // a tile's colour is in the original but its alpha is stored, as in CMYK layers, it would come + // out opaque white instead: such a layer (a HEIC photo in a CMYK document) is left out, and said. + if needs_source && source.is_none() && channels > 1 { + let states = |c: usize| match s.field(bitmap, &tag(b"Sta", c)) { + Some(Value::Array(a)) => a.clone(), + _ => Vec::new(), + }; + let alpha = states(channels); + let blank = (1..channels).any(|c| states(c).iter().enumerate().any(|(i, v)| *v == Value::UInt(5) && alpha.get(i) != Some(&Value::UInt(5)))); + if blank { + r.warn("placed images whose pixels are only in an original the reader can't decode, such as HEIC photos (left out)"); + return Ok(None); + } + } + if step > 1 { + // Its stored tiles must fit what is left of the import's extraction budget. + let stored: usize = (1..=channels).map(|c| tiles_in(s, bitmap, c, bps, (x0, y0, w, h)).len()).sum(); + if stored.saturating_mul(TILE_BYTES) > r.archive.remaining().max_total { + r.warn("pixel layers too large to read (left out)"); + return Ok(None); + } + r.warn("pixel layers larger than 64 megapixels (read at a lower resolution)"); + } let mut cache = CachedPixels { tiles: HashMap::new(), source }; for c in 1..=channels { - planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?); + planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), step, &mut cache)?); } + let (w, h) = (w.div_ceil(step), h.div_ceil(step)); let n = (w as usize) * (h as usize); + let k = f64::from(step); + let transform = Affine([k, 0.0, 0.0, k, f64::from(x0), f64::from(y0)]).then(world); + if channels == 5 { + // Ink amounts and alpha as stored (8 bits), interleaved. + let mut cmyka = vec![0u8; n * 5]; + for (i, px) in cmyka.as_chunks_mut::<5>().0.iter_mut().enumerate() { + for (c, v) in px.iter_mut().enumerate() { + *v = planes.get(c).and_then(|p| p.get(i)).copied().unwrap_or(0); + } + } + return Ok(Some(Image { width: w, height: h, pixels: Pixels::Cmyka8(cmyka), transform })); + } let mut rgba = vec![0u8; n * 4]; let sample = |p: &Vec, i: usize| -> f64 { if bps == 2 { @@ -167,17 +258,9 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi }; for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { let v: Vec = planes.iter().map(|p| sample(p, i)).collect(); - let (r2, g, b, a) = if channels == 1 { - (v[0], v[0], v[0], 1.0) - } else if channels == 5 { - let k = 1.0 - v[3]; - ((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4]) - } else { - (v[0], v[1], v[2], v[3]) - }; + let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) } else { (v[0], v[1], v[2], v[3]) }; px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8)); } - let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world); Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform })) } @@ -229,28 +312,57 @@ fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] { [prefix[0], prefix[1], prefix[2], b'0' + c as u8] } -/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample. +/// Channel `c`'s tile grid: (tiles across, tiles down, each tile's state). +fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usize, Vec) { + let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); + let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); + let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE)); + let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); + let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { + Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), + _ => Vec::new(), + }; + (tw, th, states) +} + +/// The stored tiles (state 4) of channel `c` that overlap the crop, by their index in `Idx`. +fn tiles_in(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32)) -> Vec { + let (tw, th, states) = grid(s, bitmap, c, bps); + let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); + let (py0, py1) = (y0 as usize, (y0 + h) as usize); + let mut out = Vec::new(); + let mut next = 0usize; + for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) { + if *state != 4 { + continue; + } + let (ox, oy) = ((t % tw.max(1)) * TILE, (t / tw.max(1)) * TILE); + if !(ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0) { + out.push(next); + } + next += 1; + } + out +} + +/// One channel of level 0, cropped to (x0, y0, w, h) pixels and taking every `step`th pixel of +/// every `step`th row; `bps` bytes per sample. fn plane( r: &mut Reader, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32), + step: u32, cache: &mut CachedPixels, ) -> Result, Error> { let CachedPixels { tiles: cache, source } = cache; let s = r.s; - let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); - let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); - let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE)); - let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); - let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), - _ => Vec::new(), - }; + let (tw, th, states) = grid(s, bitmap, c, bps); let tiles = s.objs(bitmap, &tag(b"Idx", c)); - let row = (w as usize) * bps; - let mut out = vec![0u8; row * h as usize]; + let at = Crop { bps, x0: x0 as usize, y0: y0 as usize, w: w as usize, h: h as usize, step: step.max(1) as usize }; + let row = at.w.div_ceil(at.step) * bps; + let mut out = vec![0u8; row * at.h.div_ceil(at.step)]; let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); let (py0, py1) = (y0 as usize, (y0 + h) as usize); let mut next = 0usize; @@ -267,7 +379,7 @@ fn plane( continue; } let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string(); - if !cache.contains_key(&name) { + let read = |r: &mut Reader| -> Result, Error> { let mut data = r.archive.read(&name)?; // Older files wrap a tile in a one-field stream: header, then a 64 KiB blob. if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") { @@ -276,10 +388,20 @@ fn plane( if data.len() != TILE_BYTES { return Err(Error::Malformed("pixel tile size")); } + Ok(data) + }; + // A layer read at a lower resolution is too large to keep its tiles around. + if at.step > 1 { + let tile = read(r)?; + copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); + continue; + } + if !cache.contains_key(&name) { + let data = read(r)?; cache.insert(name.clone(), data); } let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?; - copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); + copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); continue; } 3 => { @@ -289,9 +411,7 @@ fn plane( 5 => { if let Some((source_width, pixels)) = source.as_ref() { let (ox, oy) = (tx * TILE, ty * TILE); - copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| { - pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0) - }); + copy(&mut out, &at, (ox, oy), |x, y| pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)); continue; } r.warn("pixel layers drawn from an embedded image (left empty)"); @@ -300,17 +420,38 @@ fn plane( _ => return Err(Error::Malformed("unknown pixel tile state")), }; if let Some(v) = fill { - copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v); + copy(&mut out, &at, (tx * TILE, ty * TILE), |_, _| v); } } Ok(out) } -/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane. -fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) { - for y in oy.max(py0)..(oy + TILE).min(py1) { +/// Where a plane's pixels come from: the crop (pixels) and the sampling step. +struct Crop { + bps: usize, + x0: usize, + y0: usize, + w: usize, + h: usize, + step: usize, +} + +/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane, keeping every +/// `step`th pixel of every `step`th row. +fn copy(out: &mut [u8], c: &Crop, (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) { + let row = c.w.div_ceil(c.step) * c.bps; + let (bx0, bx1) = (c.x0 * c.bps, (c.x0 + c.w) * c.bps); + for y in oy.max(c.y0)..(oy + TILE).min(c.y0 + c.h) { + if !(y - c.y0).is_multiple_of(c.step) { + continue; + } + let out_row = (y - c.y0) / c.step * row; for x in ox.max(bx0)..(ox + TILE).min(bx1) { - if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) { + let px = x / c.bps - c.x0; + if !px.is_multiple_of(c.step) { + continue; + } + if let Some(b) = out.get_mut(out_row + px / c.step * c.bps + x % c.bps) { *b = at(x - ox, y - oy); } } @@ -323,6 +464,15 @@ mod tests { use crate::model::Kind; use crate::synth::{self, F, Method, tag}; + #[test] + fn heif_originals_are_recognised_by_their_brand() { + assert!(is_heif(b"\0\0\0\x18ftypheic\0\0\0\0")); + assert!(is_heif(b"\0\0\0\x1cftypavif")); + assert!(!is_heif(b"\0\0\0\x18ftypisom"), "a video is not a photo"); + assert!(!is_heif(&png())); + assert!(!is_heif(b"ftyp")); + } + fn png() -> Vec { let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255])); let mut bytes = std::io::Cursor::new(Vec::new()); @@ -333,18 +483,29 @@ mod tests { /// An original synthetic mixed source/cache bitmap. It exercises reader recovery, /// not a claim that Affinity itself accepts the synthetic container. fn fixture(source: &[u8], origin: i32) -> Vec { + fixture_in(source, origin, false) + } + + /// `cmyk`: a CMYK bitmap whose colour tiles are all in the source and whose alpha is stored. + fn fixture_in(source: &[u8], origin: i32, cmyk: bool) -> Vec { + let channels = if cmyk { 5 } else { 4 }; let mut bitmap = vec![ - (tag(b"Frmt"), F::Enum(0, 0)), + (tag(b"Frmt"), F::Enum(if cmyk { 4 } else { 0 }, 0)), (tag(b"BmpW"), F::I32(300)), (tag(b"BmpH"), F::I32(2)), (tag(b"LInf"), F::I32(origin)), (tag(b"TInf"), F::I32(0)), (tag(b"Bckg"), F::Entry("c/1".into())), ]; - for c in 1..=4 { + for c in 1..=channels { + let states = match (cmyk, c) { + (false, _) => vec![5, 4], + (true, 5) => vec![4, 4], + (true, _) => vec![5, 5], + }; bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2))); bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1))); - bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4]))); + bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(states))); bitmap.push(( tag(&super::tag(b"Idx", c)), F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]), @@ -369,18 +530,44 @@ mod tests { blob.extend((source.len() as u32).to_le_bytes()); blob.extend(source); let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]); - let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect(); - synth::container( - &[ - ("doc.dat", &doc, Method::Zlib), - ("c/1", &block, Method::Zlib), - ("d/1", &tiles[0], Method::Zlib), - ("d/2", &tiles[1], Method::Zlib), - ("d/3", &tiles[2], Method::Zlib), - ("d/4", &tiles[3], Method::Zlib), - ], - None, - ) + let tiles: Vec<_> = [200u8, 10, 30, 255, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect(); + let names: Vec = (1..=channels).map(|c| format!("d/{c}")).collect(); + let mut entries = vec![("doc.dat", doc.as_slice(), Method::Zlib), ("c/1", block.as_slice(), Method::Zlib)]; + for (name, tile) in names.iter().zip(&tiles) { + entries.push((name.as_str(), tile.as_slice(), Method::Zlib)); + } + synth::container(&entries, None) + } + + #[test] + fn a_cmyk_layer_whose_colours_are_only_in_an_unreadable_original_is_left_out_not_white() { + let doc = crate::read(&fixture_in(b"\0\0\0\x18ftypheic not decodable", 0, true), crate::Limits::default()).unwrap(); + assert!(doc.warnings.iter().any(|w| w.starts_with("placed images whose pixels are only in an original")), "{:?}", doc.warnings); + assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported), "{:?}", doc.spreads[0].nodes[0].kind); + } + + #[test] + fn layers_over_the_pixel_budget_are_read_at_a_lower_resolution() { + let bytes = fixture(&png(), 0); + let mut archive = crate::Archive::open(&bytes, crate::Limits::default()).unwrap(); + let doc = archive.read("doc.dat").unwrap(); + let s = stream::parse(&doc).unwrap(); + let bitmap = s.objects.iter().position(|o| o.class == Tag::of(b"DyBm")).unwrap(); + let mut r = Reader::new(&s, &mut archive, Affine::IDENTITY, 0); + // 20 × 2 pixels over a budget of 10: every other pixel of every other row. + let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 10).unwrap().unwrap(); + assert_eq!((image.width, image.height), (10, 1)); + assert_eq!(image.transform, Affine([2.0, 0.0, 0.0, 2.0, 250.0, 0.0]), "each pixel covers two"); + let Pixels::Rgba8(pixels) = &image.pixels else { panic!() }; + for x in 0..10 { + let sx = 2 * x; + let expected = if sx + 250 < 256 { [((sx + 250) % 251) as u8, 0, 77, 255] } else { [200, 10, 30, 255] }; + assert_eq!(&pixels[x * 4..x * 4 + 4], &expected, "pixel {x}"); + } + assert!(r.warnings.contains_key("pixel layers larger than 64 megapixels (read at a lower resolution)")); + // Within budget, nothing changes. + let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 40).unwrap().unwrap(); + assert_eq!((image.width, image.height), (20, 2)); } #[test] diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index 2b632cab3..22b494b23 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -2,7 +2,7 @@ //! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning, //! OpenType features) is left to the importing application. -use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect}; +use crate::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect}; use crate::paint::{self, Color, Paint}; use crate::stream::{ObjId, Value}; @@ -11,92 +11,16 @@ const MAX_CHARS: usize = 1 << 20; /// Shared character attributes must not amplify into unbounded font/feature/gradient data. const MAX_ATTRIBUTE_BYTES: usize = 16 << 20; +/// Ends a table cell in a table's story (Affinity's break glyph of kind 4). +pub const CELL_BREAK: char = '\u{1F}'; + pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option { let s = r.s; let story = s.obj(id, b"StSt")?; let frame = s.obj(id, b"TxtH")?; let artistic = s.is(frame, b"ArFr"); let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?; - let mut runs = Vec::new(); - let mut align = None; - let mut total = 0usize; - let mut placeholders = false; - let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES); - for block in s.objs(story, b"Blok") { - let Some(glyphs) = s.obj(block, b"Glyp") else { - r.warn("a text block without readable characters (left out)"); - continue; - }; - // Bound collection itself, including placeholders, before allocating the character buffer. - let budget = MAX_CHARS - total; - - let mut chars: Vec = match s.str(glyphs, b"Utf8") { - Some(t) => t.chars().take(budget + 1).collect(), - None => { - // Inline objects (page numbers, anchors) each take one index before the segment text. - let segments = s.objs(glyphs, b"Mixd"); - if segments.is_empty() { - r.warn("a text block without readable characters (left out)"); - continue; - } - let mut v = Vec::new(); - for seg in segments { - let inline = s.objs(seg, b"Glys").len(); - v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len()))); - placeholders |= inline > 0; - v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len())); - if v.len() > budget { - break; - } - } - v - } - }; - let truncated = chars.len() > budget; - if truncated { - chars.truncate(budget); - r.warn("text longer than a million characters (truncated)"); - } - total += chars.len(); - let mut start = 0usize; - for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() { - let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else { - r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); - continue; - }; - let end = index.min(chars.len()); - if index > chars.len() || end < start { - r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); - } - if end <= start { - continue; - } - let text = characters(&chars, start, end); - start = end; - push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget); - } - if start < chars.len() { - let text = characters(&chars, start, chars.len()); - push_run(r, &mut runs, None, text, world, &mut attribute_budget); - } - for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() { - if let Some(attrs) = s.obj(paragraph, b"Item") { - let paragraph = paragraph_align(r, attrs); - match align { - None => align = Some(paragraph), - Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"), - _ => {} - } - } - } - if truncated { - break; - } - } - if placeholders { - r.warn("text fields such as page numbers (left out of the text)"); - } - let align = align.unwrap_or(Align::Left); + let (runs, align) = story_runs(r, story, world); let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0); let anchor_x = match align { Align::Center => (bounds.x0 + bounds.x1) / 2.0, @@ -111,19 +35,231 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option { r.warn("text frames with a curved outline (imported as rectangular frames)"); } } - Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world }) + let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) }; + Some(Text { + runs, + align, + anchor: Point { x: anchor_x, y: first_baseline }, + frame: if artistic { None } else { Some(bounds) }, + transform: world, + text_transform, + }) +} + +/// Rows and columns a table may have. +const MAX_TABLE_LINES: usize = 4096; + +/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges. +pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option { + let s = r.s; + let story = s.obj(id, b"StSt")?; + let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?; + let bounds = |pos: Option| -> Option> { + let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None }; + let v: Vec = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::>()?; + (v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v) + }; + let columns = bounds(s.obj(tabl, b"CPos"))?; + let rows = bounds(s.obj(tabl, b"RPos"))?; + let (ncols, nrows) = (columns.len() - 1, rows.len() - 1); + let count = ncols.checked_mul(nrows)?; + let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default(); + if cell_objs.len() != count { + r.warn("tables whose cells don't match their grid (cell formatting left out)"); + } + let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false }; + let mut cells: Vec = (0..count).map(|_| blank()).collect(); + for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() { + if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) { + cell.inset = inset; + } + cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) { + Some(1) => VAlign::Center, + Some(2) => VAlign::Bottom, + _ => VAlign::Top, + }; + // A cell merged into the one on its left; the first column has nothing to its left. + cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true); + } + // The story holds the text of every cell that isn't merged away, row by row, each ended by a + // cell break. + let owner_list: Vec = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect(); + let mut owners = owner_list.into_iter(); + let mut target = owners.next(); + let mut extra = false; + let (runs, _) = story_runs(r, story, world); + for run in runs { + let mut parts = run.text.split(CELL_BREAK).peekable(); + while let Some(part) = parts.next() { + if !part.is_empty() { + match target.and_then(|i| cells.get_mut(i)) { + Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), + None => extra = true, + } + } + if parts.peek().is_some() { + target = owners.next(); + } + } + } + if extra { + r.warn("table text beyond its last cell (left out)"); + } + let mut edges = |tag: &[u8; 4], expected: usize| -> Vec> { + let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default(); + if objs.len() != expected { + r.warn("tables whose lines don't match their grid (lines left out)"); + return vec![None; expected]; + } + objs.into_iter() + .map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) { + (Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false), + _ => None, + }) + .collect() + }; + let vertical = edges(b"CEdg", (ncols + 1) * nrows); + let horizontal = edges(b"REdg", ncols * (nrows + 1)); + if let Some(format) = s.obj(tabl, b"TFmt") { + let filled = + s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN"))); + if filled { + r.warn("table cell fills (left out)"); + } + } + let text_transform = text_scale(r, id).then(r.type_base); + Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform }) +} + +/// A story's character runs, each with its paragraph's format, and the first paragraph's +/// alignment. Table cells end with [`CELL_BREAK`]. +pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec, Align) { + let s = r.s; + let mut runs = Vec::new(); + let mut first_align = None; + let mut total = 0usize; + let mut placeholders = false; + let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES); + for block in s.objs(story, b"Blok") { + let Some(glyphs) = s.obj(block, b"Glyp") else { + r.warn("a text block without readable characters (left out)"); + continue; + }; + // Bound collection itself, including placeholders, before allocating the character buffer. + let budget = MAX_CHARS - total; + + let mut chars: Vec = match s.str(glyphs, b"Utf8") { + Some(t) => t.chars().take(budget + 1).collect(), + None => { + // Inline objects (page numbers, anchors, cell ends) each take one index before + // the segment text. + let segments = s.objs(glyphs, b"Mixd"); + if segments.is_empty() { + r.warn("a text block without readable characters (left out)"); + continue; + } + let mut v = Vec::new(); + for seg in segments { + for inline in s.objs(seg, b"Glys") { + if v.len() > budget { + break; + } + if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) { + v.push(CELL_BREAK); + } else { + v.push('\u{FFFC}'); + placeholders = true; + } + } + v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len()))); + if v.len() > budget { + break; + } + } + v + } + }; + let truncated = chars.len() > budget; + if truncated { + chars.truncate(budget); + r.warn("text longer than a million characters (truncated)"); + } + total += chars.len(); + // Paragraph formats by end index; a run without an index covers the rest of the block. + let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new(); + let mut open: Option = None; + for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() { + let Some(attrs) = s.obj(run, b"Item") else { continue }; + let a = paragraph(r, attrs); + first_align.get_or_insert(a.align); + match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) { + Some(end) => paragraphs.push((end, a)), + None => match open { + None => open = Some(a), + Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"), + _ => {} + }, + } + } + let align_at = |i: usize| paragraphs.iter().find(|(end, _)| i < *end).map(|(_, a)| *a).or(open).unwrap_or_default(); + let mut cuts: Vec = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect(); + cuts.sort_unstable(); + cuts.dedup(); + let push = |r: &mut Reader, runs: &mut Vec, attrs: Option, from: usize, to: usize, budget: &mut Option| { + // Split at paragraph ends so every run has one paragraph format. + let mut a = from; + for &c in cuts.iter().filter(|c| **c > from && **c < to).chain(std::iter::once(&to)) { + push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a)); + a = c; + } + }; + let mut start = 0usize; + for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() { + let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else { + r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); + continue; + }; + let end = index.min(chars.len()); + if index > chars.len() || end < start { + r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); + } + if end <= start { + continue; + } + push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget); + start = end; + } + if start < chars.len() { + push(r, &mut runs, None, start, chars.len(), &mut attribute_budget); + } + if truncated { + break; + } + } + if placeholders { + r.warn("text fields such as page numbers (left out of the text)"); + } + (runs, first_align.unwrap_or(Align::Left)) } fn characters(chars: &[char], start: usize, end: usize) -> String { chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect() } -fn push_run(r: &mut Reader, runs: &mut Vec, attrs: Option, text: String, world: Affine, attribute_budget: &mut Option) { +fn push_run( + r: &mut Reader, + runs: &mut Vec, + attrs: Option, + text: String, + world: Affine, + attribute_budget: &mut Option, + paragraph: Paragraph, +) { if text.is_empty() { return; } let run = match attrs { - Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget), + Some(attrs) if attribute_budget.is_some() => TextRun { paragraph, ..run_attrs(r, attrs, text, world, attribute_budget) }, _ => { // This is a recovery policy, not an assertion about unspecified Affinity attributes. if attrs.is_none() { @@ -137,10 +273,15 @@ fn push_run(r: &mut Reader, runs: &mut Vec, attrs: Option, text: italic: false, features: Vec::new(), all_caps: false, + underline: false, + strikethrough: false, size: 12.0 * r.dpi / 72.0, tracking: 0.0, + h_scale: 1.0, leading: None, fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }), + stroke: None, + paragraph, } } }; @@ -195,13 +336,22 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }); } } - if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None) - { - r.warn("outlined (stroked) text"); - } - if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) { - r.warn("horizontally scaled text"); - } + // Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes). + let stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) { + (true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false), + _ => None, + }; + // Underline (slot 0) and strikethrough (slot 1): 1 is a single line. + let mut decoration = |i: usize| match slot(ints, i, int) { + Some(1) => true, + Some(0) | None => false, + Some(_) => { + r.warn("text decorations of an unknown kind (left out)"); + false + } + }; + let (underline, strikethrough) = (decoration(0), decoration(1)); + let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0); // A resolved font is populated on the emoji run of the public Affinity 3 text-runs file // (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common. // Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af @@ -238,10 +388,15 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false), features, all_caps, + underline, + strikethrough, size, tracking, + h_scale, leading, fill, + stroke, + paragraph: Paragraph::default(), } } @@ -303,13 +458,18 @@ fn font_features(r: &mut Reader, attrs: Option, attribute_budget: &mut Op (features, all_caps) } -fn paragraph_align(r: &mut Reader, p: ObjId) -> Align { - // Nonzero offsets in these slots are present in the public text-indent and text-para-spacing - // fixtures. Their layout is not represented by this importer; retain text and report the gap. - if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) { - r.warn("paragraph indents or spacing (imported with defaults)"); - } - match slot(r.s.field(p, b"Ints"), 0, int) { +/// A paragraph's alignment, indents, spacing and leading. The `Doub` slots: 0 the automatic +/// leading as a multiple of the font's line height (1 in every document checked), 1 fixed leading +/// (when `Ints` slot 1 is 2; in every paragraph checked the two go together), 2 indent of the +/// lines after the first, 3 right indent, 4 indent of the first line, 5 space before, 6 space +/// after. Slots 2, 4, 5 and 6 were identified on the public MIT text-indent and text-para-spacing +/// fixtures (Patchy) by measuring their thumbnails, slot 1 by measuring the baseline pitch in the +/// thumbnail of a document whose owner made it earlier. +fn paragraph(r: &mut Reader, p: ObjId) -> Paragraph { + let doubles = r.s.field(p, b"Doub"); + let ints = r.s.field(p, b"Ints"); + let at = |i: usize| slot(doubles, i, float).filter(|v| v.abs() < 1e6).unwrap_or(0.0); + let align = match slot(ints, 0, int) { Some(1) => Align::Center, Some(2) => Align::Right, Some(3) => Align::Justify, @@ -318,5 +478,29 @@ fn paragraph_align(r: &mut Reader, p: ObjId) -> Align { r.warn("an unknown paragraph alignment (imported as left)"); Align::Left } + }; + let leading = match slot(ints, 1, int) { + Some(2) => Some(at(1)).filter(|l| *l > 0.0), + Some(0) | None => None, + Some(_) => { + r.warn("paragraph leading of an unknown kind (imported as automatic)"); + None + } + }; + let auto_leading = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0); + Paragraph { align, left: at(2), right: at(3), first: at(4), space_before: at(5).max(0.0), space_after: at(6).max(0.0), leading, auto_leading } +} + +/// The scale a text frame or table gives its type (`FTxS`, stored like `Xfrm`): none unless set. +/// Neither the frame's own transform nor its groups' scale its type (checked against the +/// thumbnails of tables and frames inside scaled groups). +fn text_scale(r: &mut Reader, id: ObjId) -> Affine { + match r.s.floats::<6>(id, b"FTxS").map(Affine::from_xfrm) { + Some(m) if m.is_finite() && m.scale() > 1e-9 => Affine([m.0[0], m.0[1], m.0[2], m.0[3], 0.0, 0.0]), + Some(_) => { + r.warn("text frames with an invalid text scale (imported unscaled)"); + Affine::IDENTITY + } + None => Affine::IDENTITY, } } diff --git a/crates/affinity/tests/raster.rs b/crates/affinity/tests/raster.rs index e477b4d26..173bc9158 100644 --- a/crates/affinity/tests/raster.rs +++ b/crates/affinity/tests/raster.rs @@ -103,13 +103,16 @@ fn rotated_scaled_pixels_keep_the_full_affine_transform() { } #[test] -fn cmyk_and_lab_losses_are_reported() { +fn cmyk_pixels_stay_cmyk_and_lab_losses_are_reported() { + // CMYK layers keep their inks for the importer to convert through a colour profile. let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return }; assert_eq!(images.len(), 1); assert_eq!((images[0].width, images[0].height), (64, 48)); - let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() }; - assert_eq!(crc32fast::hash(pixels), 0x5872bb76); - assert_eq!(cmyk.warnings, ["CMYK pixel layers (converted to RGB without the document's colour profile)"]); + let Pixels::Cmyka8(pixels) = &images[0].pixels else { panic!() }; + assert_eq!(pixels.len(), 64 * 48 * 5); + assert_eq!(crc32fast::hash(pixels), 0x56b1f659); + assert!(cmyk.warnings.is_empty(), "{:?}", cmyk.warnings); + assert_eq!(cmyk.cmyk_profile, None, "this file embeds no profile"); let (_, lab, images) = read_fixture("patchy-lab.af").unwrap(); assert!(images.is_empty()); assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]); diff --git a/crates/affinity/tests/support/affinity3-signatures.txt b/crates/affinity/tests/support/affinity3-signatures.txt index fb6c94b29..779a58552 100644 --- a/crates/affinity/tests/support/affinity3-signatures.txt +++ b/crates/affinity/tests/support/affinity3-signatures.txt @@ -1,33 +1,33 @@ -affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=df3c417b;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (114×)", "layer effects (shadows, glows, outlines, bevels…) (156×)"] -affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=d0387de4;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 559, "subpaths": 881};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (545×)", "layer effects (shadows, glows, outlines, bevels…) (245×)"] -affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=b966f693;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (54×)", "layer effects (shadows, glows, outlines, bevels…) (278×)"] -affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=ac448123;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 288, "subpaths": 656};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes)", "layer effects (shadows, glows, outlines, bevels…) (339×)"] -patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=10a37502;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] -patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=c65409b4;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] -patchy-artboards.af dpi=72;spreads=1;model_crc32=f1ae5644;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[] -patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=f6691ff9;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"] -patchy-cmyk.af dpi=72;spreads=1;model_crc32=5145b5a1;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["CMYK pixel layers (converted to RGB without the document's colour profile)"] -patchy-dpi300.af dpi=300;spreads=1;model_crc32=0c23e7c8;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=090c674b;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-fx-blur.af dpi=72;spreads=1;model_crc32=3f1b8b44;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (2×)"] -patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=e868c992;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (3×)"] -patchy-fx.af dpi=72;spreads=1;model_crc32=5a1c34ac;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (5×)"] -patchy-group.af dpi=72;spreads=1;model_crc32=c217ab38;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[] -patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=63ad778e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…)"] -patchy-lab.af dpi=72;spreads=1;model_crc32=01e8a926;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"] -patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=9ce9937e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"] -patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"] -patchy-shapes.af dpi=72;spreads=1;model_crc32=7519f5fe;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[] -patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[] -patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"] -patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"] -patchy-text-indent.af dpi=72;spreads=1;model_crc32=30ee834c;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)", "paragraph indents or spacing (imported with defaults) (2×)"] -patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=3d580ad2;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)"] -patchy-text-rotated.af dpi=72;spreads=1;model_crc32=ad77772a;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"] -patchy-text-runs.af dpi=72;spreads=1;model_crc32=a8da3052;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)", "text uses a stored fallback font (imported with that font)"] -patchy-transform.af dpi=72;spreads=1;model_crc32=f8807611;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-vector-mask.af dpi=72;spreads=1;model_crc32=1851fd90;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"] -patchy-vector.af dpi=72;spreads=1;model_crc32=b89a91d3;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[] +affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"] +affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"] +affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"] +affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"] +patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] +patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] +patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[] +patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=8497c917;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"] +patchy-cmyk.af dpi=72;spreads=1;model_crc32=56754ce9;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-dpi300.af dpi=300;spreads=1;model_crc32=2561f9d3;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=ab4b6fbf;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-fx-blur.af dpi=72;spreads=1;model_crc32=29b5083b;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["blur effects (left out) (2×)"] +patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=96cd10be;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=[] +patchy-fx.af dpi=72;spreads=1;model_crc32=7c8d4d20;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=[] +patchy-group.af dpi=72;spreads=1;model_crc32=dc37ab6c;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[] +patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=38c9c5f4;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-lab.af dpi=72;spreads=1;model_crc32=df3a4e40;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"] +patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=8776a8a0;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-live-filter.af dpi=72;spreads=1;model_crc32=12235fe9;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"] +patchy-rgba16.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-rgba8.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-shapes-2.af dpi=72;spreads=1;model_crc32=886e9852;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"] +patchy-shapes.af dpi=72;spreads=1;model_crc32=c1790e97;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[] +patchy-text-artistic.af dpi=72;spreads=1;model_crc32=1d33192c;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[] +patchy-text-caps.af dpi=72;spreads=1;model_crc32=24e52518;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"] +patchy-text-frame.af dpi=72;spreads=1;model_crc32=3bff8ef9;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[] +patchy-text-indent.af dpi=72;spreads=1;model_crc32=dc36dfc5;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"] +patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=6408f39b;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"] +patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"] +patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"] +patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"] +patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[] diff --git a/crates/affinity/tests/support/features.rs b/crates/affinity/tests/support/features.rs index 31d3eb53f..f7024feb6 100644 --- a/crates/affinity/tests/support/features.rs +++ b/crates/affinity/tests/support/features.rs @@ -35,7 +35,12 @@ pub fn signature(doc: &Document) -> String { } "texts" } + Kind::Table(t) => { + *counts.entry("table_cells").or_default() += t.cells.len(); + "tables" + } Kind::Image(_) => "images", + Kind::MasterInstance { .. } => "master_instances", Kind::Unsupported => "unsupported", }; *counts.entry(key).or_default() += 1; diff --git a/crates/affinity/tests/support/text_fixture.rs b/crates/affinity/tests/support/text_fixture.rs index d629253d3..574a0ca38 100644 --- a/crates/affinity/tests/support/text_fixture.rs +++ b/crates/affinity/tests/support/text_fixture.rs @@ -140,7 +140,7 @@ pub const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]; // Shared arrays require definitions/references, whereas inline fields use Obj. Give the original // fixture objects independent shared IDs without making the fixture descriptions noisy. -fn shared_objects(value: &mut F, next_id: &mut u32) { +pub fn shared_objects(value: &mut F, next_id: &mut u32) { match value { F::Shared(items) => { for item in items { diff --git a/crates/affinity/tests/text.rs b/crates/affinity/tests/text.rs index 8d6a203e5..d680d2eda 100644 --- a/crates/affinity/tests/text.rs +++ b/crates/affinity/tests/text.rs @@ -11,7 +11,7 @@ mod support { use support::text_fixture::*; use vectorcraft_affinity::{ Document, Limits, - model::{Align, Kind, Text}, + model::{Align, Kind, Paragraph, Text}, paint::{Color, Paint}, synth::{F, tag}, }; @@ -108,7 +108,7 @@ fn mixed_paragraph_alignments_are_explicitly_reported() { let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]); assert_eq!(content(text(&d)), "one\u{2029}two"); assert_eq!(text(&d).align, Align::Right); - assert!(warning(&d, "mixed paragraph alignments")); + assert!(warning(&d, "mixed paragraph formats")); } #[test] @@ -194,10 +194,22 @@ fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() { assert_eq!(texts.len(), 2); assert!(texts[0].runs[0].all_caps); assert_eq!(texts[1].runs[0].features, ["smcp"]); - for name in ["patchy-text-indent.af", "patchy-text-para-spacing.af"] { + // Indents and paragraph spacing are read (values in document pixels, as these fixtures set + // them in Affinity's paragraph panel). + let paragraphs = |name: &str| -> Vec { let d = load(name); - assert!(warning(&d, "paragraph indents or spacing"), "{name}: {:?}", d.warnings); - } + assert!(!warning(&d, "paragraph"), "{name}: {:?}", d.warnings); + d.spreads[0] + .nodes + .iter() + .filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t) } else { None }) + .flat_map(|t| t.runs.iter().map(|r| r.paragraph)) + .collect() + }; + let p = paragraphs("patchy-text-indent.af"); + assert_eq!(p.iter().map(|p| (p.left, p.first, p.space_after)).collect::>(), [(40.0, 0.0, 12.0), (0.0, 25.0, 12.0)]); + let p = paragraphs("patchy-text-para-spacing.af"); + assert_eq!(p.iter().map(|p| (p.space_before, p.space_after)).collect::>(), [(9.0, 17.0)]); } proptest::proptest! { diff --git a/crates/engine/src/cmd/fileio/affinity.rs b/crates/engine/src/cmd/fileio/affinity.rs index 6c4700847..dd944c773 100644 --- a/crates/engine/src/cmd/fileio/affinity.rs +++ b/crates/engine/src/cmd/fileio/affinity.rs @@ -1,6 +1,6 @@ //! Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): the native layers, curves, -//! shapes, artboards, text and images that `vectorcraft-affinity` reads, mapped to editable -//! VectorCraft art. Whatever the reader could not map is listed in the warnings. A file whose +//! shapes, artboards, text, tables, master pages, layer effects and images that +//! `vectorcraft-affinity` reads, mapped to editable VectorCraft art. Whatever the reader could not map is listed in the warnings. A file whose //! native document can't be read opens as its embedded preview instead, with a warning that says //! why, and can't be placed. @@ -8,12 +8,13 @@ use std::io::Cursor; use std::sync::Arc; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; +use serde_json::json; use vectorcraft_affinity::model::{self as af, Align as AfAlign, Blend, Kind, Pixels}; use vectorcraft_affinity::paint::{self as ap, Cap, GradientKind as AfKind, Join}; use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; -use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer}; +use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer}; use vectorcraft_doc::geom::{FillRule, PathData, shapes}; -use vectorcraft_doc::text::{CharStyle, Justify, TextKind, TextObject, TextRun}; +use vectorcraft_doc::text::{CharStyle, FirstBaseline, Justify, TextKind, TextObject, TextRun, VerticalAlign}; use vectorcraft_doc::{Artboard, Document, ImageObject, LayerColor, Node, NodeKind, OpacityMask}; use super::load::{err, raster_doc}; @@ -30,7 +31,7 @@ pub(super) struct Imported { pub(super) fn import(name: &str, bytes: &[u8]) -> Result { match vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits::default()) { Ok(d) => { - let mut b = Builder { doc: Document::new(0.0, 0.0), warnings: Vec::new(), k: 72.0 / d.dpi, layer: 0 }; + let mut b = Builder::new(72.0 / d.dpi); b.document(&d)?; b.doc.limit_depth_noting(&mut b.warnings); let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::>(); @@ -73,8 +74,20 @@ struct Builder { k: f64, /// Layers made so far (their colours cycle). layer: u8, + /// How deep inside embedded Affinity documents the node being mapped is: their artboards + /// are only clip groups, never artboards of this document. + embedded: usize, + /// Each master spread's art as shown on pages, in the order the masters are first used. + masters: Vec<(usize, Vec>)>, + /// The document's CMYK profile, until [`Builder::document_cmyk`] makes its table. + cmyk_profile: Option>, + /// That table, once made (`Some(None)`: there is none). + cmyk_lut: Option>>, } +/// Deepest node tree mapped (the reader bounds trees well below this). +const MAX_DEPTH: usize = 160; + fn rect_of(r: af::Rect) -> Rect { Rect::new(r.x0, r.y0, r.x1, r.y1) } @@ -83,7 +96,23 @@ fn affine(a: af::Affine) -> Affine { Affine::new(a.0) } +/// Where text goes: a rigid placement (translation and rotation) and the scale that maps node +/// space into it (applied to the frame's geometry, not the type), plus the type's own scale. +struct Placement { + /// Node space to the frame's space, before `place`. + to_frame: Affine, + place: Affine, + /// Points per node unit of type size. + size_scale: f64, + /// Horizontal scale of the type relative to `size_scale`. + h_scale: f64, +} + impl Builder { + fn new(k: f64) -> Self { + Self { doc: Document::new(0.0, 0.0), warnings: Vec::new(), k, layer: 0, embedded: 0, masters: Vec::new(), cmyk_profile: None, cmyk_lut: None } + } + fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); @@ -91,6 +120,7 @@ impl Builder { } fn document(&mut self, d: &af::Document) -> Result<()> { + self.cmyk_profile = d.cmyk_profile.clone(); // Spreads (Publisher pages) sit side by side, 36 pt apart; a single spread keeps its origin. let mut layers = Vec::new(); let mut artboards = Vec::new(); @@ -102,10 +132,12 @@ impl Builder { let mut loose: Vec> = Vec::new(); for n in &spread.nodes { let top = match &n.kind { - Kind::Layer => self.node(n, to_doc, &mut artboards, true), - Kind::Artboard { .. } => self.node(n, to_doc, &mut artboards, true).map(|art| self.layer_of(&n.name, art, n.visible, n.locked)), + Kind::Layer => self.node(n, to_doc, &mut artboards, true, 0), + Kind::Artboard { .. } => { + self.node(n, to_doc, &mut artboards, true, 0).map(|art| self.layer_of(&n.name, art, n.visible, n.locked)) + } _ => { - if let Some(art) = self.node(n, to_doc, &mut artboards, false) { + if let Some(art) = self.node(n, to_doc, &mut artboards, false, 0) { loose.push(art); } continue; @@ -119,6 +151,7 @@ impl Builder { if !loose.is_empty() { layers.push(self.layer_with(None, loose)); } + self.master_pages(d, spread, to_doc); // Without artboards, each page (or the spread) becomes one. if artboards.len() == before { let rects = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() }; @@ -149,8 +182,18 @@ impl Builder { if self.doc.artboards.is_empty() { return Err(err("the Affinity document has no page with a size")); } + // Master pages are behind everything on the pages that show them. + let mut masters = std::mem::take(&mut self.masters); + masters.sort_by_key(|(m, _)| *m); + let mut bottom = Vec::new(); + for (m, art) in masters { + let mut l = (*self.layer_with(Some(&format!("Master {}", master_prefix(m))), art)).clone(); + l.locked = true; + bottom.push(Arc::new(l)); + } // Bottom to top, as Affinity lists them. - self.doc.layers = layers; + bottom.extend(layers); + self.doc.layers = bottom; if self.doc.layers.is_empty() { let empty = self.layer_with(None, Vec::new()); self.doc.layers.push(empty); @@ -158,6 +201,53 @@ impl Builder { Ok(()) } + /// The master pages `spread`'s pages show, placed on those pages (`to_doc` places the spread). + /// Each master page shows only the objects on it, moved from its page to the one showing it. + fn master_pages(&mut self, d: &af::Document, spread: &af::Spread, to_doc: Affine) { + let instances = master_instances(&spread.nodes); + for (pi, page) in spread.pages.iter().enumerate() { + let Some(&(master, first, master_page, _)) = instances.iter().find(|(_, p, _, n)| pi >= *p && pi < p.saturating_add(*n)) else { + continue; + }; + let Some(m) = d.masters.get(master) else { continue }; + let index = master_page.saturating_add(pi - first); + let r = m.pages.get(index).or_else(|| m.pages.last()).copied().unwrap_or(m.bounds); + if !master_instances(&m.nodes).is_empty() { + self.warn("master pages based on other master pages (only their own objects are used)"); + } + let place = to_doc * Affine::translate((page.x0 - r.x0, page.y0 - r.y0)); + let on_page = + |n: &af::Node| af::nodes_bounds(std::slice::from_ref(n)).is_some_and(|b| b.x1 > r.x0 && b.x0 < r.x1 && b.y1 > r.y0 && b.y0 < r.y1); + let mut art = Vec::new(); + let mut boards = Vec::new(); + self.embedded += 1; + for n in &m.nodes { + if matches!(n.kind, Kind::Layer) { + // A layer of the master spread: just its objects on this page. + let kids: Vec> = + n.children.iter().filter(|c| on_page(c)).filter_map(|c| self.node(c, place, &mut boards, false, 1)).collect(); + if !kids.is_empty() { + let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); + g.isolate = n.blend != Blend::PassThrough; + art.push(Arc::new(g)); + } + } else if on_page(n) { + art.extend(self.node(n, place, &mut boards, false, 0)); + } + } + self.embedded -= 1; + if art.is_empty() { + continue; + } + let mut g = Node::group(self.doc.alloc_id(), art); + g.name = Some(format!("Master {} (page {})", master_prefix(master), pi + 1)); + match self.masters.iter_mut().find(|(i, _)| *i == master) { + Some((_, v)) => v.push(Arc::new(g)), + None => self.masters.push((master, vec![Arc::new(g)])), + } + } + } + fn layer_with(&mut self, name: Option<&str>, children: Vec>) -> Arc { let id = self.doc.alloc_id(); self.layer = self.layer.wrapping_add(1); @@ -212,18 +302,40 @@ impl Builder { } } + /// `n` with its colour and gradient overlays baked into its paint and pixels (VectorCraft has + /// no overlay effect). + fn overlaid<'n>(&mut self, n: &'n af::Node) -> std::borrow::Cow<'n, af::Node> { + let mut warnings = Vec::new(); + let out = vectorcraft_affinity::overlay::apply(n, &mut |w| warnings.push(w.to_string())); + for w in warnings { + self.warn(&w); + } + out + } + /// One Affinity node (and its subtree) as VectorCraft art; `to_doc` maps document pixels to /// points. `layer_level`: the node is directly in a spread or a layer, so a layer stays a layer. - fn node(&mut self, n: &af::Node, to_doc: Affine, boards: &mut Vec<(String, Rect)>, layer_level: bool) -> Option> { - // Register before descending so nested artboards keep their document order. - if let Kind::Artboard { rect, .. } = &n.kind { + fn node(&mut self, n: &af::Node, to_doc: Affine, boards: &mut Vec<(String, Rect)>, layer_level: bool, depth: usize) -> Option> { + if depth > MAX_DEPTH { + self.warn("objects nested too deeply (left out)"); + return None; + } + let n = &*self.overlaid(n); + // Register before descending so nested artboards keep their document order. Inside an + // embedded document an artboard is just its content's frame. + if let Kind::Artboard { rect, .. } = &n.kind + && self.embedded == 0 + { boards.push(( if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() }, to_doc.transform_rect_bbox(rect_of(*rect)), )); } let child_level = matches!(n.kind, Kind::Layer) && layer_level; - let children: Vec> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level)).collect(); + let embeds = n.class == vectorcraft_affinity::stream::Tag::of(b"EmbN"); + self.embedded += usize::from(embeds); + let children: Vec> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level, depth + 1)).collect(); + self.embedded -= usize::from(embeds); let mut out = match &n.kind { Kind::Layer if layer_level => { let mut l = (*self.layer_with(Some(if n.name.is_empty() { "Layer" } else { &n.name }), children)).clone(); @@ -242,7 +354,9 @@ impl Builder { Kind::Shape { path, fills, strokes, even_odd } => { let data = self.path(path, to_doc); let rule = if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }; - let appearance = self.appearance(fills, strokes, to_doc); + let mut appearance = self.appearance(fills, strokes, to_doc); + // An outline effect on a shape is one more stroke. + self.outline_stroke(&n.effects, &mut appearance); if children.is_empty() { let mut p = self.base(n, NodeKind::Path { path: data, rule, live: None, clipping: false, guide: false }); p.appearance = appearance.into(); @@ -276,8 +390,17 @@ impl Builder { g } Kind::Text(t) => { - let text = self.text(t, to_doc); + let Some((mut text, alpha)) = self.text(t, to_doc) else { + // The text is left out (warned); what it holds still comes in. + if children.is_empty() { + return None; + } + let g = self.base(n, NodeKind::Group { children, clip: false }); + return Some(Arc::new(g)); + }; + self.outline_text(&n.effects, &mut text); let mut node = self.base(n, NodeKind::Text(Box::new(text))); + node.opacity *= alpha; if !children.is_empty() { let mut kids = vec![Arc::new(node)]; kids.extend(children); @@ -285,14 +408,29 @@ impl Builder { } node } + Kind::Table(t) => { + let mut kids = self.table(t, to_doc); + if kids.is_empty() && children.is_empty() { + return None; + } + kids.extend(children); + let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); + g.name.get_or_insert_with(|| "Table".into()); + g + } Kind::Image(img) => match self.image(img, to_doc) { Some(kind) => self.base(n, kind), None if children.is_empty() => return None, None => self.base(n, NodeKind::Group { children, clip: false }), }, + // Shown on the pages through the master's layer (see `master_pages`). + Kind::MasterInstance { .. } => return None, Kind::Unsupported if children.is_empty() => return None, Kind::Unsupported => self.base(n, NodeKind::Group { children, clip: false }), }; + if !matches!(n.kind, Kind::Shape { .. } | Kind::Text(_)) && n.effects.iter().any(|e| matches!(e, af::Effect::Outline { .. })) { + self.warn("outline effects on groups, images and tables (left out)"); + } if let Some(img) = &n.pixel_mask { // A pixel mask: its grey levels show (white) or hide (black) the node, and nothing // shows outside it, as an opacity mask that clips. @@ -314,9 +452,140 @@ impl Builder { (g.opacity, g.blend, g.visible, g.name) = (opacity, blend, visible, name); out = g; } + // Shadows and glows apply to what the node shows, mask and all. + let fx = self.effects(&n.effects); + if !fx.is_empty() { + out.appearance.effects.extend(fx); + } Some(Arc::new(out)) } + /// Drop shadows, inner shadows and outer glows as VectorCraft's Stylize effects. Affinity's + /// blur radius sets the same Gaussian as VectorCraft's blur (σ = half of it), and its angle + /// is the direction the shadow goes in. + fn effects(&mut self, effects: &[af::Effect]) -> Vec { + let k = self.k; + let len = move |v: f64| if v.is_finite() { (v * k).clamp(0.0, 1000.0) } else { 0.0 }; + let num = |v: f64| (v * 1000.0).round() / 1000.0; + let mut out = Vec::new(); + for e in effects { + let (id, params) = match e { + af::Effect::DropShadow { color, opacity, radius, distance, angle } => { + let (color, alpha) = Self::color(*color); + let d = len(*distance); + let a = if angle.is_finite() { *angle } else { 0.0 }; + let p = json!({ + "mode": "normal", + "opacity": num(opacity * f64::from(alpha) * 100.0), + "x": num(d * a.cos()), + "y": num(d * a.sin()), + "blur": num(len(*radius)), + "color": color, + }); + ("stylize.dropShadow", p) + } + af::Effect::InnerShadow { color, opacity, radius, distance, .. } => { + self.warn("inner shadows (imported as inner glows at the edge, without their offset)"); + let (color, alpha) = Self::color(*color); + let p = json!({ + "mode": "normal", + "opacity": num(opacity * f64::from(alpha) * 100.0), + "blur": num(len(*radius) + len(*distance)), + "color": color, + "source": "edge", + }); + ("stylize.innerGlow", p) + } + af::Effect::OuterGlow { color, opacity, radius } => { + let (color, alpha) = Self::color(*color); + let p = + json!({ "mode": "normal", "opacity": num(opacity * f64::from(alpha) * 100.0), "blur": num(len(*radius)), "color": color }); + ("stylize.outerGlow", p) + } + af::Effect::Bevel { .. } => { + self.warn("bevel and emboss effects (left out)"); + continue; + } + // Outlines become strokes (`outline_stroke`, `outline_text`), overlays are baked + // into the paint (`overlaid`). + af::Effect::Outline { .. } | af::Effect::ColorOverlay { .. } | af::Effect::GradientOverlay { .. } => continue, + }; + out.push(Effect { id: id.into(), params, visible: true }); + } + out + } + + /// An outline effect on a shape: a stroke layer. Affinity outlines what the shape shows, its own + /// strokes included: an outline outside goes under them (from the path out to as far past them as + /// it is wide), one centred on or inside that edge over them. A centred one reaches as far into + /// the shape as out of it here, which only differs by the width of the shape's own stroke. + fn outline_stroke(&mut self, effects: &[af::Effect], appearance: &mut Appearance) { + // How far the shape's own strokes reach outside its path. + let reach = appearance + .items + .iter() + .filter_map(|i| match i { + AppearanceItem::Stroke(s) => Some(match s.align { + StrokeAlign::Center => s.width / 2.0, + StrokeAlign::Outside => s.width, + StrokeAlign::Inside => 0.0, + }), + _ => None, + }) + .filter(|w| w.is_finite()) + .fold(0.0, f64::max); + for e in effects { + let af::Effect::Outline { color, opacity, width, align } = e else { continue }; + let w = width * self.k; + if !(w.is_finite() && w > 0.0) { + continue; + } + let (c, alpha) = Self::color(*color); + let (align, w) = match align { + 1 => (StrokeAlign::Inside, w), + 2 => (StrokeAlign::Center, w + 2.0 * reach), + _ => (StrokeAlign::Outside, w + reach), + }; + let mut l = StrokeLayer::new(Paint::solid(c), w.min(1000.0)); + l.opacity = (opacity.clamp(0.0, 1.0) as f32) * alpha; + l.join = LineJoin::Round; + l.align = align; + let item = AppearanceItem::Stroke(l); + if align == StrokeAlign::Outside { + // Under the shape's strokes, over its fills. + let at = appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap_or(appearance.items.len()); + appearance.items.insert(at, item); + } else { + appearance.items.push(item); + } + } + } + + /// An outline effect on text: the characters' stroke, where they have none of their own. + fn outline_text(&mut self, effects: &[af::Effect], text: &mut TextObject) { + for e in effects { + let af::Effect::Outline { color, opacity, width, align } = e else { continue }; + let w = width * self.k; + if !(w.is_finite() && w > 0.0) { + continue; + } + let (c, alpha) = Self::color(*color); + if opacity * f64::from(alpha) < 0.999 { + self.warn("semi-transparent outline effects on text (imported opaque)"); + } + if *align != 2 { + self.warn("outline effects on text inside or outside the characters (centred on them)"); + } + for r in &mut text.runs { + if r.style.stroke == Paint::None { + r.style.stroke = Paint::solid(c); + // Centred on the outline, so twice as wide to show as far outside. + r.style.stroke_width = if *align == 2 { w } else { 2.0 * w }.min(1000.0); + } + } + } + } + fn path(&self, p: &af::Path, to_doc: Affine) -> PathData { let mut bp = BezPath::new(); for s in &p.subpaths { @@ -337,7 +606,7 @@ impl Builder { } fn color(c: ap::Color) -> (Color, f32) { - let f = |v: f64| v.clamp(0.0, 1.0) as f32; + let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }; match c { ap::Color::Rgb { r, g, b, a } => (Color::Rgb { r: f(r), g: f(g), b: f(b) }, f(a)), ap::Color::Cmyk { c, m, y, k, a } => (Color::Cmyk { c: f(c), m: f(m), y: f(y), k: f(k) }, f(a)), @@ -390,6 +659,34 @@ impl Builder { } } + fn stroke_layer(&mut self, s: &ap::Stroke, to_doc: Affine) -> Option { + let (paint, opacity) = self.paint(&s.paint, to_doc)?; + let mut l = StrokeLayer::new(paint, s.width * self.k); + l.opacity = opacity; + l.cap = match s.cap { + Cap::Butt => LineCap::Butt, + Cap::Round => LineCap::Round, + Cap::Square => LineCap::Square, + }; + l.join = match s.join { + Join::Miter => LineJoin::Miter, + Join::Round => LineJoin::Round, + Join::Bevel => LineJoin::Bevel, + }; + l.miter_limit = s.miter_limit; + l.align = match s.align { + ap::Align::Center => StrokeAlign::Center, + ap::Align::Inside => StrokeAlign::Inside, + ap::Align::Outside => StrokeAlign::Outside, + }; + l.dash = s.dash.as_ref().map(|(pattern, phase)| Dash { + pattern: pattern.iter().map(|v| v * self.k).collect(), + offset: phase * self.k, + align_corners: false, + }); + Some(l) + } + fn appearance(&mut self, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> Appearance { let mut items = Vec::new(); let mut above = Vec::new(); @@ -401,30 +698,7 @@ impl Builder { } } for s in strokes { - let Some((paint, opacity)) = self.paint(&s.paint, to_doc) else { continue }; - let mut l = StrokeLayer::new(paint, s.width * self.k); - l.opacity = opacity; - l.cap = match s.cap { - Cap::Butt => LineCap::Butt, - Cap::Round => LineCap::Round, - Cap::Square => LineCap::Square, - }; - l.join = match s.join { - Join::Miter => LineJoin::Miter, - Join::Round => LineJoin::Round, - Join::Bevel => LineJoin::Bevel, - }; - l.miter_limit = s.miter_limit; - l.align = match s.align { - ap::Align::Center => StrokeAlign::Center, - ap::Align::Inside => StrokeAlign::Inside, - ap::Align::Outside => StrokeAlign::Outside, - }; - l.dash = s.dash.as_ref().map(|(pattern, phase)| Dash { - pattern: pattern.iter().map(|v| v * self.k).collect(), - offset: phase * self.k, - align_corners: false, - }); + let Some(l) = self.stroke_layer(s, to_doc) else { continue }; if s.behind { items.insert(0, AppearanceItem::Stroke(l)); } else { @@ -435,85 +709,414 @@ impl Builder { Appearance { items, ..Appearance::default() } } - fn text(&mut self, t: &af::Text, to_doc: Affine) -> TextObject { - let origin = match t.frame { - Some(f) => Point::new(f.x0, f.y0), - None => Point::new(t.anchor.x, t.anchor.y), + /// Where text placed by `m` (node space to points) goes, its type scaled by `type_m`. + fn placement(&mut self, m: Affine, type_m: Affine) -> Option { + let [a, b, c, d, e, f] = m.as_coeffs(); + let sx = a.hypot(b); + let det = a * d - b * c; + if !(sx > 1e-9 && det.is_finite() && det.abs() > 1e-12 && e.is_finite() && f.is_finite()) { + self.warn("text with an invalid transform (left out)"); + return None; + } + let sy = det / sx; + let shear = (a * c + b * d) / (sx * sx); + if shear.abs() > 1e-3 || sy < 0.0 { + self.warn("skewed or flipped text (imported without the skew or flip)"); + } + // The type's vertical scale gives the font size, its horizontal scale relative to that the + // characters' width. + let [ta, tb, tc, td, ..] = type_m.as_coeffs(); + let (tsx, tdet) = (ta.hypot(tb), ta * td - tb * tc); + let (size_scale, h_scale) = if tsx > 1e-9 && tdet.is_finite() && tdet.abs() > 1e-12 { + let tsy = (tdet / tsx).abs(); + (tsy, tsx / tsy) + } else { + self.warn("text with an invalid text scale (imported unscaled)"); + (det.abs().sqrt(), 1.0) }; - // Text space: origin at the anchor (or frame corner), the transform's uniform scale moved - // into the type size so sizes read in points. - let m = to_doc * affine(t.transform) * Affine::translate(origin.to_vec2()); - let c = m.as_coeffs(); - let k = (c[0] * c[3] - c[1] * c[2]).abs().sqrt().max(1e-9); - let xf = m * Affine::scale(1.0 / k); - let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); - obj.xf = xf; - obj.runs = t - .runs - .iter() - .map(|r| { - let mut style = CharStyle::default(); - if !r.family.is_empty() { - style.font_family = r.family.clone(); - } else if !r.postscript.is_empty() { - style.font_family = r.postscript.clone(); - } else { - self.warn("text without a font name uses the default font"); + Some(Placement { + to_frame: Affine::scale_non_uniform(sx, sy.abs()), + place: Affine::translate((e, f)) * Affine::rotate(b.atan2(a)), + size_scale, + h_scale, + }) + } + + /// Text → its object and the opacity its characters' shared transparency gives the node. + fn text(&mut self, t: &af::Text, to_doc: Affine) -> Option<(TextObject, f32)> { + let p = self.placement(to_doc * affine(t.transform), to_doc * affine(t.text_transform))?; + let alpha = self.text_alpha(&t.runs); + let mut obj = self.type_object(&t.runs, &p)?; + obj.para.justify = justify(t.align); + if let Some(first) = obj.paras.first_mut() { + first.justify = obj.para.justify; + } + match t.frame { + Some(f) => { + let (a, b) = (p.to_frame * Point::new(f.x0, f.y0), p.to_frame * Point::new(f.x1, f.y1)); + let r = Rect::from_points(a, b); + obj.xf = p.place * Affine::translate(r.origin().to_vec2()); + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, r.width(), r.height())) }; + // Affinity hangs a frame's first line from its capitals' tops, not the font's ascent. + obj.area.first_baseline = FirstBaseline::CapHeight; + if fit_lines(&mut obj, r.width(), r.height()) { + self.warn("text frames whose last lines hang out of them (made taller so the lines show)"); } - // A family/weight pair cannot identify width variants such as Arial Narrow. - // Resolve a known PostScript identity to the exact installed family and style. - if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) { - style.font_family = family; - style.font_style = face_style; - } else { - style.font_style = font_style(r.weight, r.italic); + // Affinity shows a line in a frame shorter than it (#1423); here none of it would draw. + if vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj) { + self.warn("text frames too short for their first line (made taller so the line shows)"); } - style.features = r.features.clone(); - if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) { - self.warn("unsupported text OpenType features (retained but not rendered)"); - } - style.all_caps = r.all_caps; - style.size = r.size * k; - style.leading = r.leading.map(|l| l * k); - style.tracking = r.tracking * 1000.0; - style.fill = match self.paint(&r.fill, to_doc) { - Some((p, a)) => { - if a < 0.999 { - self.warn("translucent text colours import opaque"); - } - p - } - None => Paint::None, - }; - // Affinity separates paragraphs with U+2029 and lines with U+2028. - TextRun::new(r.text.replace(['\u{2029}', '\u{2028}'], "\n"), style) - }) - .collect(); - obj.para.justify = match t.align { - AfAlign::Left => Justify::Left, - AfAlign::Center => Justify::Center, - AfAlign::Right => Justify::Right, - AfAlign::Justify => Justify::JustifyLeft, - }; - if let Some(f) = t.frame { - obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, (f.x1 - f.x0) * k, (f.y1 - f.y0) * k)) }; - // Affinity shows a line in a frame shorter than it (#1423); here none of it would draw. - if vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj) { - self.warn("text frames too short for their first line (made taller so the line shows)"); + } + None => { + align_lines(&mut obj); + obj.xf = p.place * Affine::translate((p.to_frame * Point::new(t.anchor.x, t.anchor.y)).to_vec2()); } } - obj + Some((obj, alpha)) + } + + /// The opacity text takes from its characters' colour: VectorCraft's characters have none of + /// their own, so text whose characters share one becomes an object with that opacity. + fn text_alpha(&mut self, runs: &[af::TextRun]) -> f32 { + let alphas: Vec = + runs.iter().filter_map(|r| if let ap::Paint::Solid(c) = &r.fill { Some(c.alpha()) } else { None }).filter(|a| a.is_finite()).collect(); + let (lo, hi) = alphas.iter().fold((1.0f64, 0.0f64), |(lo, hi), a| (lo.min(*a), hi.max(*a))); + if alphas.is_empty() || lo >= 1.0 { + return 1.0; + } + if hi - lo > 0.01 { + self.warn("text with characters of different transparency (imported opaque)"); + return 1.0; + } + lo.clamp(0.0, 1.0) as f32 + } + + /// The characters and paragraphs of `runs` as a text object at the origin of text space. + fn type_object(&mut self, runs: &[af::TextRun], p: &Placement) -> Option { + let (v, h) = (p.size_scale, p.size_scale * p.h_scale); + let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); + let mut formats: Vec = Vec::new(); + let mut out: Vec = Vec::new(); + let mut paragraph = 0usize; + // Whether each paragraph here continues the one before after a line break (U+2028): + // VectorCraft has no line break inside a paragraph, so the lines become paragraphs. + let mut continues = vec![false]; + for r in runs { + for c in r.text.chars() { + match c { + '\u{2028}' => continues.push(true), + '\u{2029}' | '\r' | '\n' => continues.push(false), + _ => {} + } + } + // Affinity separates paragraphs with U+2029 and lines with U+2028; other control + // characters don't show. + let text: String = r + .text + .chars() + .filter_map(|c| match c { + '\u{2029}' | '\u{2028}' | '\r' | '\n' => Some('\n'), + '\t' => Some('\t'), + c if c.is_control() => None, + c => Some(c), + }) + .collect(); + if text.is_empty() { + continue; + } + for c in text.chars() { + if formats.len() <= paragraph { + formats.push(r.paragraph); + } + if c == '\n' { + paragraph += 1; + } + } + let mut style = self.char_style(r, (v, p.h_scale)); + if style.leading.is_none() { + style.leading = r.paragraph.leading.map(|l| l * v).filter(|l| l.is_finite() && *l > 0.0).map(|l| l.min(5000.0)); + } + out.push(TextRun::new(text, style)); + } + // Affinity ends a story with a paragraph mark; VectorCraft's last paragraph has none. + if let Some(last) = out.last_mut() + && last.text.ends_with('\n') + { + last.text.pop(); + if last.text.is_empty() { + out.pop(); + } + } + obj.runs = out; + let count = obj.runs.iter().map(|r| r.text.matches('\n').count()).sum::() + 1; + let mut paras = Vec::with_capacity(count); + let mut previous_after = 0.0; + let mut first_styles = Vec::with_capacity(count); + { + // The first character's style of each paragraph. + let mut at_start = true; + for r in &obj.runs { + for c in r.text.chars() { + if at_start { + first_styles.push((r.style.font_family.clone(), r.style.font_style.clone())); + at_start = false; + } + if c == '\n' { + at_start = true; + } + } + } + } + for i in 0..count { + let f = formats.get(i).or(formats.last()).copied().unwrap_or_default(); + let pt = |x: f64, k: f64| if (x * k).is_finite() { (x * k).clamp(-5000.0, 5000.0) } else { 0.0 }; + let mut ps = vectorcraft_doc::text::ParaStyle { justify: justify(f.align), ..Default::default() }; + // Affinity places the first line and the others from the left edge; VectorCraft's + // first-line indent is relative to its left indent. + ps.left_indent = pt(f.left, h); + ps.first_line_indent = pt(f.first - f.left, h); + ps.right_indent = pt(f.right, h); + // Between two paragraphs Affinity keeps the larger of the space after the first and + // before the second; VectorCraft adds them. + let (mut before, mut after) = (pt(f.space_before, v).max(0.0), pt(f.space_after, v).max(0.0)); + // The lines of one Affinity paragraph: no space between them, and the first-line + // indent only on its first. + if continues.get(i).copied().unwrap_or(false) { + before = 0.0; + ps.first_line_indent = 0.0; + } + if continues.get(i + 1).copied().unwrap_or(false) { + after = 0.0; + } + ps.space_before = if i == 0 { before } else { (before - previous_after).max(0.0) }; + ps.space_after = after; + previous_after = after; + // Automatic leading: the font size plus the font's line gap, times the paragraph's + // factor, for artistic and frame text alike. Fitted on Affinity's thumbnails: Bebas + // Neue Pro and Impact lines about 1 em apart, DIN Condensed (a gap of 0.2 em) 1.2 em, + // and the public Arial frame fixture fits this better than the font's line height. + let (family, style) = first_styles.get(i).cloned().unwrap_or_default(); + let ratio = (1.0 + line_gap(&family, &style)) * f.auto_leading; + // Affinity documents use tiny factors for spacer paragraphs (0.15 of a line); VectorCraft + // takes 0–500 %. + if ratio.is_finite() && (0.01..=5.0).contains(&ratio) { + ps.justification.auto_leading = ratio * 100.0; + } + paras.push(ps); + } + if let Some(first) = paras.first() { + obj.para = first.clone(); + } + obj.paras = paras; + obj.normalize_paras(); + Some(obj) + } + + /// A run's character style; `(size_scale, h_scale)` the type's vertical scale to points and + /// its horizontal scale relative to that. + fn char_style(&mut self, r: &af::TextRun, (size_scale, h_scale): (f64, f64)) -> CharStyle { + let finite = |v: f64, d: f64| if v.is_finite() { v } else { d }; + let mut style = CharStyle::default(); + if !r.family.is_empty() { + style.font_family = r.family.clone(); + } else if !r.postscript.is_empty() { + style.font_family = r.postscript.clone(); + } else { + self.warn("text without a font name uses the default font"); + } + // A family/weight pair cannot identify width variants such as Arial Narrow. + // Resolve a known PostScript identity to the exact installed family and style. + if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) { + style.font_family = family; + style.font_style = face_style; + } else { + style.font_style = font_style(r.weight, r.italic); + } + style.features = r.features.clone(); + if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) { + self.warn("unsupported text OpenType features (retained but not rendered)"); + } + style.all_caps = r.all_caps; + style.underline = r.underline; + style.strikethrough = r.strikethrough; + style.size = finite(r.size * size_scale, 12.0).clamp(0.1, 1296.0); + style.leading = r.leading.map(|l| finite(l * size_scale, 0.0)).filter(|l| *l > 0.0).map(|l| l.min(5000.0)); + style.tracking = finite(r.tracking * 1000.0, 0.0).clamp(-1000.0, 10000.0); + // Text squeezed or stretched by its transform keeps that as horizontal scale, on top of + // its own. + let hs = h_scale * r.h_scale; + if hs.is_finite() && (hs - 1.0).abs() > 1e-3 { + style.h_scale = (hs * 100.0).clamp(1.0, 1000.0); + } + // Transparency is the object's (see `text_alpha`); the paint's transform doesn't matter + // for a solid colour, and gradients on type are laid out in text space. + style.fill = match self.paint(&r.fill, Affine::scale(size_scale)) { + Some((p, _)) => p, + None => Paint::None, + }; + if let Some(st) = &r.stroke { + let w = st.width * size_scale; + if let Some((paint, _)) = self.paint(&st.paint, Affine::scale(size_scale)) + && w.is_finite() + && w > 0.0 + { + style.stroke = paint; + style.stroke_width = w.min(1000.0); + } + } + style + } + + /// A table: its cells as area type and the lines on their edges, as VectorCraft has no + /// tables. + fn table(&mut self, t: &af::Table, to_doc: Affine) -> Vec> { + let mut out = Vec::new(); + let (ncols, nrows) = (t.ncols(), t.nrows()); + if ncols == 0 || nrows == 0 { + return out; + } + self.warn("tables (imported as text frames and lines)"); + let m = to_doc * affine(t.transform); + let Some(p) = self.placement(m, to_doc * affine(t.text_transform)) else { return out }; + let col = |i: usize| t.columns.get(i).copied().unwrap_or(0.0); + let row = |i: usize| t.rows.get(i).copied().unwrap_or(0.0); + for (i, cell) in t.cells.iter().enumerate() { + if cell.merged_left || cell.runs.is_empty() { + continue; + } + let (r, c) = (i / ncols, i % ncols); + // Cells to the right merged into this one widen it. + let span = 1 + t.cells.iter().skip(i + 1).take(ncols - c - 1).take_while(|n| n.merged_left).count(); + let [l, tp, rt, bt] = cell.inset; + let node = af::Rect { x0: col(c) + l, y0: row(r) + tp, x1: col(c + span) - rt, y1: row(r + 1) - bt }; + let (a, b) = (p.to_frame * Point::new(node.x0, node.y0), p.to_frame * Point::new(node.x1, node.y1)); + let rect = Rect::from_points(a, b); + if !(rect.width() > 0.0 && rect.height() > 0.0) { + continue; + } + let Some(mut obj) = self.type_object(&cell.runs, &p) else { continue }; + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, rect.width(), rect.height())) }; + obj.area.vertical_align = match cell.valign { + af::VAlign::Top => VerticalAlign::Top, + af::VAlign::Center => VerticalAlign::Center, + af::VAlign::Bottom => VerticalAlign::Bottom, + }; + obj.area.first_baseline = FirstBaseline::CapHeight; + let dy = place_cell(&mut obj, rect.width(), rect.height()); + obj.xf = p.place * Affine::translate(rect.origin().to_vec2() + Vec2::new(0.0, dy)); + // A cell's text never vanishes for want of a line's height. + vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj); + let alpha = self.text_alpha(&cell.runs); + let mut n = Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))); + n.opacity = alpha; + out.push(Arc::new(n)); + } + // Lines over the cells: the vertical edges row by row (none inside merged cells), then + // the horizontal ones. + let mut line = |b: &mut Self, s: &Option, from: (f64, f64), to: (f64, f64)| { + let Some(s) = s else { return }; + let Some(layer) = b.stroke_layer(s, to_doc) else { return }; + let mut bp = BezPath::new(); + bp.move_to(m * Point::new(from.0, from.1)); + bp.line_to(m * Point::new(to.0, to.1)); + let mut n = Node::new( + b.doc.alloc_id(), + NodeKind::Path { path: PathData::from_bezpath(&bp), rule: FillRule::NonZero, live: None, clipping: false, guide: false }, + ); + n.appearance = Appearance { items: vec![AppearanceItem::Stroke(layer)], ..Appearance::default() }.into(); + out.push(Arc::new(n)); + }; + for r in 0..nrows { + for k in 0..=ncols { + let merged = k > 0 && k < ncols && t.cells.get(r * ncols + k).is_some_and(|c| c.merged_left); + if merged { + continue; + } + if let Some(s) = t.vertical.get(r * (ncols + 1) + k) { + line(self, s, (col(k), row(r)), (col(k), row(r + 1))); + } + } + } + for j in 0..=nrows { + for k in 0..ncols { + if let Some(s) = t.horizontal.get(j * ncols + k) { + line(self, s, (col(k), row(j)), (col(k + 1), row(j))); + } + } + } + out + } + + /// A CMYK pixel layer as an image file. Through the document's own CMYK profile when it + /// embeds one (as Affinity shows it): RGBA. Otherwise, opaque, a CMYK image that keeps its + /// inks (shown and separated through the working CMYK profile, as placed CMYK images are); + /// with transparency, RGBA through the working CMYK profile. + fn cmyk_image(&mut self, width: u32, height: u32, cmyka: &[u8]) -> Option> { + let n = (width as usize).checked_mul(height as usize)?; + let px = cmyka.get(..n.checked_mul(5)?)?.as_chunks::<5>().0; + if let Some(lut) = self.document_cmyk() { + let rgba = px + .iter() + .flat_map(|p| { + let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); + [r, g, b, p[4]] + }) + .collect(); + return png(width, height, rgba); + } + if px.iter().all(|p| p[4] == 255) { + let inks = px.iter().flat_map(|p| [p[0], p[1], p[2], p[3]]).collect(); + if let Some(blob) = vectorcraft_doc::cmyk::Inks::new(width, height, inks).and_then(|i| vectorcraft_doc::ImageBlob::cmyk_tiff(&i)) { + return Some(Arc::unwrap_or_clone(blob.bytes)); + } + } + self.warn("CMYK pixel layers with transparency (converted to RGB through the working CMYK profile)"); + let lut = vectorcraft_color::cms::CmykLut::build(|[c, m, y, k]| { + let [r, g, b] = Color::cmyk(c, m, y, k).to_rgb(); + [r, g, b, 0.0] + }); + let rgba = px + .iter() + .flat_map(|p| { + let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); + [r, g, b, p[4]] + }) + .collect(); + png(width, height, rgba) + } + + /// The document's CMYK profile as a table from ink amounts to sRGB (relative colorimetric, + /// with black-point compensation, as Affinity's pixel layers convert), made once; `None` when + /// the document embeds none, or not one that converts. + fn document_cmyk(&mut self) -> Option> { + use vectorcraft_color::cms::{self, CmykLut, IccProfile, Intent}; + if let Some(lut) = &self.cmyk_lut { + return lut.clone(); + } + let embedded = self.cmyk_profile.is_some(); + let lut = self.cmyk_profile.take().and_then(|bytes| { + let profile = IccProfile::from_bytes(None, &bytes).ok()?; + let srgb = cms::icc::builtin_rgb(cms::SRGB)?; + profile.to_rgb_of(&srgb, &[0.0; 4], Intent::RelativeColorimetric, true)?; + Some(Arc::new(CmykLut::build(|inks| { + let [r, g, b] = profile + .to_rgb_of(&srgb, &inks, Intent::RelativeColorimetric, true) + .unwrap_or_else(|| Color::cmyk(inks[0], inks[1], inks[2], inks[3]).to_rgb()); + [r, g, b, 0.0] + }))) + }); + if embedded && lut.is_none() { + self.warn("CMYK pixel layers whose document's colour profile could not be read (shown through the working CMYK profile)"); + } + self.cmyk_lut = Some(lut.clone()); + lut } fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option { let bytes = match &img.pixels { Pixels::Encoded(b) => b.clone(), - Pixels::Rgba8(rgba) => { - let buf = image::RgbaImage::from_raw(img.width, img.height, rgba.clone())?; - let mut png = Vec::new(); - buf.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - png - } + Pixels::Rgba8(rgba) => png(img.width, img.height, rgba.clone())?, + Pixels::Cmyka8(cmyka) => self.cmyk_image(img.width, img.height, cmyka)?, }; let r = match super::load::raster_image(&bytes) { Ok(r) => r, @@ -531,6 +1134,167 @@ impl Builder { } } +/// The master page instances among `nodes` (also inside layers and groups): (master, first page, +/// first master page, page count). +fn master_instances(nodes: &[af::Node]) -> Vec<(usize, usize, usize, usize)> { + fn visit(n: &af::Node, depth: usize, out: &mut Vec<(usize, usize, usize, usize)>) { + if depth > MAX_DEPTH || !n.visible { + return; + } + match n.kind { + Kind::MasterInstance { master, page, master_page, count } => out.push((master, page, master_page, count)), + Kind::Layer | Kind::Group => n.children.iter().for_each(|c| visit(c, depth + 1, out)), + _ => {} + } + } + let mut out = Vec::new(); + nodes.iter().for_each(|n| visit(n, 0, &mut out)); + out +} + +/// Master names: A, B … Z, then A1, B1 …. +fn master_prefix(index: usize) -> String { + let letter = char::from(b'A' + (index % 26) as u8); + match index / 26 { + 0 => letter.to_string(), + n => format!("{letter}{n}"), + } +} + +/// A table cell's text `w` × `h` at the origin, placed as Affinity places it. Affinity aligns the +/// block from the first line's capitals' tops to the last baseline in the cell, and draws it even +/// where it doesn't fit (centred cells measured on a document's thumbnail: two lines in a cell +/// shorter than them reach out of it at both ends). VectorCraft aligns the block down to the last +/// line's descenders and keeps lines inside the frame: the frame moves by what this returns (down +/// the cell) and grows where the lines need more room. +fn place_cell(obj: &mut TextObject, w: f64, h: f64) -> f64 { + let share = match obj.area.vertical_align { + VerticalAlign::Center => 0.5, + VerticalAlign::Bottom => 1.0, + _ => 0.0, + }; + let mut tall = obj.clone(); + tall.area.vertical_align = VerticalAlign::Top; + tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; + let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tall).lines; + let Some(last) = lines.iter().rfind(|l| l.region == 0) else { return 0.0 }; + let (baseline, descent) = (last.baseline, last.descent); + if !(baseline.is_finite() && descent.is_finite() && h.is_finite()) { + return 0.0; + } + // (A little more than the lines need, for the rounding of the layout.) + let frame = h.max(baseline + descent + 0.05); + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, frame)) }; + // Where Affinity has the block's top, less where VectorCraft puts it in the frame. + (h - baseline) * share - (frame - baseline - descent) * share +} + +/// Area type `w` × `h` at the origin whose lines don't all fit. Affinity shows each line whose +/// baseline is inside the frame, its descenders hanging out of it; VectorCraft's area type leaves +/// such a line out. The frame is made taller, from its top, by what those lines need. Whether it was. +fn fit_lines(obj: &mut TextObject, w: f64, h: f64) -> bool { + let db = vectorcraft_text::FontDb::global(); + if !(w > 0.0 && h > 0.0 && h.is_finite()) || !vectorcraft_text::layout(db, obj).overflow { + return false; + } + // Where the lines go in a frame with room for all of them. + let mut tall = obj.clone(); + tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; + let need = vectorcraft_text::layout(db, &tall) + .lines + .iter() + .filter(|l| l.region == 0 && l.baseline <= h + 1e-6) + .map(|l| l.baseline + l.descent) + .filter(|b| b.is_finite()) + .fold(0.0, f64::max); + if need <= h { + return false; + } + let before = vectorcraft_text::layout(db, obj).lines.len(); + // (A little more than they need, for the rounding of the layout.) + let short = std::mem::replace(&mut obj.kind, TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, need + 0.05)) }); + // Something else keeps the lines out: as the source has it. + if vectorcraft_text::layout(db, obj).lines.len() <= before { + obj.kind = short; + return false; + } + true +} + +/// RGBA pixels as a PNG file. +fn png(width: u32, height: u32, rgba: Vec) -> Option> { + let buf = image::RgbaImage::from_raw(width, height, rgba)?; + let mut png = Vec::new(); + buf.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(png) +} + +/// Where a line of `w` starts in a span from 0 to `room` (as a share of `room - w`). +fn share(j: Justify) -> f64 { + match j { + Justify::Center | Justify::JustifyCenter => 0.5, + Justify::Right | Justify::JustifyRight => 1.0, + _ => 0.0, + } +} + +/// Artistic text whose paragraphs are aligned differently. Affinity aligns each line within the +/// width of the longest, and places that block at the anchor by the text's own alignment (its +/// first paragraph's); VectorCraft's point type aligns each line on the anchor. Indents move the +/// lines to where Affinity has them and keep their alignment: a centred line centres between its +/// indents and a right-aligned one ends at its right indent, whatever its width. +fn align_lines(obj: &mut TextObject) { + let block = share(obj.para.justify); + if obj.paras.iter().all(|p| share(p.justify) == block) { + return; + } + let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj).lines; + let w = lines.iter().map(|l| l.x1 - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); + if !(w > 0.0 && w < 1e6) { + return; + } + // The block's left edge, from the anchor. + let left = -w * block; + for p in &mut obj.paras { + let at = share(p.justify); + let shift = left + w * at; + // A left-aligned line starts at its left indent, a right-aligned one ends at minus its right + // indent, and a centred one centres on half their difference. + if at < 0.25 { + p.left_indent += shift; + } else if at > 0.75 { + p.right_indent -= shift; + } else { + p.left_indent += shift; + p.right_indent -= shift; + } + } + if let Some(first) = obj.paras.first() { + obj.para = first.clone(); + } +} + +fn justify(a: AfAlign) -> Justify { + match a { + AfAlign::Left => Justify::Left, + AfAlign::Center => Justify::Center, + AfAlign::Right => Justify::Right, + AfAlign::Justify => Justify::JustifyLeft, + } +} + +/// A font's line gap in em, as VectorCraft will set it (the installed face, or the fallback for +/// a missing one); 0 when no font is loaded. +fn line_gap(family: &str, style: &str) -> f64 { + let defaults = CharStyle::default(); + let family = if family.is_empty() { defaults.font_family.as_str() } else { family }; + let style = if style.is_empty() { defaults.font_style.as_str() } else { style }; + let Some(face) = vectorcraft_text::FontDb::global().face(family, style) else { return 0.0 }; + let upem = face.units_per_em(); + let gap = if upem > 0.0 { face.line_gap() / upem } else { 0.0 }; + if gap.is_finite() { gap.clamp(0.0, 2.0) } else { 0.0 } +} + fn font_style(weight: i64, italic: bool) -> String { let w = match weight { ..=150 => "Thin", diff --git a/crates/engine/tests/affinity_features.rs b/crates/engine/tests/affinity_features.rs new file mode 100644 index 000000000..77c848e73 --- /dev/null +++ b/crates/engine/tests/affinity_features.rs @@ -0,0 +1,1043 @@ +//! Affinity → VectorCraft mapping of pages, master pages, tables, paragraph formats, text scale, +//! layer effects and embedded documents, on original synthetic documents built with the reader's +//! `synth` feature (no Affinity software or files were used). The fixtures follow DesignCraft's +//! Affinity import tests, whose reader VectorCraft shares. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +#[path = "../../affinity/tests/support/text_fixture.rs"] +mod text_fixture; + +use std::sync::Arc; + +use kurbo::Rect; +use text_fixture::{array_f64, array_i32, attrs, block, run, shared_objects, utf8}; +use vectorcraft_affinity::synth::{self, F, Method, tag}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::appearance::{AppearanceItem, StrokeAlign}; +use vectorcraft_doc::text::{TextKind, TextObject, VerticalAlign}; +use vectorcraft_doc::{Document, Node, NodeKind}; +use vectorcraft_engine::cmd::fileio::{Loaded, load}; + +type Field = (vectorcraft_affinity::stream::Tag, F); + +/// Encode a stream whose shared arrays may list inline objects. +fn stream(mut root: Vec) -> Vec { + let mut next_id = 1000; + for (_, value) in &mut root { + shared_objects(value, &mut next_id); + } + synth::stream(&root) +} + +/// 300 dpi: a document pixel is 0.24 pt. +const DPI: f64 = 300.0; +const PT: f64 = 72.0 / DPI; + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +fn rgba(r: f32, g: f32, b: f32) -> F { + F::Obj(tag(b"RGBA"), vec![(tag(b"_col"), F::Struct([r, g, b, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]) +} + +fn cmyk(c: f32, m: f32, y: f32, k: f32) -> F { + F::Obj( + tag(b"FilS"), + vec![(tag(b"Colr"), F::Obj(tag(b"CMYK"), vec![(tag(b"_col"), F::Struct([c, m, y, k, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]))], + ) +} + +/// A magenta rectangle named `name`. +fn named_rect(id: u32, name: &str, at: (f64, f64), size: (f64, f64)) -> F { + F::Def( + id, + vec![tag(b"ShpN")], + vec![ + (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])), + (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])), + (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])), + (tag(b"BFil"), cmyk(0.0, 1.0, 0.0, 0.0)), + (tag(b"Desc"), F::Str(name.into())), + ], + ) +} + +fn rectangle(id: u32, at: (f64, f64), size: (f64, f64)) -> F { + named_rect(id, "Magenta box", at, size) +} + +/// An artboard (`ABEn`) named `name` over `bounds` at `at`, holding `children`. +fn board(id: u32, name: &str, at: (f64, f64), size: (f64, f64), children: Vec) -> F { + F::Def( + id, + vec![tag(b"ShpN")], + vec![ + (tag(b"Desc"), F::Str(name.into())), + (tag(b"ABEn"), F::Bool(true)), + (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])), + (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])), + (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])), + (tag(b"Chld"), F::Shared(children)), + ], + ) +} + +fn artistic_text(id: u32, text: &str, size: f64) -> F { + F::Def( + id, + vec![tag(b"TxtA")], + vec![ + ( + tag(b"StSt"), + F::Obj( + tag(b"Stry"), + vec![( + tag(b"Blok"), + F::Shared(vec![block( + utf8(text), + vec![run(text.chars().count() as i32, Some(attrs("Inter", "Inter-Bold", 700, false, size)))], + &[1], + )]), + )], + ), + ), + (tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])), + (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 1000.0, 0.0, 1.0, 500.0])), + ], + ) +} + +fn page(x0: f64, w: f64, h: f64) -> F { + F::Obj(tag(b"PagR"), vec![(tag(b"rctp"), F::F64s(vec![x0, 0.0, x0 + w, h]))]) +} + +/// A Publisher spread of A4 pages at 300 dpi holding `nodes`. +fn spread(id: u32, pages: usize, nodes: Vec) -> F { + let w = 2480.0 * pages as f64; + F::Def( + id, + vec![tag(b"Sprd")], + vec![ + (tag(b"SprB"), F::F64s(vec![0.0, 0.0, w, 3508.0])), + ( + tag(b"SpMd"), + F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(2480.0 * i as f64, 2480.0, 3508.0)).collect()))]), + ), + (tag(b"Chld"), F::Shared(nodes)), + ], + ) +} + +fn publisher_with(spreads: Vec) -> Vec { + let data = stream(vec![ + (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(DPI))])), + (tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(spreads))])), + ]); + synth::container(&[("doc.dat", &data, Method::Zstd)], None) +} + +/// A two-page spread: a layer with a CMYK rectangle, and centred artistic text. +fn publisher() -> Vec { + let layer = F::Def( + 3, + vec![tag(b"Scop")], + vec![(tag(b"Desc"), F::Str("Background".into())), (tag(b"Chld"), F::Shared(vec![rectangle(10, (100.0, 100.0), (100.0, 200.0))]))], + ); + publisher_with(vec![spread(2, 2, vec![layer, artistic_text(20, "Hello\u{2029}World\u{2029}", 100.0)])]) +} + +/// One A4 page at 300 dpi holding `node`. +fn page_with(node: F) -> Vec { + publisher_with(vec![spread(2, 1, vec![node])]) +} + +/// A document at 72 dpi holding `nodes` on a canvas without pages; `embedded` is `edc/1`. +fn canvas_with(nodes: Vec, embedded: Option<&[u8]>) -> Vec { + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 1000.0, 1000.0])), (tag(b"Chld"), F::Shared(nodes))]); + let data = stream(vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); + match embedded { + Some(inner) => { + let mut entry = b"EmDc\0\0\0\0".to_vec(); + entry.extend_from_slice(inner); + synth::container(&[("doc.dat", &data, Method::Zstd), ("edc/1", &entry, Method::Zstd)], None) + } + None => synth::container(&[("doc.dat", &data, Method::Zstd)], None), + } +} + +/// An instance of the embedded document `edc/1`, scaled 2× with its content's centre at (500, 300). +fn embedded_node(id: u32) -> F { + let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]); + F::Def( + id, + vec![tag(b"EmbN")], + vec![ + (tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content), (tag(b"PBBx"), F::Enum(2, 0))])), + (tag(b"Xfrm"), F::F64s(vec![2.0, 0.0, 500.0, 0.0, 2.0, 300.0])), + ], + ) +} + +/// A blue 2 px line on a table edge. +fn table_edge() -> F { + F::Obj( + tag(b"TEdg"), + vec![ + (tag(b"Line"), F::Obj(tag(b"LDsc"), vec![(tag(b"LDeL"), F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]))])), + (tag(b"Fill"), F::Obj(tag(b"FDsc"), vec![(tag(b"FDeF"), cmyk(1.0, 0.5, 0.0, 0.0))])), + ], + ) +} + +/// A 3 × 2 table whose cell (1, 1) is merged into (1, 0); cells "A".."E" in reading order, each +/// ended by a cell break. Cells are 100 × 50 px from (0, 0). +fn table_node(id: u32, transform: [f64; 6]) -> F { + table_with(id, transform, ["A", "B", "C", "D", "E"]) +} + +/// [`table_node`] with these cell texts. +fn table_with(id: u32, transform: [f64; 6], texts: [&str; 5]) -> F { + let brk = || F::Obj(tag(b"BrGl"), vec![(tag(b"HdBk"), F::Enum(4, 1))]); + let segments: Vec = texts + .iter() + .enumerate() + .map(|(i, t)| { + let mut fields = vec![(tag(b"Utf8"), F::Str((*t).into()))]; + if i > 0 { + fields.insert(0, (tag(b"Glys"), F::Shared(vec![brk()]))); + } + F::Obj(tag(b"GSeg"), fields) + }) + .collect(); + let len = (texts.iter().map(|t| t.chars().count()).sum::() + texts.len() - 1) as i32; + let glyphs = F::Obj(tag(b"GStr"), vec![(tag(b"Mixd"), F::Shared(segments))]); + let story = block(glyphs, vec![run(len, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))], &[]); + let cell = |merged: bool| { + let mut f = vec![(tag(b"AliY"), F::Enum(1, 0)), (tag(b"Inse"), F::F64s(vec![5.0, 6.0, 7.0, 8.0]))]; + if merged { + f.push((tag(b"BrLf"), F::Bool(true))); + } + F::Obj(tag(b"TCel"), f) + }; + let pos = |values: &[f64]| F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", values))]); + let edges = |n: usize| F::Obj(tag(b"TEds"), vec![(tag(b"Edge"), F::Shared((0..n).map(|_| table_edge()).collect()))]); + let table = F::Obj( + tag(b"Tabl"), + vec![ + (tag(b"CPos"), pos(&[0.0, 100.0, 200.0, 300.0])), + (tag(b"RPos"), pos(&[0.0, 50.0, 100.0])), + (tag(b"CEdg"), edges(8)), + (tag(b"REdg"), edges(9)), + (tag(b"Cell"), F::Obj(tag(b"TCls"), vec![(tag(b"Cell"), F::Shared((0..6).map(|i| cell(i == 4)).collect()))])), + ], + ); + F::Def( + id, + vec![tag(b"TxtT")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![story]))])), + (tag(b"TxtH"), F::Obj(tag(b"TbFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 300.0, 100.0])), (tag(b"Tabl"), table)])), + (tag(b"Xfrm"), F::F64s(transform.to_vec())), + ], + ) +} + +/// Character attributes without their own fixed leading. +fn auto_leading(mut attrs: F) -> F { + if let F::Obj(_, fields) = &mut attrs { + for (t, v) in fields.iter_mut() { + if *t == tag(b"Ints") { + *v = array_i32(b"Ints", &[0, 0, 0, 0, 0]); + } + } + } + attrs +} + +fn open(name: &str, bytes: &[u8]) -> Loaded { + let loaded = load(name, bytes).unwrap(); + assert!(!loaded.preview_only, "{:?}", loaded.warnings); + loaded +} + +/// Every node of the document, depth first. +fn all(d: &Document) -> Vec<&Node> { + fn walk<'a>(nodes: &'a [Arc], out: &mut Vec<&'a Node>) { + for n in nodes { + out.push(n); + if let Some(c) = n.children() { + walk(c, out); + } + } + } + let mut out = Vec::new(); + walk(&d.layers, &mut out); + out +} + +fn named<'a>(d: &'a Document, name: &str) -> &'a Node { + all(d).into_iter().find(|n| n.name.as_deref() == Some(name)).unwrap_or_else(|| panic!("no node {name}")) +} + +fn texts(d: &Document) -> Vec<&TextObject> { + all(d).into_iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect() +} + +fn content(t: &TextObject) -> String { + t.runs.iter().map(|r| r.text.as_str()).collect() +} + +/// An area text's frame in document space. +fn frame(t: &TextObject) -> Rect { + let TextKind::Area { frame } = &t.kind else { panic!("area type: {:?}", t.kind) }; + t.xf.transform_rect_bbox(frame.bounds().unwrap()) +} + +fn rect_near(a: Rect, b: Rect) -> bool { + near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1) +} + +fn layer_names(d: &Document) -> Vec<&str> { + d.layers.iter().map(|l| l.name.as_deref().unwrap_or("")).collect() +} + +#[test] +fn publisher_pages_become_artboards_in_points() { + let d = open("brochure.afpub", &publisher()).doc; + let boards: Vec<_> = d.artboards.iter().map(|b| (b.name.as_str(), b.rect)).collect(); + assert_eq!(boards.len(), 2); + assert_eq!((boards[0].0, boards[1].0), ("Page 1", "Page 2")); + assert!(rect_near(boards[0].1, Rect::new(0.0, 0.0, 2480.0 * PT, 3508.0 * PT)), "{boards:?}"); + assert!(rect_near(boards[1].1, Rect::new(2480.0 * PT, 0.0, 4960.0 * PT, 3508.0 * PT)), "{boards:?}"); + assert!(layer_names(&d).contains(&"Background")); + let rect = named(&d, "Magenta box"); + assert!(rect_near(rect.geometric_bounds().unwrap(), Rect::new(100.0 * PT, 100.0 * PT, 200.0 * PT, 300.0 * PT))); + let Some(AppearanceItem::Fill(fill)) = rect.appearance.items.first() else { panic!("{:?}", rect.appearance) }; + assert_eq!(fill.paint, Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), "CMYK stays CMYK"); +} + +/// Several spreads of several pages: every page is an artboard, the spreads side by side. +#[test] +fn every_page_of_every_spread_is_an_artboard() { + let file = publisher_with(vec![ + spread(2, 1, vec![rectangle(10, (10.0, 10.0), (20.0, 20.0))]), + spread(3, 2, vec![rectangle(20, (2500.0, 10.0), (20.0, 20.0))]), + spread(4, 2, vec![]), + ]); + let d = open("book.afpub", &file).doc; + let names: Vec<_> = d.artboards.iter().map(|b| b.name.as_str()).collect(); + assert_eq!(names, ["Page 1", "Page 2", "Page 3", "Page 4", "Page 5"]); + assert_eq!(d.artboards.iter().map(|b| b.id).collect::>(), [1, 2, 3, 4, 5]); + let (w, h) = (2480.0 * PT, 3508.0 * PT); + for b in &d.artboards { + assert!(near(b.rect.width(), w) && near(b.rect.height(), h), "{b:?}"); + } + // The two pages of a spread touch; spreads are 36 pt apart. + let x: Vec = d.artboards.iter().map(|b| b.rect.x0).collect(); + assert!(near(x[1], w + 36.0) && near(x[2], 2.0 * w + 36.0) && near(x[3], 3.0 * w + 72.0), "{x:?}"); + // The second spread's box is on its second page. + let boxes: Vec = all(&d).into_iter().filter(|n| n.name.as_deref() == Some("Magenta box")).map(|n| n.geometric_bounds().unwrap()).collect(); + assert_eq!(boxes.len(), 2); + assert!(d.artboards[2].rect.contains(boxes[1].center()), "{boxes:?} {:?}", d.artboards[2]); + let reopened = vectorcraft_format::load_file(&vectorcraft_format::save_file(&d)).unwrap(); + assert_eq!(reopened.doc.artboards, d.artboards); +} + +/// Artboards on one canvas keep their names, sizes, places and own content; objects beside them +/// stay on the canvas. +#[test] +fn several_artboards_keep_their_own_content() { + let file = canvas_with( + vec![ + board(10, "Icon", (0.0, 0.0), (64.0, 64.0), vec![named_rect(11, "Icon art", (8.0, 8.0), (48.0, 48.0))]), + named_rect(20, "Loose", (80.0, 0.0), (10.0, 10.0)), + board(30, "Banner", (100.0, 0.0), (300.0, 100.0), vec![named_rect(31, "Banner art", (110.0, 10.0), (280.0, 80.0))]), + board(40, "Card", (0.0, 200.0), (85.0, 55.0), vec![]), + ], + None, + ); + let d = open("set.af", &file).doc; + let boards: Vec<_> = d.artboards.iter().map(|b| (b.name.as_str(), b.rect)).collect(); + assert_eq!( + boards, + [("Icon", Rect::new(0.0, 0.0, 64.0, 64.0)), ("Banner", Rect::new(100.0, 0.0, 400.0, 100.0)), ("Card", Rect::new(0.0, 200.0, 85.0, 255.0))] + ); + // Each artboard is a layer holding its clip group, its art inside. + for (board, art) in [("Icon", "Icon art"), ("Banner", "Banner art")] { + let layer = d.layers.iter().find(|l| l.name.as_deref() == Some(board)).unwrap(); + let group = &layer.children().unwrap()[0]; + assert!(matches!(group.kind, NodeKind::Group { clip: true, .. })); + assert!(group.children().unwrap().iter().any(|c| c.name.as_deref() == Some(art)), "{board}"); + } + assert!(rect_near(named(&d, "Loose").geometric_bounds().unwrap(), Rect::new(80.0, 0.0, 90.0, 10.0))); +} + +#[test] +fn artistic_text_keeps_its_paragraphs_size_and_leading() { + let d = open("brochure.afpub", &publisher()).doc; + let t = texts(&d)[0]; + assert_eq!(content(t), "Hello\nWorld", "paragraph marks become paragraphs; the final one goes"); + assert!(near(t.runs[0].style.size, 100.0 * PT)); + assert_eq!(t.runs[0].style.font_style, "Bold"); + assert_eq!(t.para.justify, vectorcraft_doc::text::Justify::Center); + assert!(matches!(t.kind, TextKind::Point)); + // Centred on the anchor (x = 1000 + 200), first baseline 80 px below the frame top (y = 500). + let origin = t.xf * kurbo::Point::ZERO; + assert!(near(origin.x, 1200.0 * PT) && near(origin.y, 580.0 * PT), "{origin:?}"); + // Lines are the font size plus the font's line gap apart (VectorCraft's default is 120 %). + let face = vectorcraft_text::FontDb::global().face(&t.runs[0].style.font_family, &t.runs[0].style.font_style).unwrap(); + let expected = 100.0 * (1.0 + face.line_gap() / face.units_per_em()); + assert!(near(t.para.justification.auto_leading, expected), "{} vs {expected}", t.para.justification.auto_leading); +} + +#[test] +fn tables_become_cell_text_and_edge_lines() { + // Stretched to twice its width by its transform: the grid follows, the type doesn't. + let loaded = open("table.afpub", &page_with(table_node(30, [2.0, 0.0, 400.0, 0.0, 1.0, 600.0]))); + let d = &loaded.doc; + let table = named(d, "Table"); + let cells: Vec<&TextObject> = + table.children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect(); + assert_eq!(cells.iter().map(|t| content(t)).collect::>(), ["A", "B", "C", "D", "E"]); + let a = cells[0]; + assert!(near(a.runs[0].style.size, 40.0 * PT), "type keeps its size"); + assert!(near(a.runs[0].style.h_scale, 100.0), "type isn't stretched with the grid"); + assert_eq!(a.area.vertical_align, VerticalAlign::Center); + // Insets (5, 6, 7, 8 px) inside the cell, scaled with the grid; the text centred in them as + // Affinity centres it. + let f = frame(a); + assert!(near(f.x0, (400.0 + 10.0) * PT) && near(f.width(), 176.0 * PT), "{f:?}"); + let (top, last) = cap_to_last_baseline(a); + assert!(((top + last) / 2.0 - (600.0 + 24.0) * PT).abs() < 1e-6, "{top} {last}"); + // The merged cell spans two columns. + assert!(near(frame(cells[3]).width(), (400.0 - 24.0) * PT), "{:?}", frame(cells[3])); + // 8 vertical edges less the one inside the merged cell, and 9 horizontal ones. + let lines: Vec<&Arc> = table.children().unwrap().iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect(); + assert_eq!(lines.len(), 16); + let Some(AppearanceItem::Stroke(s)) = lines[0].appearance.items.first() else { panic!() }; + assert_eq!(s.paint, Paint::solid(Color::cmyk(1.0, 0.5, 0.0, 0.0))); + assert!(s.width > 0.0); +} + +/// Where a cell's text goes in the document: its first line's capitals' tops and its last +/// baseline (y). +fn cap_to_last_baseline(t: &TextObject) -> (f64, f64) { + let db = vectorcraft_text::FontDb::global(); + // Hung from the frame's top by its cap height, the first baseline is that far down. + let mut top = t.clone(); + top.area.vertical_align = VerticalAlign::Top; + let cap = vectorcraft_text::layout(db, &top).lines[0].baseline; + let lines = vectorcraft_text::layout(db, t).lines; + let y = t.xf.translation().y; + (y + lines[0].baseline - cap, y + lines.last().unwrap().baseline) +} + +#[test] +fn cell_text_taller_than_its_cell_is_centred_on_it_and_shows() { + // "A" over "a" (a line break inside the paragraph) in a cell 36 px tall inside its insets. + let loaded = open("cells.afpub", &page_with(table_with(30, [1.0, 0.0, 400.0, 0.0, 1.0, 600.0], ["A\u{2028}a", "B", "C", "D", "E"]))); + assert!(!loaded.warnings.iter().any(|w| w.contains("don't fit")), "{:?}", loaded.warnings); + let a = texts(&loaded.doc)[0]; + assert_eq!(content(a), "A\na"); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), a); + assert_eq!(lay.lines.len(), 2, "both lines show"); + assert!(!lay.overflow); + assert!(frame(a).height() > 36.0 * PT, "the frame grew: {:?}", frame(a)); + let (top, last) = cap_to_last_baseline(a); + assert!(top < (600.0 + 6.0) * PT, "reaching out of the cell"); + assert!(((top + last) / 2.0 - (600.0 + 24.0) * PT).abs() < 1e-6, "centred: {top} {last}"); +} + +#[test] +fn line_breaks_inside_a_paragraph_get_no_paragraph_spacing() { + // Doub slots: 4 first line, 5 space before, 6 after (see the test below). + let attrs_para = F::Obj(tag(b"PAtt"), vec![(tag(b"Doub"), array_f64(b"Doub", &[1.0, 0.0, 0.0, 0.0, 25.0, 9.0, 17.0]))]); + let blk = F::Obj( + tag(b"StBl"), + vec![ + (tag(b"Glyp"), utf8("One\u{2028}two\u{2029}Three\u{2029}")), + ( + tag(b"GAtt"), + F::Obj( + tag(b"GlAS"), + vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(14, Some(attrs("Inter", "Inter-Regular", 400, false, 30.0)))]))], + ), + ), + ( + tag(b"PAtt"), + F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![vec![(tag(b"Indx"), F::I32(14)), (tag(b"Item"), attrs_para)]]))]), + ), + ], + ); + let node = F::Def( + 40, + vec![tag(b"TxtF")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])), + (tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, 300.0]))])), + ], + ); + let t = texts(&open("breaks.afpub", &page_with(node)).doc)[0].clone(); + assert_eq!(content(&t), "One\ntwo\nThree"); + let p = &t.paras; + let spacing: Vec<(f64, f64, f64)> = p.iter().map(|p| (p.space_before / PT, p.space_after / PT, p.first_line_indent / PT)).collect(); + let r = |v: (f64, f64, f64)| ((v.0 * 1e6).round() / 1e6, (v.1 * 1e6).round() / 1e6, (v.2 * 1e6).round() / 1e6); + assert_eq!( + spacing.into_iter().map(r).collect::>(), + [(9.0, 0.0, 25.0), (0.0, 17.0, 0.0), (0.0, 17.0, 25.0)], + "space before the paragraph and after it, the first-line indent on its first line" + ); +} + +#[test] +fn each_paragraph_keeps_its_alignment_indents_and_spacing() { + // Doub slots: 1 leading (Ints slot 1 = 2), 2 left, 3 right, 4 first line, 5 space before, 6 after. + let para = |end: i32, align: i32| { + vec![ + (tag(b"Indx"), F::I32(end)), + ( + tag(b"Item"), + F::Obj( + tag(b"PAtt"), + vec![ + (tag(b"Doub"), array_f64(b"Doub", &[1.0, 5.0, 40.0, 3.0, 25.0, 9.0, 17.0])), + (tag(b"Ints"), array_i32(b"Ints", &[align, 2])), + ], + ), + ), + ] + }; + let blk = F::Obj( + tag(b"StBl"), + vec![ + (tag(b"Glyp"), utf8("One\u{2029}Two\u{2029}")), + ( + tag(b"GAtt"), + F::Obj( + tag(b"GlAS"), + vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(8, Some(auto_leading(attrs("Inter", "Inter-Regular", 400, false, 30.0))))]))], + ), + ), + (tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![para(4, 0), para(8, 2)]))])), + ], + ); + let node = F::Def( + 40, + vec![tag(b"TxtF")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])), + (tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, 300.0]))])), + ], + ); + let loaded = open("paragraphs.afpub", &page_with(node)); + assert!(!loaded.warnings.iter().any(|w| w.contains("paragraph")), "{:?}", loaded.warnings); + let t = texts(&loaded.doc)[0]; + assert_eq!(content(t), "One\nTwo"); + assert_eq!(t.paras.len(), 2, "{:?}", t.paras); + let p = &t.paras; + use vectorcraft_doc::text::Justify; + assert_eq!((p[0].justify, p[1].justify), (Justify::Left, Justify::Right)); + assert!(near(p[0].left_indent, 40.0 * PT) && near(p[0].first_line_indent, -15.0 * PT) && near(p[0].right_indent, 3.0 * PT), "{:?}", p[0]); + assert!(near(p[0].space_before, 9.0 * PT) && near(p[0].space_after, 17.0 * PT)); + // The larger of 17 after "One" and 9 before "Two": nothing more before "Two". + assert!(near(p[1].space_before, 0.0) && near(p[1].space_after, 17.0 * PT)); + assert!(t.runs.iter().all(|r| r.style.leading.is_some_and(|l| near(l, 5.0 * PT))), "the paragraph's fixed leading"); +} + +#[test] +fn frame_type_follows_the_frame_text_scale_not_the_frame_transform() { + // The same frame scaled 3× by its transform, without and with a text scale (FTxS) of 3. + let frame_node = |ftxs: bool| { + let story = block(utf8("Hi"), vec![run(2, Some(attrs("Inter", "Inter-Regular", 400, false, 10.0)))], &[]); + let mut fields = vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![story]))])), + (tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 50.0]))])), + (tag(b"Xfrm"), F::F64s(vec![3.0, 0.0, 0.0, 0.0, 3.0, 0.0])), + ]; + if ftxs { + fields.push((tag(b"FTxS"), F::F64s(vec![3.0, 0.0, 0.0, 0.0, 3.0, 0.0]))); + } + F::Def(40, vec![tag(b"TxtF")], fields) + }; + for (ftxs, size) in [(false, 10.0), (true, 30.0)] { + let d = open("frame.afpub", &page_with(frame_node(ftxs))).doc; + let t = texts(&d)[0]; + assert!(near(frame(t).width(), 300.0 * PT), "the frame scales either way: {:?}", frame(t)); + assert!(near(t.runs[0].style.size, size * PT), "FTxS {ftxs}: {}", t.runs[0].style.size); + assert!(near(t.xf.determinant(), 1.0), "the type isn't scaled by the placement"); + } +} + +#[test] +fn embedded_affinity_documents_come_in_as_their_content() { + let inner = canvas_with(vec![rectangle(10, (0.0, 0.0), (100.0, 50.0))], None); + let outer = canvas_with(vec![embedded_node(20)], Some(&inner)); + let loaded = open("logo.af", &outer); + let rect = named(&loaded.doc, "Magenta box"); + // Content bounds (0,0)-(100,50), centre (50,25) → origin, then 2× at (500,300). + assert!(rect_near(rect.geometric_bounds().unwrap(), Rect::new(400.0, 250.0, 600.0, 350.0)), "{:?}", rect.geometric_bounds()); + assert!(!loaded.warnings.iter().any(|w| w.contains("cached picture")), "{:?}", loaded.warnings); +} + +/// An embedded document's artboard frames its content but is no artboard of the document it is in. +#[test] +fn artboards_of_embedded_documents_are_not_artboards_here() { + let inner = canvas_with(vec![board(10, "Logo board", (0.0, 0.0), (100.0, 50.0), vec![rectangle(11, (10.0, 10.0), (80.0, 30.0))])], None); + let outer = canvas_with(vec![board(30, "Poster", (0.0, 0.0), (1000.0, 800.0), vec![embedded_node(20)])], Some(&inner)); + let d = open("poster.af", &outer).doc; + assert_eq!(d.artboards.iter().map(|b| b.name.as_str()).collect::>(), ["Poster"]); + // The logo still comes in, clipped by its own board. + let logo = named(&d, "Logo board"); + assert!(matches!(logo.kind, NodeKind::Group { clip: true, .. })); + assert!(rect_near(named(&d, "Magenta box").geometric_bounds().unwrap(), Rect::new(420.0, 270.0, 580.0, 330.0))); +} + +#[test] +fn embedded_documents_nest_only_so_deep() { + let mut doc = canvas_with(vec![rectangle(10, (0.0, 0.0), (100.0, 50.0))], None); + for _ in 0..6 { + doc = canvas_with(vec![embedded_node(20)], Some(&doc)); + } + let loaded = open("deep.af", &doc); + assert!(loaded.warnings.iter().any(|w| w.contains("nested more than four deep")), "{:?}", loaded.warnings); +} + +/// A two-page master spread (pages 0–100 and 100–200 wide) with a box on each page; the +/// document's page shows its second page, as Affinity's instance says (MPOf 1). +fn with_master(pages: usize) -> Vec { + let master = F::Def( + 7, + vec![tag(b"Sprd")], + vec![ + (tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared(vec![page(0.0, 100.0, 150.0), page(100.0, 100.0, 150.0)]))])), + ( + tag(b"Chld"), + F::Shared(vec![ + named_rect(30, "Left master box", (10.0, 10.0), (20.0, 20.0)), + named_rect(31, "Right master box", (110.0, 10.0), (30.0, 40.0)), + ]), + ), + ], + ); + let instance = F::Def( + 5, + vec![tag(b"MPIN")], + vec![ + (tag(b"SLnk"), F::Obj(tag(b"ILSN"), vec![(tag(b"ILOb"), F::Shared(vec![master, F::Ref(5)]))])), + (tag(b"PgOf"), F::U32(0)), + (tag(b"MPOf"), F::U32(1)), + (tag(b"PgCt"), F::U32(pages as u32)), + ], + ); + let spread = F::Def( + 2, + vec![tag(b"Sprd")], + vec![ + (tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(100.0 * i as f64, 100.0, 150.0)).collect()))])), + (tag(b"Chld"), F::Shared(vec![instance, named_rect(40, "Page box", (50.0, 100.0), (10.0, 10.0))])), + ], + ); + let data = stream(vec![( + tag(b"DocR"), + F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread])), (tag(b"MpCh"), F::Shared(vec![F::Ref(7)]))]), + )]); + synth::container(&[("doc.dat", &data, Method::Zstd)], None) +} + +#[test] +fn master_pages_show_on_the_pages_that_use_them() { + let loaded = open("master.afpub", &with_master(1)); + let d = &loaded.doc; + assert!(!loaded.warnings.iter().any(|w| w.contains("master")), "{:?}", loaded.warnings); + assert_eq!(d.artboards.len(), 1, "a master spread is no artboard"); + // Behind the page's own objects, on a locked layer of its own. + let master = &d.layers[0]; + assert_eq!(master.name.as_deref(), Some("Master A")); + assert!(master.locked); + // Only the second master page's box, moved onto the page. + assert!(all(d).iter().all(|n| n.name.as_deref() != Some("Left master box"))); + let b = named(d, "Right master box").geometric_bounds().unwrap(); + assert!(rect_near(b, Rect::new(10.0, 10.0, 40.0, 50.0)), "{b:?}"); + assert!(named(d, "Page box").geometric_bounds().is_some()); +} + +#[test] +fn a_master_page_shows_on_every_page_it_covers() { + // Two pages show master pages 2 and 3 (the master spread has two: the last repeats). + let d = open("master2.afpub", &with_master(2)).doc; + assert_eq!(d.artboards.len(), 2); + let boxes: Vec = + all(&d).into_iter().filter(|n| n.name.as_deref() == Some("Right master box")).map(|n| n.geometric_bounds().unwrap()).collect(); + assert_eq!(boxes.len(), 2, "{boxes:?}"); + for (b, board) in boxes.iter().zip(&d.artboards) { + assert!(board.rect.contains(b.center()), "{b:?} on {:?}", board.rect); + } +} + +fn pixel(d: &Document, x: usize, y: usize) -> [u8; 4] { + let img = vectorcraft_render::Renderer::new().render_region(d, d.artboards[0].rect, 1.0, false); + img.to_straight()[(y * img.width as usize + x) * 4..][..4].try_into().unwrap() +} + +fn with_effects(mut node: F, effects: Vec) -> F { + if let F::Def(_, _, fields) = &mut node { + fields.push((tag(b"FiEf"), F::Shared(effects))); + } + node +} + +#[test] +fn shadows_go_where_affinity_puts_them() { + // A blue shadow 10 px straight below (angle π/2), blurred 2 px, at half opacity. + let shadow = F::Obj( + tag(b"Shad"), + vec![ + (tag(b"Enab"), F::Bool(true)), + (tag(b"Opac"), F::F64(0.5)), + (tag(b"Radi"), F::F64(2.0)), + (tag(b"Offs"), F::F64(10.0)), + (tag(b"Angl"), F::F64(std::f64::consts::FRAC_PI_2)), + (tag(b"Colr"), rgba(0.0, 0.0, 1.0)), + ], + ); + let bevel = F::Obj(tag(b"BevE"), vec![(tag(b"Enab"), F::Bool(true))]); + let loaded = open("shadow.af", &canvas_with(vec![with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![shadow, bevel])], None)); + let d = &loaded.doc; + let rect = named(d, "Magenta box"); + let fx = &rect.appearance.effects; + assert_eq!(fx.len(), 1, "{fx:?}"); + assert_eq!(fx[0].id, "stylize.dropShadow"); + let p = &fx[0].params; + assert!(near(p["x"].as_f64().unwrap(), 0.0) && near(p["y"].as_f64().unwrap(), 10.0), "{p}"); + assert_eq!((p["blur"].as_f64(), p["opacity"].as_f64()), (Some(2.0), Some(50.0))); + // A bevel without a radius draws nothing, in Affinity as here. + assert!(!loaded.warnings.iter().any(|w| w.contains("bevel")), "{:?}", loaded.warnings); + // Drawn: blue at half strength below the box, the box itself magenta. + let [r, g, b, a] = pixel(d, 25, 57); + assert!(r < 10 && g < 10 && b > 245 && (100..160).contains(&a), "{:?}", [r, g, b, a]); + assert_eq!(pixel(d, 25, 25)[3], 255); +} + +#[test] +fn glows_inner_shadows_and_bevels_are_kept_or_reported() { + let effect = |class: &[u8; 4], radius: f64| { + F::Obj( + tag(class), + vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(0.8)), (tag(b"Radi"), F::F64(radius)), (tag(b"Colr"), rgba(1.0, 1.0, 0.0))], + ) + }; + let node = with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![effect(b"OutG", 6.0), effect(b"InnS", 3.0), effect(b"BevE", 4.0)]); + let loaded = open("glow.af", &canvas_with(vec![node], None)); + let fx = &named(&loaded.doc, "Magenta box").appearance.effects; + assert_eq!(fx.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.outerGlow", "stylize.innerGlow"]); + assert_eq!((fx[0].params["blur"].as_f64(), fx[0].params["opacity"].as_f64()), (Some(6.0), Some(80.0))); + for w in ["inner shadows (imported as inner glows", "bevel and emboss effects (left out)"] { + assert!(loaded.warnings.iter().any(|x| x.contains(w)), "{w}: {:?}", loaded.warnings); + } +} + +#[test] +fn outline_effects_become_strokes() { + let outline = |align: u16| { + F::Obj( + tag(b"Strk"), + vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Radi"), F::F64(3.0)), (tag(b"Alig"), F::Enum(align, 0)), (tag(b"Colr"), rgba(1.0, 0.0, 0.0))], + ) + }; + let shape = with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![outline(0)]); + let text = with_effects(artistic_text(20, "Hi", 40.0), vec![outline(2)]); + let d = open("outline.af", &canvas_with(vec![shape, text], None)).doc; + let rect = named(&d, "Magenta box"); + let Some(AppearanceItem::Stroke(s)) = rect.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!() }; + assert_eq!((s.width, s.align), (3.0, StrokeAlign::Outside)); + assert_eq!(s.paint, Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let t = texts(&d)[0]; + assert_eq!((t.runs[0].style.stroke.clone(), t.runs[0].style.stroke_width), (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 3.0)); +} + +#[test] +fn colour_overlays_are_baked_into_the_paint() { + let overlay = F::Obj(tag(b"ColO"), vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(1.0)), (tag(b"Colr"), rgba(0.0, 1.0, 0.0))]); + let d = open("overlay.af", &canvas_with(vec![with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![overlay])], None)).doc; + let rect = named(&d, "Magenta box"); + assert!(rect.appearance.effects.is_empty()); + let [r, g, b, a] = pixel(&d, 25, 25); + assert!(r < 5 && g > 250 && b < 5 && a == 255, "{:?}", [r, g, b, a]); +} + +#[test] +fn outline_effects_on_stroked_shapes_go_around_their_stroke() { + let outline = |align: u16, width: f64| { + F::Obj( + tag(b"Strk"), + vec![ + (tag(b"Enab"), F::Bool(true)), + (tag(b"Radi"), F::F64(width)), + (tag(b"Alig"), F::Enum(align, 0)), + (tag(b"Colr"), rgba(1.0, 0.0, 0.0)), + ], + ) + }; + // A black stroke 2 px wide, centred on the path: it reaches 1 px out of the shape. + let stroked = |id: u32| { + let mut node = rectangle(id, (0.0, 0.0), (50.0, 50.0)); + if let F::Def(_, _, fields) = &mut node { + fields.push((tag(b"PFil"), cmyk(0.0, 0.0, 0.0, 1.0))); + fields.push((tag(b"LSty"), F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]))); + } + node + }; + let loaded = open("outline.af", &canvas_with(vec![with_effects(stroked(10), vec![outline(2, 4.0), outline(0, 3.0)])], None)); + assert!(!loaded.warnings.iter().any(|w| w.contains("outline")), "{:?}", loaded.warnings); + let strokes: Vec<(f64, StrokeAlign, Paint)> = named(&loaded.doc, "Magenta box") + .appearance + .items + .iter() + .filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some((s.width, s.align, s.paint.clone())) } else { None }) + .collect(); + let (red, black) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0))); + assert_eq!( + strokes, + [ + // Outside: from the path to 3 px past the stroke, under it. + (4.0, StrokeAlign::Outside, red.clone()), + (2.0, StrokeAlign::Center, black), + // Centred on the stroke's outer edge, over it (as far in as out). + (6.0, StrokeAlign::Center, red), + ] + ); +} + +/// Artistic text "Wide line" over "Mid", the paragraphs aligned `first` and `second` +/// (0 left, 1 centre, 2 right). +fn mixed_alignment(first: i32, second: i32) -> F { + let para = |end: i32, align: i32| { + vec![(tag(b"Indx"), F::I32(end)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[align]))]))] + }; + let blk = F::Obj( + tag(b"StBl"), + vec![ + (tag(b"Glyp"), utf8("Wide line\u{2029}Mid\u{2029}")), + ( + tag(b"GAtt"), + F::Obj( + tag(b"GlAS"), + vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(14, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))]))], + ), + ), + (tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![para(10, first), para(14, second)]))])), + ], + ); + F::Def( + 50, + vec![tag(b"TxtA")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])), + (tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])), + (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 100.0, 0.0, 1.0, 100.0])), + ], + ) +} + +#[test] +fn artistic_text_aligns_its_lines_within_the_longest() { + // (first, second alignment) → where "Mid" goes in the span of "Wide line" (0 its left end, + // 0.5 centred, 1 its right end). + for (first, second, at) in [(0, 1, 0.5), (0, 2, 1.0), (1, 0, 0.0), (2, 1, 0.5), (1, 2, 1.0)] { + let d = open("mixed.af", &canvas_with(vec![mixed_alignment(first, second)], None)).doc; + let t = texts(&d)[0]; + assert!(matches!(t.kind, TextKind::Point)); + let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines; + let (wide, mid) = (&lines[0], &lines[1]); + let w = wide.x1 - wide.x0; + assert!(w > 0.0); + // The block stays where the first paragraph's alignment puts it on the anchor. + let start = [0.0, -0.5, -1.0][first as usize] * w; + assert!((wide.x0 - start).abs() < 1e-6, "{first}/{second}: {} vs {start}", wide.x0); + let expected = wide.x0 + (w - (mid.x1 - mid.x0)) * at; + assert!((mid.x0 - expected).abs() < 1e-6, "{first}/{second}: {} vs {expected}", mid.x0); + // Each paragraph keeps its own alignment. + use vectorcraft_doc::text::Justify; + let j = |a: i32| [Justify::Left, Justify::Center, Justify::Right][a as usize]; + assert_eq!((t.paras[0].justify, t.paras[1].justify), (j(first), j(second))); + } + // One alignment throughout: no indents. + let d = open("same.af", &canvas_with(vec![mixed_alignment(1, 1)], None)).doc; + assert!(texts(&d)[0].paras.iter().all(|p| p.left_indent == 0.0 && p.right_indent == 0.0)); +} + +/// Frame text of two 100 px lines in a frame `height` tall. +fn two_line_frame(height: f64) -> F { + let blk = block(utf8("One\u{2029}Two\u{2029}"), vec![run(8, Some(auto_leading(attrs("Inter", "Inter-Regular", 400, false, 100.0))))], &[0]); + F::Def( + 60, + vec![tag(b"TxtF")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])), + (tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, height]))])), + ], + ) +} + +#[test] +fn frame_text_hangs_from_cap_height_and_shows_lines_whose_baseline_fits() { + use vectorcraft_doc::text::FirstBaseline; + let db = vectorcraft_text::FontDb::global(); + // Room for both lines: where they go. + let loaded = open("tall.af", &canvas_with(vec![two_line_frame(1000.0)], None)); + let t = texts(&loaded.doc)[0]; + assert_eq!(t.area.first_baseline, FirstBaseline::CapHeight); + let lines = vectorcraft_text::layout(db, t).lines; + assert_eq!(lines.len(), 2); + // Higher than VectorCraft's default, the font's ascent. + let ascent = vectorcraft_text::layout(db, &TextObject { area: Default::default(), ..t.clone() }).lines[0].baseline; + assert!(lines[0].baseline < ascent - 1.0, "first baseline {} vs ascent {ascent}", lines[0].baseline); + let (baseline, bottom) = (lines[1].baseline, lines[1].baseline + lines[1].descent); + // A frame the second line's baseline fits in but not its descenders: Affinity shows the line. + let height = (baseline + bottom) / 2.0; + let loaded = open("short.af", &canvas_with(vec![two_line_frame(height)], None)); + assert!(loaded.warnings.iter().any(|w| w.contains("made taller so the lines show")), "{:?}", loaded.warnings); + let t = texts(&loaded.doc)[0]; + let lay = vectorcraft_text::layout(db, t); + assert_eq!(lay.lines.len(), 2); + assert!(!lay.overflow); + assert!(frame(t).height() > height && frame(t).height() < bottom + 1.0, "{:?}", frame(t)); + // One whose second baseline is outside it too: that line stays out, as in Affinity. + let loaded = open("shorter.af", &canvas_with(vec![two_line_frame(baseline - 5.0)], None)); + let t = texts(&loaded.doc)[0]; + assert_eq!(vectorcraft_text::layout(db, t).lines.len(), 1); + assert!((frame(t).height() - (baseline - 5.0)).abs() < 1e-6); +} + +#[test] +fn underlined_and_struck_through_characters_stay_so() { + let decorated = |ints: &[i32]| { + let mut a = attrs("Inter", "Inter-Regular", 400, false, 40.0); + if let F::Obj(_, fields) = &mut a { + for (t, v) in fields.iter_mut() { + if *t == tag(b"Ints") { + *v = array_i32(b"Ints", ints); + } + } + } + a + }; + let blk = block( + utf8("Plain under struck"), + vec![ + run(6, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0))), + run(12, Some(decorated(&[1, 0, 0, 0, 1]))), + run(18, Some(decorated(&[0, 1, 0, 0, 1]))), + ], + &[0], + ); + let node = F::Def( + 70, + vec![tag(b"TxtA")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])), + (tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])), + ], + ); + let loaded = open("underline.af", &canvas_with(vec![node], None)); + assert!(!loaded.warnings.iter().any(|w| w.contains("decoration")), "{:?}", loaded.warnings); + let t = texts(&loaded.doc)[0]; + assert_eq!( + t.runs.iter().map(|r| (r.text.as_str(), r.style.underline, r.style.strikethrough)).collect::>(), + [("Plain ", false, false), ("under ", true, false), ("struck", false, true)] + ); +} + +/// A 16 × 16 CMYK pixel layer of pure cyan, opaque or (`transparent`) with no alpha, in a +/// document that embeds `profile` as its CMYK profile. +fn cmyk_layer(transparent: bool, profile: Option<&[u8]>) -> Vec { + let mut bitmap = vec![(tag(b"Frmt"), F::Enum(4, 0)), (tag(b"BmpW"), F::I32(16)), (tag(b"BmpH"), F::I32(16))]; + for c in 1..=5 { + // Tile state 2: every sample 255; 0: every sample 0. + let full = c == 1 || (c == 5 && !transparent); + bitmap.push((tag(&[b'T', b'W', b'i', b'0' + c]), F::I32(1))); + bitmap.push((tag(&[b'T', b'H', b'i', b'0' + c]), F::I32(1))); + bitmap.push((tag(&[b'S', b't', b'a', b'0' + c]), F::U8s(vec![if full { 2 } else { 0 }]))); + } + let layer = F::Def(10, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)), (tag(b"Desc"), F::Str("Photo".into()))]); + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 16.0, 16.0])), (tag(b"Chld"), F::Shared(vec![layer]))]); + let mut root = vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]; + if let Some(icc) = profile { + let mut blob = vec![0x2d]; + blob.extend(tag(b"Data").0.to_le_bytes()); + blob.extend((icc.len() as u32).to_le_bytes()); + blob.extend_from_slice(icc); + root.push((tag(b"CMYP"), F::Obj(tag(b"ICCP"), vec![(tag(b"Data"), F::Raw(blob))]))); + } + synth::container(&[("doc.dat", &stream(root), Method::Zstd)], None) +} + +/// The image of the node named "Photo", and the blob it shows. +fn photo(d: &Document) -> &vectorcraft_doc::ImageBlob { + let NodeKind::Image(img) = &named(d, "Photo").kind else { panic!("an image") }; + &d.images[&img.key] +} + +#[test] +fn cmyk_pixel_layers_show_through_the_documents_cmyk_profile() { + use vectorcraft_color::cms::{self, IccProfile, Intent}; + // A CMYK profile written from a built-in space, embedded as the document's. + let icc = cms::icc_bytes(cms::GENERIC_CMYK).unwrap(); + let loaded = open("photo.af", &cmyk_layer(false, Some(&icc))); + let blob = photo(&loaded.doc); + assert!(!blob.is_cmyk()); + let px = image::load_from_memory(&blob.bytes).unwrap().to_rgba8(); + let profile = IccProfile::from_bytes(None, &icc).unwrap(); + let srgb = cms::icc::builtin_rgb(cms::SRGB).unwrap(); + let want = profile.to_rgb_of(&srgb, &[1.0, 0.0, 0.0, 0.0], Intent::RelativeColorimetric, true).unwrap().map(|v| (v * 255.0).round() as i32); + let got = px.get_pixel(8, 8).0; + assert!((0..3).all(|i| (i32::from(got[i]) - want[i]).abs() <= 2) && got[3] == 255, "{got:?} vs {want:?}"); + // Not the profile-free formula's pure cyan. + assert_ne!(&got[..3], &[0, 255, 255]); + assert!(!loaded.warnings.iter().any(|w| w.contains("CMYK")), "{:?}", loaded.warnings); +} + +#[test] +fn cmyk_pixel_layers_without_a_profile_keep_their_inks_or_use_the_working_profile() { + // Opaque: a CMYK image with the layer's inks. + let loaded = open("photo.af", &cmyk_layer(false, None)); + let inks = photo(&loaded.doc).cmyk().expect("a CMYK image"); + assert_eq!((inks.width, inks.height, &inks.data[..4]), (16, 16, &[255, 0, 0, 0][..])); + // With transparency: RGBA through the working CMYK profile, and said. + let loaded = open("photo.af", &cmyk_layer(true, None)); + let blob = photo(&loaded.doc); + assert!(!blob.is_cmyk()); + assert_eq!(image::load_from_memory(&blob.bytes).unwrap().to_rgba8().get_pixel(8, 8).0[3], 0); + assert!(loaded.warnings.iter().any(|w| w.contains("CMYK pixel layers with transparency")), "{:?}", loaded.warnings); +} + +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(128))] + #[test] + fn mutated_documents_never_panic(edits in proptest::collection::vec((0usize..4096, proptest::prelude::any::()), 1..16)) { + // The stream is mutated before it is packed, so edits reach the object graph instead of + // failing the archive's checksum. + let mut data = { + let shadow = F::Obj(tag(b"Shad"), vec![(tag(b"Radi"), F::F64(2.0)), (tag(b"Offs"), F::F64(3.0)), (tag(b"Colr"), rgba(0.0, 0.0, 0.0))]); + let nodes = vec![ + with_effects(rectangle(10, (1.0, 2.0), (3.0, 4.0)), vec![shadow]), + artistic_text(20, "Hi\u{2029}there", 40.0), + table_node(30, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]), + ]; + stream(vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread(2, 2, nodes)]))]))]) + }; + let n = data.len(); + for (at, v) in edits { + if let Some(b) = data.get_mut(at % n) { + *b = v; + } + } + let file = synth::container(&[("doc.dat", &data, Method::Stored)], None); + if let Ok(loaded) = load("mutated.af", &file) + && let Some(board) = loaded.doc.artboards.first() + { + let _ = vectorcraft_render::Renderer::new().render_region(&loaded.doc, board.rect, 0.05, false); + } + } +} diff --git a/crates/engine/tests/affinity_local.rs b/crates/engine/tests/affinity_local.rs new file mode 100644 index 000000000..d3589fabf --- /dev/null +++ b/crates/engine/tests/affinity_local.rs @@ -0,0 +1,84 @@ +//! Local Affinity documents (never committed): every file under `AFFINITY_SAMPLES` (searched +//! recursively) opens as editable art, and each artboard is compared with the thumbnail Affinity +//! stored in the file. Reports the mean difference per artboard (0–255, both on white); with +//! `AFFINITY_SAMPLES_OUT` set, writes each board's thumbnail crop and render side by side there. +//! Skipped when `AFFINITY_SAMPLES` is unset (as in CI). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::{Path, PathBuf}; + +fn files(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for e in entries.flatten() { + let p = e.path(); + if p.is_dir() { + files(&p, out); + } else if p + .extension() + .and_then(|x| x.to_str()) + .is_some_and(|x| ["af", "afdesign", "afpub", "afphoto"].contains(&x.to_ascii_lowercase().as_str())) + { + out.push(p); + } + } +} + +fn on_white(p: &[u8]) -> [f64; 3] { + let a = f64::from(p[3]) / 255.0; + [0, 1, 2].map(|i| f64::from(p[i]) * a + 255.0 * (1.0 - a)) +} + +#[test] +fn local_affinity_documents_render_like_their_thumbnails() { + let Some(root) = std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from) else { return }; + let out = std::env::var_os("AFFINITY_SAMPLES_OUT").map(PathBuf::from); + let mut paths = Vec::new(); + files(&root, &mut paths); + paths.sort(); + for path in paths { + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let bytes = std::fs::read(&path).unwrap(); + let loaded = vectorcraft_engine::cmd::fileio::load(&name, &bytes).unwrap(); + assert!(!loaded.preview_only, "{name}: {:?}", loaded.warnings); + let d = &loaded.doc; + eprintln!("{name}: {} artboards, {} layers", d.artboards.len(), d.layers.len()); + for w in &loaded.warnings { + eprintln!(" {w}"); + } + let Ok(preview) = vectorcraft_affinity::preview(&bytes) else { continue }; + let thumb = image::load_from_memory(preview.png).unwrap().to_rgba8(); + let union = d.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap(); + let scale = f64::from(thumb.width()) / union.width(); + let mut total = 0.0; + for (i, board) in d.artboards.iter().enumerate() { + let r = board.rect; + let crop = |v: f64| (v * scale).round().max(0.0) as u32; + let (x, y) = (crop(r.x0 - union.x0).min(thumb.width() - 1), crop(r.y0 - union.y0).min(thumb.height() - 1)); + let (w, h) = (crop(r.width()).min(thumb.width() - x).max(1), crop(r.height()).min(thumb.height() - y).max(1)); + let expected = image::imageops::crop_imm(&thumb, x, y, w, h).to_image(); + let k = (512.0 / r.width().max(r.height())).max(scale); + let rendered = vectorcraft_render::Renderer::new().render_region(d, r, k, false); + let big = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.to_straight()).unwrap(); + let got = image::imageops::resize(&big, w, h, image::imageops::FilterType::Triangle); + let (mut sum, mut n) = (0.0, 0usize); + for (p, q) in expected.as_raw().as_chunks::<4>().0.iter().zip(got.as_raw().as_chunks::<4>().0.iter()) { + let (a, b) = (on_white(p), on_white(q)); + sum += (0..3).map(|c| (a[c] - b[c]).abs()).sum::(); + n += 3; + } + let diff = sum / n.max(1) as f64; + total += diff; + eprintln!(" board {i:3} {:<30} {diff:6.2}", board.name); + if let Some(dir) = &out { + let thumb_big = image::imageops::resize(&expected, big.width(), big.height(), image::imageops::FilterType::Triangle); + let mut pair = image::RgbaImage::from_pixel(big.width() * 2 + 8, big.height(), image::Rgba([255, 255, 255, 255])); + image::imageops::overlay(&mut pair, &thumb_big, 0, 0); + image::imageops::overlay(&mut pair, &big, i64::from(big.width()) + 8, 0); + let stem = path.file_stem().unwrap().to_string_lossy().to_string(); + std::fs::create_dir_all(dir).unwrap(); + pair.save(dir.join(format!("{stem}-{i:03}.png"))).unwrap(); + } + } + eprintln!(" mean over boards: {:.2}", total / d.artboards.len().max(1) as f64); + } +} diff --git a/crates/engine/tests/affinity_text.rs b/crates/engine/tests/affinity_text.rs index feb704813..42224bfcb 100644 --- a/crates/engine/tests/affinity_text.rs +++ b/crates/engine/tests/affinity_text.rs @@ -93,13 +93,17 @@ fn a_frame_too_short_for_its_first_line_is_made_taller_with_a_warning() { // #1423: a title taller than its frame (60 pt type in a 50 pt frame) drew nothing. let lines = |t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines.len(); let title = |size: f64| import(vec![block(utf8("T"), vec![run(1, Some(attrs("Frame", "", 400, false, size)))], &[3])], true, 144.0, IDENTITY); - let short = title(120.0); - let t = texts(&short.doc)[0]; - let TextKind::Area { frame } = &t.kind else { panic!("expected area type") }; - let b = frame.bounds().unwrap(); - assert!((b.x0, b.y0, b.width()) == (0.0, 0.0, 100.0) && b.height() > 50.0 && b.height() < 100.0, "taller from its top: {b:?}"); - assert_eq!(lines(t), 1, "the line shows"); - assert!(short.warnings.iter().any(|w| w.contains("too short for their first line")), "{:?}", short.warnings); + // Its baseline (at cap height) is inside the frame, its descenders hang out of it; and an + // 80 pt title whose capitals don't fit either. + for (size, warning, max) in [(120.0, "made taller so the lines show", 100.0), (160.0, "too short for their first line", 150.0)] { + let short = title(size); + let t = texts(&short.doc)[0]; + let TextKind::Area { frame } = &t.kind else { panic!("expected area type") }; + let b = frame.bounds().unwrap(); + assert!((b.x0, b.y0, b.width()) == (0.0, 0.0, 100.0) && b.height() > 50.0 && b.height() < max, "{size}: taller from its top: {b:?}"); + assert_eq!(lines(t), 1, "the line shows"); + assert!(short.warnings.iter().any(|w| w.contains(warning)), "{size}: {:?}", short.warnings); + } // A frame that holds its line keeps its height, and nothing is said. let fits = title(20.0); let t = texts(&fits.doc)[0]; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index f56eaa371..8be863bb6 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -216,6 +216,11 @@ impl FontFace { pub fn vertical_metrics(&self) -> (f64, f64) { (self.ascent, self.descent) } + /// The line gap the font asks for between lines, in font units (from the same table as + /// [`vertical_metrics`](Self::vertical_metrics); 0 when it has none). + pub fn line_gap(&self) -> f64 { + self.skrifa().map_or(0.0, |f| f64::from(f.metrics(Size::unscaled(), self.location()).leading)).max(0.0) + } /// Every mapped character and its glyph id, sorted by code point (the Glyphs panel). pub fn chars(&self) -> Vec<(char, u32)> { let Some(f) = self.skrifa() else { return vec![] }; diff --git a/docs/gaps.md b/docs/gaps.md index c6aa23a48..dc62b3f40 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged; interactive Mirror Repeat axis; G12: Background Export progress dialog with Cancel, #2564; no entry closed, estimates unchanged; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged; interactive Mirror Repeat axis; G12: Background Export progress dialog with Cancel, #2564; no entry closed, estimates unchanged; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -433,6 +433,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Affinity import: master pages, tables, paragraph formats, text scale, shadows, glows, outlines, overlays, underlines, strikethrough, CMYK pixel layers through the document's profile and editable embedded documents, text placed as Affinity places it (AF.5, from storytold/designcraft#291). No entry closed, estimates unchanged. | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | | 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. | | 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 96a7c6080..37d271b1e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; #3515: recent-font groups in font pickers; PDF/X-1a and PDF/X-3 flattening keeps a CMYK document's inks where it rasterizes, #2160; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; UI chrome: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth and drawn, with SVG/PDF outlines where ruby is present; scores and hours unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; G4: System Layout; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; #3515: recent-font groups in font pickers; PDF/X-1a and PDF/X-3 flattening keeps a CMYK document's inks where it rasterizes, #2160; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; UI chrome: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth and drawn, with SVG/PDF outlines where ruby is present; scores and hours unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; G4: System Layout; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; Image Trace: a higher Paths never gives fewer segments on collapsed loops; Image Trace: the notch rule reads only its own piece; Image Trace: an edge's sub-pixel shift is the same from either side; Image Trace Flat Logo traces line art on white paper instead of a blank result; score and hours unchanged; Image Trace: a simpler contour walk with no precomputed direction table; transparency grid visibility saved in native documents, #1968; MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 45/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -370,7 +370,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4 | -| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | +| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done, as are the Japanese papers in its Media list: JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判, beside the ISO B4/B5 that settings saved before them keep) | 1–2 | | Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | @@ -852,7 +852,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); CNC preset and VCarve QA remain pending. - - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). + - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. - File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests_place.rs`). - Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft; Windows 7 retains WMI and the installed PDF viewer. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests_print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged. @@ -981,6 +981,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | File formats: Affinity import gains master pages, tables, paragraph formats, text scale, shadows, glows, outlines (also on stroked shapes), overlays, underlines, strikethrough and editable embedded documents, strokes styled None left out, CMYK pixel layers through the document's CMYK profile, text placed as Affinity places it (AF.5, from storytold/designcraft#291); synthetic, corpus and local-thumbnail tests; score and hours unchanged (estimated). | | 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. | | 2026-10-22 | minor | Font pickers list recently applied families first (#3515), using the existing recent-font limit; search, filters, previews and keyboard selection share the same rows. Scores and estimates unchanged. | | 2026-10-21 | minor | Print: PDF/X-1a and PDF/X-3 flattening in a CMYK document rasterizes as ink amounts, so CMYK colours under an image with see-through pixels keep their numbers, K included, instead of going through RGB (#2160); own-fixture test; scores and hours unchanged (estimated) |