diff --git a/ROADMAP.md b/ROADMAP.md index a52cad74a..4cf94e922 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -143,10 +143,10 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). - **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian and Russian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian and Russian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a wrapped line flush with its start, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. -- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). +- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (`object.cropImage {trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl` or CSS, and loaded from `.vcswatches`, `.gpl` and swatch exchange (`.ase`) files (RGB, CMYK, Lab and Gray; global, spot and process; groups). Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. - - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). + - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. @@ -158,7 +158,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). - - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). + - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). - 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. - Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index f93fd28f8..94d316c8d 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -1,7 +1,7 @@ //! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files, //! and the profiles exports embed ([`encode_builtin`]), written in code from the CMS. -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use moxcms::{ ColorProfile, DataColorSpace, Layout, LocalizableString, LutMultidimensionalType, LutStore, LutWarehouse, Matrix3d, ProfileClass, ProfileText, @@ -27,6 +27,9 @@ fn intent_index(i: Intent) -> usize { type Xf = Option>; +/// A direct transform's cache key: destination name, intent index, black-point compensation. +type DirectKey = (String, usize, bool); + /// A parsed ICC profile plus lazily-built transforms to/from sRGB. pub struct IccProfile { pub name: String, @@ -37,6 +40,21 @@ pub struct IccProfile { srgb: ColorProfile, to_srgb: [OnceLock; 4], from_srgb: [OnceLock; 4], + /// Direct transforms into other RGB profiles ([`Self::to_rgb_of`]), by destination, intent + /// and black-point compensation. + direct: Mutex>)>>, +} + +/// A transform from one profile into an RGB profile without passing through sRGB: into the +/// destination's primaries with linear tone curves, where black-point compensation is a linear +/// remap, then onto its own curves. +struct Direct { + to_linear: Arc, + /// Linear → the destination's curves; `None` when the destination isn't a matrix profile + /// (the first transform goes straight into it, without compensation). + to_dest: Option>, + /// The source black's luminance in the linear destination (0: no compensation). + black: f32, } impl std::fmt::Debug for IccProfile { @@ -72,6 +90,7 @@ impl IccProfile { srgb: ColorProfile::new_srgb(), to_srgb: Default::default(), from_srgb: Default::default(), + direct: Mutex::new(Vec::new()), }) } @@ -135,6 +154,72 @@ impl IccProfile { Some(dst.into_iter().map(|x| x.clamp(0.0, 1.0)).collect()) } + /// Device values (RGB / CMYK / Gray, 0..1) → `dest`'s RGB, converted directly with `intent`: + /// not through sRGB, so colours outside sRGB keep their place in a wider space. With `bpc` + /// (and an intent other than absolute), this profile's black maps onto the destination's + /// black (black-point compensation, the darkest ink the profile prints becoming RGB 0 rather + /// than a dark grey). `None` when `dest` isn't RGB or no transform can be built. + pub fn to_rgb_of(&self, dest: &IccProfile, v: &[f32], intent: Intent, bpc: bool) -> Option<[f32; 3]> { + let t = self.direct_xf(dest, intent, bpc && intent != Intent::AbsoluteColorimetric)?; + let mut src = v.to_vec(); + src.resize(self.channels(), 0.0); + let mut lin = [0.0f32; 3]; + t.to_linear.transform(&src, &mut lin).ok()?; + let Some(to_dest) = &t.to_dest else { return Some(lin.map(|x| x.clamp(0.0, 1.0))) }; + if t.black > 0.0 { + lin = lin.map(|x| (x - t.black) / (1.0 - t.black)); + } + let lin = lin.map(|x| x.clamp(0.0, 1.0)); + let mut out = [0.0f32; 3]; + to_dest.transform(&lin, &mut out).ok()?; + Some(out.map(|x| x.clamp(0.0, 1.0))) + } + + fn direct_xf(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option> { + if dest.kind != ProfileKind::Rgb { + return None; + } + let key = (dest.name.clone(), intent_index(intent), bpc); + let mut cache = self.direct.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((_, t)) = cache.iter().find(|(k, _)| *k == key) { + return t.clone(); + } + let t = self.build_direct(dest, intent, bpc).map(Arc::new); + cache.push((key, t.clone())); + t + } + + fn build_direct(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option { + let d = &dest.profile; + let matrix = d.red_trc.is_some() && d.green_trc.is_some() && d.blue_trc.is_some(); + if !matrix { + let xf = self.profile.create_transform_f32(self.layout(), d, Layout::Rgb, Self::opts(intent)).ok()?; + return Some(Direct { to_linear: xf, to_dest: None, black: 0.0 }); + } + let mut linear = d.clone(); + for trc in [&mut linear.red_trc, &mut linear.green_trc, &mut linear.blue_trc] { + *trc = Some(ToneReprCurve::Parametric(vec![1.0])); + } + // A coding-independent code point names the transfer curve outright (moxcms's sRGB has + // one) and would override the linear curves. + linear.cicp = None; + let to_linear = self.profile.create_transform_f32(self.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?; + let to_dest = linear.create_transform_f32(Layout::Rgb, d, Layout::Rgb, Self::opts(Intent::RelativeColorimetric)).ok()?; + // The source black: the darkest colour the profile reproduces, found as lcms does for an + // output profile (black sent into the profile and read back), made neutral. + let black = if bpc && self.kind == ProfileKind::Cmyk { + let inks = self.from_srgb([0.0; 3], Intent::RelativeColorimetric)?; + let mut lin = [0.0f32; 3]; + to_linear.transform(&inks, &mut lin).ok()?; + let y = |c: &moxcms::Xyzd| c.y as f32; + let lum = lin[0] * y(&d.red_colorant) + lin[1] * y(&d.green_colorant) + lin[2] * y(&d.blue_colorant); + lum.clamp(0.0, 0.5) + } else { + 0.0 + }; + Some(Direct { to_linear, to_dest: Some(to_dest), black }) + } + /// Whether transforms can be built for this profile (checked at registration). pub fn usable(&self) -> bool { self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some() diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index dbe1831a4..f10b55428 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -356,10 +356,37 @@ impl Cms { } } - /// CMYK in the working space → display sRGB (`absolute` simulates the paper colour). + /// CMYK in the working space → display sRGB (`absolute` simulates the paper colour). An ICC + /// CMYK profile converts as [`Self::cmyk_to_rgb`] does into sRGB (with the settings' + /// black-point compensation), so what shows is what an RGB export writes. pub fn cmyk_to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] { + if let (false, CmykSpace::Icc(p)) = (absolute, &self.cmyk) + && let Some(srgb) = builtin_rgb_cache(SRGB) + && let Some(v) = p.to_rgb_of(&srgb, &cmyk.map(|v| v.clamp(0.0, 1.0)), Intent::RelativeColorimetric, self.settings.bpc) + { + return v; + } Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute) } + /// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts + /// directly into the RGB profile: not through display sRGB, which would clip colours a wider + /// RGB space holds (Wide Gamut RGB keeps CMYK cyan that sRGB can't show), and with the + /// settings' black-point compensation. The built-in CMYK spaces go through sRGB as before + /// (Generic CMYK compensates in its own model). + pub fn cmyk_to_rgb(&self, cmyk: [f32; 4], intent: Intent) -> [f32; 3] { + let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0)); + let src = match &self.cmyk { + CmykSpace::Icc(p) => Some(p.clone()), + CmykSpace::Generic(_) | CmykSpace::Device => None, + }; + let dest = match &self.rgb { + RgbSpace::Icc(p) => Some(p.clone()), + RgbSpace::Srgb => builtin_rgb_cache(SRGB), + }; + src.zip(dest) + .and_then(|(s, d)| s.to_rgb_of(&d, &cmyk, intent, self.settings.bpc)) + .unwrap_or_else(|| self.srgb_to_rgb(self.cmyk_to_srgb(cmyk, false))) + } /// Display sRGB → working CMYK with `intent`. pub fn srgb_to_cmyk(&self, srgb: [f32; 3], intent: Intent) -> [f32; 4] { Self::srgb_to_cmyk_in(&self.cmyk, srgb, intent, self.settings.bpc) @@ -424,6 +451,10 @@ impl Cms { let [c, m, y, k] = self.to_cmyk(c, intent); Color::Cmyk { c, m, y, k } } + (Color::Cmyk { c, m, y, k }, Model::Rgb) => { + let [r, g, b] = self.cmyk_to_rgb([*c, *m, *y, *k], intent); + Color::Rgb { r, g, b } + } (_, Model::Rgb) => { let [r, g, b] = self.srgb_to_rgb(self.display_rgb(c)); Color::Rgb { r, g, b } diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 5b23c0182..713b65399 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -60,3 +60,54 @@ fn a_loaded_profile_is_embedded_as_loaded() { register_icc(&bytes, Some("Embedded As Loaded RGB".into())).unwrap(); assert_eq!(&*icc_bytes("Embedded As Loaded RGB").unwrap(), &bytes[..]); } + +/// Settings with an ICC CMYK profile (Generic CMYK's, loaded as a file is) and Wide Gamut RGB. +fn icc_cmyk(bpc: bool) -> Cms { + let name = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Test ICC CMYK".into())).unwrap().name; + Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), cmyk: name, bpc, ..ColorSettings::default() }).unwrap() +} + +#[test] +fn icc_cmyk_converts_into_a_wide_rgb_space_directly_without_clipping_to_srgb() { + let wide = icc_cmyk(true); + let cyan = [1.0, 0.0, 0.0, 0.0]; + let direct = wide.cmyk_to_rgb(cyan, Intent::RelativeColorimetric); + // Through sRGB, cyan clips to sRGB's gamut first: in the wider space that reads as a red + // channel well above zero. + let hub = wide.srgb_to_rgb(wide.cmyk_to_srgb(cyan, false)); + assert!(direct[0] + 0.15 < hub[0], "direct {direct:?}, through sRGB {hub:?}"); + // Same colour either way where sRGB holds it: a mid grey and paper white. + for inks in [[0.0, 0.0, 0.0, 0.5], [0.0; 4]] { + let (d, h) = (wide.cmyk_to_rgb(inks, Intent::RelativeColorimetric), wide.srgb_to_rgb(wide.cmyk_to_srgb(inks, false))); + assert!(d.iter().zip(h).all(|(a, b)| (a - b).abs() < 0.02), "{inks:?}: direct {d:?}, through sRGB {h:?}"); + } + // Cms::convert takes the direct route for CMYK. + let Color::Rgb { r, .. } = wide.convert(&Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 }, Model::Rgb, Intent::RelativeColorimetric) else { + panic!("RGB") + }; + assert!((r - direct[0]).abs() < 1e-6); +} + +#[test] +fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() { + let rich = [0.75, 0.68, 0.67, 0.9]; + let (on, off) = (icc_cmyk(true).cmyk_to_rgb(rich, Intent::RelativeColorimetric), icc_cmyk(false).cmyk_to_rgb(rich, Intent::RelativeColorimetric)); + // Without compensation the profile's black is a dark grey; with it, rich black goes most of + // the way to RGB black (not all: it isn't quite the darkest ink the profile prints). + assert!(off.iter().all(|v| *v > 0.1), "without: {off:?}"); + assert!(on.iter().zip(off).all(|(a, b)| *a < 0.08 && *a < b * 0.5), "with: {on:?}, without: {off:?}"); + // The screen shows what an sRGB conversion writes, compensated alike. + let srgb = |bpc| { + let c = icc_cmyk(bpc); + Cms::new(&ColorSettings { rgb: SRGB.into(), ..c.settings().clone() }).unwrap() + }; + for bpc in [true, false] { + let c = srgb(bpc); + assert_eq!(c.cmyk_to_srgb(rich, false), c.cmyk_to_rgb(rich, Intent::RelativeColorimetric), "bpc {bpc}"); + } + // White is untouched, and absolute colorimetric never compensates. + let white = icc_cmyk(true).cmyk_to_rgb([0.0; 4], Intent::RelativeColorimetric); + assert!(white.iter().all(|v| *v > 0.99), "{white:?}"); + let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric); + assert_eq!(abs(true), abs(false)); +} diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index a293e9f12..fd85c1205 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -136,10 +136,65 @@ fn tiff_inks(bytes: &[u8]) -> Option { Inks::new(w, h, data) } +/// A CMYK TIFF with an alpha channel (5 samples) as RGBA pixels, each colour converted by `rgb` +/// (ink amounts 0..1 → sRGB 0..1) and associated (premultiplied) alpha divided back out; `None` +/// for other images, planar ones and ones larger than [`MAX_CMYK_PIXELS`]. The image decoder +/// doesn't read these at all; the inks can't be kept with the transparency, so they show as RGB. +pub fn cmyka_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option { + let (kind, mut d) = tiff(bytes)?; + let (w, h) = d.dimensions().ok()?; + let planar = d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1); + if !matches!(kind, tiff::ColorType::CMYKA(8 | 16)) || planar { + return None; + } + let n = usize::try_from(pixels(w, h)?).ok()?; + // ExtraSamples 1: associated alpha (premultiplied colour); 2 (or none stated): unassociated. + let associated = d.find_tag_unsigned_vec::(tiff::tags::Tag::ExtraSamples).ok().flatten().is_some_and(|v| v.first() == Some(&1)); + let data: Vec = match d.read_image().ok()? { + tiff::decoder::DecodingResult::U8(v) => v, + tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), + _ => return None, + }; + if data.len() != n * 5 { + return None; + } + let mut out = Vec::with_capacity(n * 4); + for p in data.as_chunks::<5>().0 { + let a = f32::from(p[4]) / 255.0; + let ink = |v: u8| { + let v = f32::from(v) / 255.0; + if associated && a > 0.0 { (v / a).min(1.0) } else { v } + }; + let [r, g, b] = rgb([ink(p[0]), ink(p[1]), ink(p[2]), ink(p[3])]); + out.extend([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); + out.push(p[4]); + } + image::RgbaImage::from_raw(w, h, out) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_cmyk_tiff_with_alpha_reads_as_rgba() { + use tiff::encoder::{TiffEncoder, colortype::CMYKA8}; + // 2 × 1: full cyan opaque, then 100% K at half alpha (premultiplied: K 128, alpha 128). + let mut file = Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut file).unwrap(); + let mut img = enc.new_image::(2, 1).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap(); + img.write_data(&[255, 0, 0, 0, 255, 0, 0, 0, 128, 128]).unwrap(); + let bytes = file.into_inner(); + let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]; + let px = cmyka_tiff_rgba(&bytes, naive).expect("CMYKA reads"); + assert_eq!(px.dimensions(), (2, 1)); + assert_eq!(px.get_pixel(0, 0).0, [0, 255, 255, 255], "cyan, opaque"); + assert_eq!(px.get_pixel(1, 0).0, [0, 0, 0, 128], "the premultiplied K divided back out: black at half alpha"); + // Not CMYKA: not this reader's. + assert!(cmyka_tiff_rgba(&ImageBlob::cmyk_tiff(&inks()).unwrap().bytes, naive).is_none()); + } + fn inks() -> Inks { Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap() } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 1a457e0dc..cb6810917 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -282,10 +282,19 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result { open_template(s, src.name, &src.bytes, src.path) } +/// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px) +/// is ~475 MB as RGBA, past the decoder's 512 MB default once it is converted. The web app keeps +/// that default: wasm32 has 4 GB to address and aborts when an allocation fails. +const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 }; + /// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as -/// they are, with their ink amounts ([`ImageBlob::cmyk`]). +/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which +/// the decoder can't read, become RGBA in the active colour settings' CMYK. pub fn raster_image(bytes: &[u8]) -> Result { - let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?; + let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_RASTER_ALLOC); + reader.limits(limits); let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?; let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?; let ppi = super::ppi::resolution(bytes); @@ -293,7 +302,14 @@ pub fn raster_image(bytes: &[u8]) -> Result { let (bytes, mime, (width, height)) = if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) { (bytes.to_vec(), f.mime, reader.into_dimensions().map_err(err)?) } else { - let img = reader.decode().map_err(err)?.to_rgba8(); + let cmyka = || { + let cms = vectorcraft_color::cms::active(); + vectorcraft_doc::cmyk::cmyka_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) + }; + let img = match (f.id == "tiff").then(cmyka).flatten() { + Some(img) => img, + None => reader.decode().map_err(err)?.to_rgba8(), + }; let size = img.dimensions(); let mut png = Vec::new(); img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?; @@ -311,16 +327,16 @@ pub fn raster_image(bytes: &[u8]) -> Result { Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi }) } -/// An image as a document of its pixel size (1 px = 1 pt), the image named after the file. +/// An image as a document of its physical size at the resolution it declares (as Place sizes it; +/// 72 ppi, 1 px = 1 pt, when it declares none), the image named after the file. fn raster_doc(name: &str, bytes: &[u8]) -> Result { - let RasterImage { key, blob, width, height, .. } = raster_image(bytes)?; - let mut d = Document::new(width as f64, height as f64); + let RasterImage { key, blob, width, height, ppi } = raster_image(bytes)?; + let (sx, sy) = crate::cmd::place::pt_per_px(ppi); + let mut d = Document::new(width as f64 * sx, height as f64 * sy); let layer = d.layers[0].id; let id = d.alloc_id(); - let mut n = Node::new( - id, - NodeKind::Image(ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link: None, placement: Default::default() }), - ); + let xf = Affine::scale_non_uniform(sx, sy); + let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() })); n.name = Some(name.to_string()); d.images.insert(key, blob); d.insert(Some(layer), 0, n).map_err(err)?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 44f49f1c2..026ae49c3 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -78,7 +78,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/tests_raster.rs b/crates/engine/src/cmd/fileio/tests_raster.rs index fa81265e2..527a44d36 100644 --- a/crates/engine/src/cmd/fileio/tests_raster.rs +++ b/crates/engine/src/cmd/fileio/tests_raster.rs @@ -147,3 +147,19 @@ fn the_document_background_is_the_default_export_background() { assert_eq!(png(&mut s, json!({"format": "png", "background": "transparent"})).get_pixel(0, 0)[3], 0, "the option wins"); assert_eq!(png(&mut s, json!({"format": "webp", "background": "black"})).get_pixel(0, 0).0, [0, 0, 0, 255]); } + +#[test] +fn an_image_opens_at_the_size_its_resolution_declares() { + let mut s = session(72.0, 36.0, 1); + let hi = b64(&s.execute("document.export", &json!({"format": "png", "ppi": 300, "background": "white"})).unwrap()); + let lo = b64(&s.execute("document.export", &json!({"format": "png", "background": "white"})).unwrap()); + let lo = super::ppi::with_png_resolution(&lo, (72.0, 72.0)); + for (png, w, h, what) in [(hi, 72.0, 36.0, "300 ppi: 300 x 150 px is 1 x 0.5 in"), (lo, 72.0, 36.0, "72 ppi: 1 px = 1 pt")] { + s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + let doc = &s.doc().unwrap().doc; + let ab = doc.artboards[0].rect; + assert!((ab.width() - w).abs() < 1e-6 && (ab.height() - h).abs() < 1e-6, "{what}: artboard {ab:?}"); + let art = doc.art_bounds().expect("the image"); + assert!((art.width() - w).abs() < 1e-6 && (art.height() - h).abs() < 1e-6, "{what}: the image fills it, {art:?}"); + } +} diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 6fe0f2b3f..5bf221b1c 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Crop Image", ["Object"], None, - "{rect?: [x, y, width, height]} crop the selected image (default: to the artboard it sits on) → {id, width, height}", + "{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim)}", has_image, crop_image ), @@ -541,6 +541,9 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| bad(C, "select an image"))?; let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?; let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) }; + if p.get("trim").and_then(Value::as_bool) == Some(true) { + return trim_image(s, id, im.clone()); + } let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?; let rect = match p.get("rect").and_then(Value::as_array) { Some(a) if a.len() == 4 => { @@ -594,6 +597,49 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0, "width": w, "height": h })) } +/// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the +/// pixels copied as they are (no resampling) and placed where they were. +fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { + const C: &str = "object.cropImage"; + let st = s.doc()?; + let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?; + let px = image::load_from_memory(&blob.bytes).map_err(|_| bad(C, "the image's pixels can't be read"))?.to_rgba8(); + let (w, h) = px.dimensions(); + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); + for (x, y, p) in px.enumerate_pixels() { + if p[3] > 0 { + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)); + } + } + if x1 <= x0 || y1 <= y0 { + return Err(bad(C, "the image is fully transparent")); + } + if (x0, y0, x1, y1) == (0, 0, w, h) { + // Already trimmed: not an error, so a script can trim every image it opens. + return Ok(json!({ "id": id.0, "width": w, "height": h, "trimmed": false })); + } + let cut = image::imageops::crop_imm(&px, x0, y0, x1 - x0, y1 - y0).to_image(); + let mut png = Vec::new(); + cut.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| bad(C, e.to_string()))?; + let (cw, ch) = (x1 - x0, y1 - y0); + s.edit("Crop Image", |d, _| { + let key = unique_key(d, "crop"); + d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png)); + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Image(ImageObject { + key, + width: cw, + height: ch, + xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))), + link: None, + placement: Default::default(), + }); + } + Ok(()) + })?; + Ok(json!({ "id": id.0, "width": cw, "height": ch, "trimmed": true })) +} + /// The Control bar's Mask for an image: a clip group of the image and a clipping path on its /// outline, with that path selected so dragging its handles crops the image. fn mask_image(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 1ee4d4e85..2d92bb2bc 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -1159,3 +1159,35 @@ fn saved_selection_names_are_trimmed_and_capped() { let list = s.execute("select.savedList", &json!({})).unwrap(); assert_eq!(list[0].as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME)); } + +#[test] +fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() { + // 100 × 80 px, transparent but for a block at (20, 10)–(50, 40) px; placed at 2 pt a pixel. + let mut px = image::RgbaImage::new(100, 80); + for y in 10..40 { + for x in 20..50 { + px.put_pixel(x, y, image::Rgba([x as u8, y as u8, 7, if x == 20 { 1 } else { 255 }])); + } + } + let mut png = Vec::new(); + px.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let img = selected(&s)[0]; + s.execute("object.scale", &json!({"sx": 200, "origin": [0, 0]})).unwrap(); + let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap(); + assert_eq!((r["width"].as_u64().unwrap(), r["height"].as_u64().unwrap()), (30, 30), "{r}"); + // Placed where those pixels were: (40, 20) pt, 60 × 60 pt at 2 pt a pixel. + let b = bounds(&s, img); + assert!(close(b.x0, 40.0) && close(b.y0, 20.0) && close(b.width(), 60.0) && close(b.height(), 60.0), "{b:?}"); + // Pixel for pixel, a barely visible (alpha 1) edge included. + let NodeKind::Image(im) = &node(&s, img).kind else { panic!("an image") }; + let out = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(out, image::imageops::crop_imm(&px, 20, 10, 30, 30).to_image()); + assert_eq!(r["trimmed"], true); + // Nothing transparent left to cut: left as it is, and not an error (scripts trim every image). + let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap(); + assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64()), (Some(false), Some(30)), "{r}"); + assert_eq!(bounds(&s, img), b); +} diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 0bace225d..ee1733758 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -592,6 +592,8 @@ struct Builder<'p> { image_keys: HashMap, /// The images kept with their CMYK samples ([`crate::import_image`]). cmyk_keys: HashSet, + /// Mask keys of CMYK images whose mask turned out to hide nothing. + opaque: HashSet, warnings: Vec, /// Fonts by cache key → base font name (from [`scan_page`]). fonts: HashMap, @@ -743,6 +745,7 @@ impl<'p> Builder<'p> { images: HashMap::new(), image_keys: HashMap::new(), cmyk_keys: HashSet::new(), + opaque: HashSet::new(), warnings: vec![], fonts: HashMap::new(), font_names: HashMap::new(), @@ -1146,6 +1149,40 @@ impl<'p> Builder<'p> { } fn add_image(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine) { + self.add_image_masked(key, make, xf, None); + } + + /// An image's alpha (its soft mask, stencil mask or colour key, at its own resolution) as a + /// greyscale image over the image's `w` × `h` pixels (`transform`): its opacity mask. `None` + /// when it masks nothing. + fn alpha_mask(&mut self, key: u128, r: &hayro_interpret::RasterImage<'_>, w: u32, h: u32, transform: Affine) -> Option { + let mkey = key ^ 0x5_3a5c; + if self.opaque.contains(&mkey) { + return None; + } + let (k, mw, mh) = if let Some(v) = self.image_keys.get(&mkey).cloned() { + v + } else { + let mut alpha = None; + r.with_rgba(|_, a| alpha = a, None); + let Some(a) = alpha.filter(|a| a.data.iter().any(|v| *v < 255)) else { + self.opaque.insert(mkey); + return None; + }; + let mut png = Vec::new(); + image::GrayImage::from_raw(a.width, a.height, a.data)?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + let blob = ImageBlob::new("image/png", png); + let k = blob.content_key(); + self.images.insert(k.clone(), blob); + self.image_keys.insert(mkey, (k.clone(), a.width, a.height)); + (k, a.width, a.height) + }; + let xf = transform * Affine::scale_non_uniform(f64::from(w) / f64::from(mw.max(1)), f64::from(h) / f64::from(mh.max(1))); + let art = Node::new(self.id(), NodeKind::Image(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default() })); + Some(OpacityMask::new(art, true)) + } + + fn add_image_masked(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine, mask: Option) { let (k, w, h) = if let Some(v) = self.image_keys.get(&key).cloned() { v } else { @@ -1160,7 +1197,9 @@ impl<'p> Builder<'p> { (k, w, h) }; let id = self.id(); - self.push_node(Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() }))); + let mut n = Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() })); + n.mask = mask.map(Box::new); + self.push_node(n); } /// Mesh shadings filling `region` (document space): gradient meshes, clipped to the region @@ -1542,7 +1581,10 @@ impl<'a> Device<'a> for Builder<'_> { }; if known || cmyk.is_some() { self.cmyk_keys.insert(key); - return self.add_image(key, || cmyk.map(|blob| (blob, w, h)), transform); + // Its mask (read by the interpreter, whatever its kind) becomes an opacity + // mask, so the inks stay as they are. + let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None }; + return self.add_image_masked(key, || cmyk.map(|blob| (blob, w, h)), transform, mask); } // JPEG passthrough for plain DeviceRGB/DeviceGray DCT images. let dict = st.dict(); diff --git a/crates/pdf/src/import_image.rs b/crates/pdf/src/import_image.rs index 83879ae31..1a1e4eacc 100644 --- a/crates/pdf/src/import_image.rs +++ b/crates/pdf/src/import_image.rs @@ -2,7 +2,9 @@ //! four components. A JPEG whose Decode array inverts its samples (Adobe's CMYK JPEGs, the //! convention JPEG readers and the PDF writer take a CMYK JPEG to follow) is kept as it is; any //! other is decoded, its Decode array applied, and stored as a CMYK TIFF -//! ([`ImageBlob::cmyk_tiff`]). Every other image is read as RGB by the interpreter. +//! ([`ImageBlob::cmyk_tiff`]). A mask (`/SMask`, `/Mask`) doesn't change that: the importer +//! carries it as the image's opacity mask ([`has_mask`]). Every other image is read as RGB by the +//! interpreter. use hayro_syntax::Filter; use hayro_syntax::object::stream::ImageDecodeParams; @@ -11,7 +13,6 @@ use vectorcraft_doc::ImageBlob; use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components}; /// A CMYK image read as RGB: why. -pub(crate) const MASKED: &str = "CMYK images with a mask were imported in RGB"; pub(crate) const UNREAD: &str = "CMYK images whose samples couldn't be read in CMYK were imported in RGB"; /// The Decode array of a JPEG kept as it is. @@ -57,9 +58,6 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result, width: u32, height: u32) -> Result) -> bool { + let d = st.dict(); + d.contains_key(b"SMask") || d.contains_key(b"Mask") || d.get::(b"SMaskInData").is_some_and(|v| v != 0) +} diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index e7142a562..ff48afd8a 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -375,6 +375,11 @@ impl Dest { }; Color::Cmyk { c, m, y, k } } + // CMYK straight into the destination RGB (the destination shares the source's CMYK). + (_, Color::Cmyk { c, m, y, k }) => { + let [r, g, b] = self.cms.cmyk_to_rgb([*c, *m, *y, *k], intent); + Color::Rgb { r, g, b } + } _ => { let [r, g, b] = self.cms.srgb_to_rgb(source.display_rgb(c)); Color::Rgb { r, g, b } diff --git a/crates/pdf/src/tests_cmykimages.rs b/crates/pdf/src/tests_cmykimages.rs index 7bfc68563..7ca5acfe0 100644 --- a/crates/pdf/src/tests_cmykimages.rs +++ b/crates/pdf/src/tests_cmykimages.rs @@ -212,19 +212,96 @@ fn other_cmyk_images_keep_their_ink_amounts() { } #[test] -fn cmyk_images_with_a_mask_or_odd_samples_open_in_rgb_with_a_warning() { - let inks = flat([0, 102, 255, 0], 4); - let mask = stream("/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", &[255, 128, 255, 128]); - let source = pdf_with_images(&[(cmyk_dict(2, 2, "/SMask 6 0 R /Filter /FlateDecode"), crate::output::deflate(&inks).unwrap())], &[mask]); - let r = import_with_report(&source, &ImportOptions::default()).unwrap(); - assert_eq!(blobs(&r.document)[0].mime, "image/png"); - assert!(r.warnings.iter().any(|w| w == crate::import_image::MASKED), "{:?}", r.warnings); +fn cmyk_images_with_odd_samples_open_in_rgb_with_a_warning() { let four_bits = cmyk_dict(2, 2, "").replace("/BitsPerComponent 8", "/BitsPerComponent 4"); let r = import_with_report(&pdf_with_images(&[(four_bits, vec![0x0F; 8])], &[]), &ImportOptions::default()).unwrap(); assert_eq!(blobs(&r.document)[0].mime, "image/png"); assert!(r.warnings.iter().any(|w| w == crate::import_image::UNREAD), "{:?}", r.warnings); } +/// An opacity mask's image: its blob and pixel size. +type MaskImage<'d> = (&'d ImageBlob, u32, u32); + +/// The image nodes on the first layer: each one's blob and its opacity mask's image, if it has one. +fn masked(d: &Document) -> Vec<(&ImageBlob, Option>)> { + let NodeKind::Layer { children, .. } = &d.layers[0].kind else { panic!() }; + children + .iter() + .filter_map(|n| { + let NodeKind::Image(im) = &n.kind else { return None }; + let mask = n.mask.as_ref().map(|m| match &m.art.kind { + NodeKind::Image(a) => (&d.images[&a.key], a.width, a.height), + other => panic!("mask art is an image: {other:?}"), + }); + Some((&d.images[&im.key], mask)) + }) + .collect() +} + +#[test] +fn masked_cmyk_images_keep_their_inks_and_carry_the_mask_as_an_opacity_mask() { + let inks = flat([0, 102, 255, 0], 4); + let z = |data: &[u8]| crate::output::deflate(data).unwrap(); + // Object 6 (the first after the two images): a soft mask; image 2 a colour key masking its + // first two pixels. + let smask = stream("/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", &[255, 128, 255, 128]); + let keyed: Vec = [[10, 20, 30, 40], [10, 20, 30, 40], [0, 102, 255, 0], [0, 102, 255, 0]].concat(); + let images = [ + (cmyk_dict(2, 2, "/SMask 7 0 R /Filter /FlateDecode"), z(&inks)), + (cmyk_dict(2, 2, "/Mask [10 10 20 20 30 30 40 40] /Filter /FlateDecode"), z(&keyed)), + ]; + let source = pdf_with_images(&images, &[smask]); + let r = import_with_report(&source, &ImportOptions::default()).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let m = masked(&r.document); + assert_eq!(m.len(), 2); + for ((blob, mask), (want, alpha)) in m.iter().zip([(&inks, [255, 128, 255, 128]), (&keyed, [0, 0, 255, 255])]) { + assert_eq!(blob.mime, "image/tiff"); + assert!(close(&blob.cmyk().unwrap().data, want, 0), "the inks as they were"); + let (mblob, mw, mh) = mask.expect("an opacity mask"); + assert_eq!((mw, mh), (2, 2)); + let grey = image::load_from_memory(&mblob.bytes).unwrap().to_luma8(); + assert_eq!(grey.into_raw(), alpha, "the mask's values"); + } + // The mask art covers the image exactly. + let NodeKind::Layer { children, .. } = &r.document.layers[0].kind else { panic!() }; + let img = children.iter().find(|n| matches!(n.kind, NodeKind::Image(_))).unwrap(); + assert_eq!(img.geometric_bounds(), img.mask.as_ref().unwrap().art.geometric_bounds()); + // Written as CMYK with the mask in grey; it opens with the same inks. + let out = export_with_report(&r.document, &opts(json!({}))).unwrap(); + let w = written(&out.bytes); + assert!(w.iter().filter(|w| w.space == "DeviceCMYK").count() == 2 && w.iter().any(|w| w.space == "DeviceGray"), "{w:?}"); + let back = import_with_report(&out.bytes, &ImportOptions::default()).unwrap(); + let inked: Vec> = all_images(&back.document).iter().filter_map(|b| b.cmyk()).map(|i| i.data).collect(); + assert!(inked.iter().any(|d| close(d, &inks, 3)) && inked.iter().any(|d| close(d, &keyed, 3)), "{:?}", back.warnings); + // A mask that hides nothing adds none. + let opaque = stream("/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", &[255; 4]); + let r = import_with_report( + &pdf_with_images(&[(cmyk_dict(2, 2, "/SMask 6 0 R /Filter /FlateDecode"), z(&inks))], &[opaque]), + &ImportOptions::default(), + ) + .unwrap(); + assert!(masked(&r.document)[0].1.is_none()); +} + +/// Every image blob a document's art uses (mask art included). +fn all_images(d: &Document) -> Vec<&ImageBlob> { + let mut keys = vec![]; + d.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + keys.push(im.key.clone()); + } + if let Some(m) = &n.mask { + m.art.walk(&mut |a| { + if let NodeKind::Image(im) = &a.kind { + keys.push(im.key.clone()); + } + }); + } + }); + keys.iter().filter_map(|k| d.images.get(k)).collect() +} + /// A document with CMYK image `blob` of `w` × `h` pixels placed 40 pt wide. fn image_doc(blob: ImageBlob, w: u32, h: u32) -> Document { let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk);