PDF export writes a CMYK image whose opacity mask is a gray image on its
own pixel grid with the mask as the image's soft mask. That path always
asked viewers to smooth the image, so a pixelated piece of an envelope
with Anti-Alias off was still written with /Interpolate when it had such
a mask. It now takes the pixelated flag as images without a mask do, and
caches the smoothed and pixelated versions apart.
Envelope Options stored Anti-Alias and Preserve Shape Using, but nothing that
draws an image read them: a distorted image was always smoothed and always
cut into clip groups.
- With Anti-Alias off, each piece of a distorted image is marked pixelated
(saved as `pixelated`). The renderer draws it with nearest-neighbor
sampling and without mips, SVG export writes `image-rendering: pixelated`,
and PDF export writes it without /Interpolate.
- With Preserve Shape Using Transparency, each piece is the image with a
white opacity mask in the piece's shape instead of a clip group. The
renderer paints an image whose mask is one plain white path straight into
that path, as it does for an image in a clip group, when the path lies
inside the image's frame.
Docs: docs/mcp.md, docs/target-app-parity.md.
An axial or radial shading's stops are the samples that linear
interpolation does not reproduce. An ink that changes nothing in RGB,
such as a varnish, could paint at dropped samples only, and its spot
swatch was then missing. Every sample now marks the inks it paints with
as used; the stops are unchanged.
A DeviceN color with a spot ink at a tint above 0 together with a process
plate or a second ink imports as RGB. The importer returned before marking
those inks as used, and only used inks become spot swatches, so the spot
ink was missing from the document and from every later export. Shading
stops take the same path, so a DeviceN gradient lost its ink the same way.
The art still imports as RGB with the existing warning; each ink it paints
with now becomes a spot swatch with its 100% color.
A space the file draws between two words gets tracking of its own so the
word after it stays where the file put it, but only U+0020 counted as a
space. A drawn no-break space (U+00A0, U+202F) or fixed-width space
(U+2000 to U+200A, thin and hair spaces included) took the old path: the
gaps on both sides of it also read as spaces, so the line came in with
an extra space before and after it and the words after it out of place.
These characters now count as spaces in the line's gap and drawn-space
rules, and a space of more than one byte is split into its own run whole.
The wrapped-line space tolerance in area type, a fiftieth of an em, is
named SPACE_TRACKING_TOLERANCE.
dithered_perceptual_gradients_export_as_images_with_noise_on_the_fill
compares two exports byte for byte: a stroke's file with dither and
without. The export dates a file by the clock, in whole seconds
(metadata::output_time, unless SOURCE_DATE_EPOCH is set), and the
tests' `uncompressed` helper gave no date of its own. So the test
failed whenever a second ticked between the two exports: the files
then differed in /CreationDate, /ModDate, the XMP dates and the IDs
made from them, and in nothing else. CI hit it on #3807.
With the wall clock moved a second on each read (an LD_PRELOAD shim
on clock_gettime), the test failed 5 runs of 5, and it was the only
one of vectorcraft-pdf's 380 tests that did. With the helper's date
fixed it passes, as do the other 379.
The helper now dates its exports `created: Some(0)`, as the crate's
other tests that compare whole files do (tests_editing, tests_pdf13,
tests_webview).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
Replace glob imports (`use super::*`, `use crate::*`, `use sig::*`) with
the names each file uses, in scriptcheck, pdf, render, text and ui-egui.
No code changes.
On a curved path each glyph was placed rigidly (turned to the tangent at
its middle), so its baseline ends stood off the curve and neighbours
turned by different angles: in scripts whose letters join, every join
opened into a notch. Measured with Arial on circles: at 29 pt on a
radius of 110 pt, 0.64 pt at the baseline and 1.24 pt at the height of
the joining stroke (a quarter of the size); 14.7 pt at 72 pt on a radius
of 60. Placing glyphs on the chord alone closes the baseline but leaves
about h (a_i + a_{i+1}) / 2R above it (0.71 pt here), so joined glyphs
are bent instead.
- Shaping marks each glyph of a segment whose script joins (Arabic,
Syriac, N'Ko, Mongolian, Adlam and the other cursive scripts), by the
segment's script, so a word bends as a whole.
- With the Rainbow effect such a glyph's outline is mapped point by
point: pen space (x, y) -> P(s + x step/advance) + (y + rise) N there,
each segment cut into pieces short enough for 0.01 pt (capped). A
glyph the path doesn't turn under stays exactly as it was; Skew, 3D
Ribbon, Stair Step and Gravity, vertical type, inline art and colour
glyphs are unchanged. `PositionedGlyph::bend` records it; `xf` stays
the straight glyph for the caret, hit testing and tools.
- PDF export draws bent glyphs as outlines and writes them again as
invisible text where they stand, so they can be selected, searched
and copied. SVG export outlines them (right-to-left type already was),
never as a <textPath>, and labels the group with its text
(role="img", aria-label) for screen readers and search.
Tests (crates/text/src/tests_path_joins.rs; installed Arabic font, skip
without one): the gap at the baseline and at join height is <= 0.01 pt
at 12, 29 and 72 pt on radii 60, 110 and 300, on both sides of the
path, with finite outlines; Latin on a curve and joined type on a
straight path are byte-for-byte what they were; the chord-only
prediction is checked.
vectorcraft-cli perf (load average 43 before, 34 after: not trustworthy;
the synthetic document has no type on a path, so bending doesn't run):
render fit 113.8 -> 132.7 ms, pan 115.5 -> 140.0 ms, 400% 32.9 -> 43.3
ms, hit test 1.36 -> 1.59 ms, save 81.5 -> 88.2 ms, open 306 -> 401 ms,
SVG 426 -> 439 ms, Pathfinder 93.7 -> 85.5 ms, handle drag 5.5 -> 6.7
ms, segment drag 5.1 -> 6.7 ms.
* Build: craft-libs at 2869b8cc
Pin craft-pdf, craft-print, craft-docpath, craft-ui, craft-keymap,
craft-window and the patched hayro/krilla backends to craft-libs
2869b8cc1077530bd07a7e192fb2b0e1304a1354, with the pdf-deps gate,
NOTICE, ASSETS.md and docs/pdf-backend.md at the same revision.
PDF: no modification date beyond the creation date, DeviceN images
are not JPEG-encoded, and clipping text draws nothing (its outlines
arrive as a clip path).
* Docking: compile against craft-ui at 2869b8cc
Floating windows are trees of groups: window_panels and window_active
read one as a flat group, and windows rebuilt from the legacy groups
keep their ids. Unknown dock actions are rejected, the new ones are
checked like the old, and every panel a close action removes keeps its
return location.
* Docking: panels and canvas on craft-ui's docking area
* Docking: tests for the dock area, flyouts, floating panels and the Tools panel
* Docking: catalogs without the old dock's strings
* Build: craft-libs at 8a503cfe
* Docking: floating panels in OS windows of their own on the desktop
* Docking: modal dialogs block panel windows; their keys and dropped files reach the main window
* Docking: art dragged onto a panel in its own window drops there
* Docking: module doc names the OS windows
* Build: craft-libs at a042cf26
* Docking: onto, above, below, collapsed, icon and column places on Float and Dock Panel; ui.docking
* Docking: command descriptions of Float, Dock, Move and Collapse Panel for the craft-ui layout
* Docking: control protocol describes the craft-ui layout, its commands and ui.docking
* Docking: rustfmt
* Docking: layouts saved before the canvas joined convert on load
Saved ui.json files and workspaces from before the shared layout held the
canvas (a dock-only tree plus floating_panels, docking_icons,
docking_collapsed, dock_collapsed and dock_width) convert into one layout:
the dock tree's groups in the main column, the icon column with the panels
dropped on it, floating groups and sets, sets docked as columns, collapsed
groups, the dock width and the places of closed panels. The legacy fields
are read, never written. A ui.json read only in part is copied to
ui.unreadable.json before anything saves over it.
* Docking: catalogs without the old collapse message
* Docking: converted floating sets start at most 720 points tall
* Docking: closed icon panels convert into the icon column; old floating rects, set heights and a copy of the file before conversion
* Build: craft-libs at 27e60765
* Docking: dock column places and icon count from the icon column; a docked column moves to a later place
* Build: craft-libs at d24e4bcc
* Build: craft-libs at 5bbe9f62
* Docs: floating panels in OS windows of their own close the other-monitor gap
* Docking: keys forwarded from a floating panel's OS window run shortcuts while the main window is unfocused
Review follow-up. A function-based shading is read once per import (kept
per shading pattern and inks), and all of them draw on one budget of
200,000 function evaluations; past it a shading comes in as its average
colour, then flat grey, each with a warning that names the cause. A
gradient read leaves enough of the budget to average another shading.
A colour that changes in step with the sampling grid is checked across
the middle of the domain before an axis is chosen, so it isn't read
along the wrong one. The grey fallback warning for other shadings names
only them.
Tests: the same shading on 2,000 paths is read once; 40 distinct
shadings stay within the budget; a hostile file (5,000 fills, 20
shadings with 500-step functions) opens in 0.8 s, 253 s before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PDF import turned every function-based shading (ShadingType 1) into a
flat mid-grey. Chromium writes a CSS repeating-linear-gradient this way,
so a red and white hatch came in as a grey bar.
A function-based shading whose colour changes along one axis of its
domain only now becomes a linear gradient across the domain: stops
sampled along that axis, each hard colour change (found by halving the
interval between two samples) two stops at one offset. One whose colour
changes in two directions comes in as its average colour, with a
warning. Either paints only over the shading's domain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With type written as text, PDF export drew the hyphen that ends a line
broken inside a word as a path. The file's text had no hyphen where the
canvas draws one, so copied or searched text read "considerations"
where the line shows "con-".
The hyphen is now written as text where the line draws it, as the
character the font gives its glyph to: "-", or U+2010 when the font
gives the glyph to U+2010 and not to "-". It is a text run of its own,
and right-to-left lines keep their /ReversedChars marking. The invisible
text of type without paint holds the hyphens too. Kashidas and list
markers are still drawn as paths.
Docs: docs/mcp.md, docs/target-app-parity.md.
Review of #3925: the spooled job turned every sheet wider than tall, so
custom paper that is wide in Portrait (a 4 × 2 in label) went to the queue
tall, and a Portrait + Transverse page was turned back. Only the sheets
whose paper the layout turned to landscape (not transverse) are written
upright now.
The desktop print service asked for upright pages on every platform, but
craft-print already turns each sheet to its own orientation on Windows:
only macOS asks for them now; Linux keeps main's behaviour.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A landscape print job was a page wider than tall, and `lp` on macOS puts
each page on the paper as it is: on upright Letter it printed unturned,
cut off on the right (checked with cupsfilter through an AirPrint
driver). Saving the PDF and printing it from Preview worked because
Preview turns the page.
`file.print` takes `forPrinter`: a page wider than tall is written
upright, the art turned a quarter anticlockwise as a printer driver turns
a landscape job. Page by page, so auto-rotated jobs that mix wide and tall
artboards print right too. The desktop print service asks for it
(`PrintService::turns_landscape`); saved PDFs, PostScript, the preview and
the browser's print dialog keep landscape pages as they are.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CodeRabbit findings #32, #38, #41, #44, #102:
- A frame (and a PDF export) draws symbol instances holding at most 16M objects together
(`vectorcraft_doc::MAX_SYMBOL_NODES`): nested instances multiplied past the depth limits.
- A dotted guide whose control polygon would make more than 100,000 dashes is drawn solid.
- Groups blending with their backdrop, drawn twice each, are drawn isolated past 4 nested.
- An image is decoded only while it and the images paints hold fit twice the decode allowance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #3924: in a flat area of ` <<(` (CMYK or RGBA 32, 60, 60, 40) in an uncompressed image, each `<<` after white space was read as a dictionary, and each read its string, which never ends, to 64 KiB past it: about 16 K bytes a pixel, so a few hundred bytes of it spent the budget and the file was refused.
Whether a byte in a literal string is escaped doesn't depend on where reading began (the run of `\` before it lies after any `(` before it), so the string from any `(` ends after the first `)` that takes the file-wide count of `(` less `)` below the count at that `(`. Those ends are found for the whole file in three linear passes the first time a string read in skipped text runs past 64 bytes: forward for the final count, back for the `(` whose string never ends, forward pairing the rest. Ends further than 64 bytes are kept (at most one for every 16 bytes of the file; a file built to nest past that is refused); nearer ones are read again. So entering a string costs at most 64 bytes, charged as one. A comment's end of line is kept for every comment read, not only the last.
Review of #3924: every `<<` in a string, comment or stream's data was read as a dictionary of its own, and in a run of `<` (an uncompressed image's flat area of grey 60) every position is one, each read to past the run, so about N²/2 bytes for N `<` and a 1 MiB run refused the file.
The reader starts a dictionary there only where its repair scan tries one (after white space or a dictionary's `>>`) or after an object's `N G obj`: so only those `<<` are read, in a run of `<` only the first. A reading goes on as far as 64 KiB past the last `<<` opened on the way, looks at each nested dictionary's `>>` for a stream and marks each `<<`, so it is not read again; a comment's end of line is kept, as many readings cross the same comment; the `<<<` second-`<` reading in the main pass goes (the reader never starts there). The dictionary text a stream check parses is charged to the budget.
Review of #3919: the scan raised its walk-back floor at every `>>stream`, so one inside a literal string moved the floor past the `<<` of the cross-reference stream around it, and that stream was never looked at. The backward walk also counted a `>>` inside a string, and missed a comment between `>>` and `stream`.
The scan now reads the file forward once, as the reader does: literal strings (nested, escaped), hex strings and comments are skipped, `<<` and `>>` pair on a stack, and a dictionary followed by `stream` is a stream's, whose data is skipped. Because the reader can still start at a `<<` inside skipped text (a cross-reference offset or its repair scan), each such `<<` is read as a dictionary of its own, up to 64 KiB, within a budget of four times the file plus 1 MiB; a crafted file that spends it is refused. The scan stays linear.
Each `stream` keyword walked back up to 64 KiB through every dictionary before it, so 1 MiB of `<</A <</B 1>> >>stream` took minutes. A walk back now stops where the previous keyword's walk began, and a stream without a Length shares the search for the next `endstream`.
Cause: since #3742 place_each measures a line's spacing from the file's
own widths, so tracking is only the file's Tc/Tw/TJ. Where the widths
are systematically off the installed font's advances (up to the 1% that
still counts as the same font), those differences add up along a line
and nothing made up for them. With no kerning and every tracking value
0, place_each returned the untracked layout early, without the 0.02 em
placement check (CodeRabbit's review of #3742): a line of embedded
Source Sans 3 with /Widths 0.8% wide opened 3.7 pt off. With the early
return removed the check rejects the line and the line-wide fit takes
over: one whole-thousandth tracking value with the font's kerning,
0.30 pt off (as before #3715). Before #3742 main placed it exactly but
with a run per character (49 and 62 runs).
Change: the early return is gone; every line goes through the check.
Before it, on_course() keeps the file's spacing where the glyphs stay
within 0.01 em of the file's; otherwise each stretch of the line takes
one share after every character (the longest stretch one share keeps
within 0.01 em), so ordinary text stays one run, and a line whose
differences change course (30 m then 30 i) takes a run per stretch (2).
Verification: new test
a_long_line_of_widths_a_little_off_the_fonts_advances_lands_in_one_run
(embedded Source Sans 3, widths x1.008 and x0.992, a kerned and an
unkerned line): one run each, glyphs under 0.24 pt (0.02 em) from where
the file draws them. Without on_course it fails after #3742, with and
without the early return ("glyphs 0.300 pt"); before #3742 it fails on
the run count. It replaces an Inter test drafted for the review: Inter
isn't embedded there, so the file's outlines are the interpreter's
stand-in stretched to the widths, not Inter, and the contours differ by
0.6 pt even with exact widths. tests_text_placement and
text_becomes_point_type pass; cargo xtask ci: all 10 steps passed.
Thanks to the CodeRabbit review on #3742 for the early-return finding.
Cause: place_each (crates/pdf/src/import_text.rs, #3715) works out each
character's tracking as the file's distance between glyph origins minus
the installed font's advance. A PDF gives widths to the thousandth of an
em (the standard-14 AFM metrics or /Widths), so with a font of 2048 units
an em (Helvetica on macOS) every character is a hair (~0.0002 em) off.
When the line isn't evenly spaced as a whole (a TJ word gap, a space read
from a gap), `even` keeps every character's own value, rounding noise
included, and restyled() splits the line into a run per character.
text_becomes_point_type failed on macOS; Linux CI passed because Helvetica
isn't installed there, its stand-in's widths differ by more than 1% and
place_each falls back to the line-wide fit.
Change: measure the spacing from the file's own widths: each character's
pen is moved by how much wider the file says the characters before it are
than the font's advances, where the file gives the character's width and
the layout sets it as a glyph of its own. The tracking is then the file's
(Tc, Tw, TJ), whatever font is installed; the layout check that the glyphs
land within 0.02 em of the file's places is unchanged.
Verification: text_becomes_point_type and all of tests_text_placement pass
on macOS. New test widths_rounded_off_the_installed_fonts_advances_keep_one_tracking
uses built-in Inter (non-embedded, widths rounded to thousandths), so it
doesn't depend on system fonts; without the fix it fails on any platform
with runs N/e/x/t/ /li/n/e/ /h/e/re. cargo xtask ci: all 10 steps passed.
Illustrator 30.0's gradient Method, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html):
Classic or Perceptual; perceptual interpolation matches colour transitions to
how the eye perceives differences between colours, and perceptual gradients
are rasterised when saved as PDF or SVG.
- Gradient gains `method` (Classic | Perceptual; serde default Classic,
written only when Perceptual, so older files open as Classic and Classic
files are written as before). The colour crate's OKLab helpers open up for it.
- Perceptual mixes neighbouring stop colours in OKLab (sample, and
sample_keep_model for the stroke meshes, Expand and blends); opacity stays
linear. Renderers that mix linearly get stops on the perceptual curve,
placed by halving each half segment where a straight line strays more than
0.4 of a colour level (a nearly straight segment needs none). Classic
gradients expand exactly as before.
- draws_as_pixels includes Perceptual gradients, so SVG and PDF write a
Perceptual fill or stroke as an image at the document's raster effects
resolution, clipped to what it paints. A gradient along or across a stroke
stays slices (PDF no longer draws it as a within-stroke image).
- With Dither: a dithered Perceptual fill is the perceptual mix with dither's
noise (the canvas's dithered grids are keyed by method too); SVG and PDF
write it as an image with the noise, and a dithered Perceptual stroke as an
image without it, as the canvas draws it (GradientPaint::pixels, SVG's
raster_paint and PDF's area are told whether they paint a fill).
Review follow-up (CodeRabbit): a gradient drawn as pixels was sized from its
area in the space it is drawn in, so type scaled 4 times got a quarter of the
raster effects resolution; the image is now sized from the area on the page,
within the same pixel caps. And type whose fill is drawn as art (a dithered
or freeform gradient's image, a pattern) went out as outlines only; its
characters are now also written as invisible text (rendering mode 3), as
unpainted type already is, so they can be selected and searched.