diff --git a/.github/workflows/print.yml b/.github/workflows/print.yml new file mode 100644 index 00000000..344eae01 --- /dev/null +++ b/.github/workflows/print.yml @@ -0,0 +1,35 @@ +name: Desktop printing +on: + pull_request: + paths: + - "Cargo.toml" + - "Cargo.lock" + - "apps/vectorcraft/Cargo.toml" + - "apps/vectorcraft/src/printing.rs" + - "apps/vectorcraft/src/printing/**" + - "crates/ui-egui/src/print.rs" + - "crates/ui-egui/src/tests_printps.rs" + - ".github/workflows/print.yml" + workflow_dispatch: +permissions: + contents: read +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 +jobs: + windows: + runs-on: windows-2022 + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master + with: + toolchain: stable + components: clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: desktop-print + - run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings + - run: cargo test --locked -p vectorcraft + - name: Check pending submission and queue refusal feedback + run: cargo test --locked -p vectorcraft-ui-egui tests_printps diff --git a/.github/workflows/windows7.yml b/.github/workflows/windows7.yml index 403be166..0d252f11 100644 --- a/.github/workflows/windows7.yml +++ b/.github/workflows/windows7.yml @@ -1,11 +1,22 @@ # Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and -# weekly, so ordinary PRs never wait on it, and the weekly run shows when a dependency update starts +# weekly and for changes to the compatibility path; the weekly run shows when a dependency update starts # importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build # that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host # smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support. name: Windows 7 x64 compatibility on: + pull_request: + paths: + - "Cargo.toml" + - "Cargo.lock" + - "apps/vectorcraft/Cargo.toml" + - "apps/vectorcraft/src/printing.rs" + - "apps/vectorcraft/src/printing/**" + - "crates/ui-egui/src/print.rs" + - "crates/ui-egui/src/tests_printps.rs" + - "packaging/windows/windows7.ps1" + - ".github/workflows/windows7.yml" schedule: - cron: "23 5 * * 1" # weekly workflow_dispatch: diff --git a/Cargo.lock b/Cargo.lock index 6ff74fb7..c1461cdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -806,6 +806,14 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +dependencies = [ + "tempfile", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -4487,6 +4495,7 @@ dependencies = [ "arboard", "block2 0.6.2", "clipboard-win", + "craft-print", "eframe", "egui", "image", diff --git a/Cargo.toml b/Cargo.toml index 748ecb6e..e324d639 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ rust-version = "1.95" repository = "https://github.com/storytold/vectorcraft" [workspace.dependencies] +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } # internal vectorcraft-geom = { path = "crates/geom" } diff --git a/ROADMAP.md b/ROADMAP.md index 803ba09a..f8b18a58 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| +| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. | | 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undo step (#1331). EPS import no longer hangs on shading functions fanned out through shared arrays (#1330), looped huge arcs, fine dashes and rectangle arrays (#1329), or very long dash patterns (#1332). Plug-in whole-number fields keep values past 2^53 exactly as typed (#1327). Large documents use far less memory: decoded images are shared and capped, undo history is bounded by memory, native files open in the background and save as a stream, and Data Recovery writes images once (#1245). Opacity mask commands (#1336), Simplify and Add Anchor Points (#1337) add no undo step when they change nothing. EPS files with thousands of tiling patterns, spot inks or clipped shadings open several times faster with the same result (#1341). | | 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). The toolbar's Fill/Stroke proxy and Color, Gradient and None buttons grow in two columns, where the three drawing modes sit side by side (#1129). An image copied in a browser that puts its `` markup on the clipboard as text pastes as the picture, not the markup (#1089). `file.place` of an SVG with `crop: "crop"` frames and clips it by its viewBox, so art drawn on a shared canvas keeps its place and scale (#788). While a star is dragged, Ctrl/Cmd keeps its inner radius as the outer one grows and Alt keeps its shoulders straight (#812). Edit Toolbar opens the All Tools drawer beside the toolbar, dims the tools already in it, and adds, removes and moves tools by drag and drop while it is open (#1018). The Selection tool's arrow is black and Direct Selection's white in every theme, as users know them: in the dark themes they looked swapped. The font menu keeps its highlight on the same font when the list changes under it (a family's styles listed above it, fonts that finish loading, a favourite unstarred), and ends the live preview when that font leaves the list (#1116). The Windows installer has a Shortcuts page with a ticked "Create a desktop shortcut" box, and a silent install adds the shortcut unless `INSTALLDESKTOPSHORTCUT=""` is passed (#1128). Image Trace's Noise merges specks until none under its size is left beside another colour, so a higher Noise never gives more paths (#1130). `cargo clippy -D warnings` passes for the wasm32 web build (#1132). Gradient stops with opacity blend colour and opacity apart on the canvas, as Illustrator and the PDF and SVG exports do: a transparent stop's colour still tints the fade (#1142). An SVG mask whose art is all hidden imports as an empty opacity mask, so its object stays hidden as browsers draw it (#1143). Shift-click with the Artboard tool, or in the Artboards panel, selects several artboards, which then move, duplicate (Alt-drag) and delete together (#1139). A shape far beyond any page (from a malformed file) is left undrawn instead of making the renderer abort the app. Live Corners round corners where curved segments meet too, trimming each curve to what the cut leaves of it (#1144). Effect › Distort & Transform › Zig Zag's dialog has sliders for Size and Ridges per segment, and Relative / Absolute and Smooth / Corner as radio buttons (#1133). Object › Path › Offset Path opens on the offset, joins and miter limit it was last applied with (#1152). A marquee selects only the objects whose paths it touches: one drawn over a background, in Preview or Outline, no longer selects the background too (#1103). On Linux, Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland on a Wayland session, where files dropped from a file manager arrive (#1151). A selected gradient mesh shows its mesh lines and points with the Selection and Direct Selection tools too, not only with the Mesh tool (#1111). A blend's spine bends: the Anchor Point tool drags any of its segments into a curve, straight ones too, and Direct Selection reshapes its curved segments (`object.blend.spine.reshapeSegment`, #1111). Alt-click on a hidden layer's eye shows that layer as it hides the others, and on a locked layer's lock unlocks it as it locks the others: the layer clicked is the one left in view (#1158). Object › Compound Path › Release splits a path of several subpaths (an SVG `d` with several `M`s, a path drawn in one go over MCP) into one path per subpath, with its paint and stacking order, as it does a compound path (#1163). MCP `render_preview` takes an `artboard` index, so an agent can look at every page of a multi-artboard document (#1163). The MCP drawing tools say that omitted paint follows the new-art appearance (a white fill and 1 pt black stroke at first), and `export` says which formats take `outlineText` (#1148). The desktop app refuses a malformed `--automation-read-root` / `--automation-write-root` (no folder, an empty one, another option or the flag twice) before it starts, instead of dropping the root its environment variable set and leaving the control channel unconfined (#1157). PDF export warns, once per object, when the 64 million pixel cap renders its raster effects below the document's raster effects resolution, with the requested and the achieved ppi (#1147). MCP `command_batch` with `atomic: true` makes the whole batch one undo step, named by `label` (`edit.undoGroup.begin`/`end`, #1163). `file.list {path}` lists a folder's files and folders for agents, held to the automation read root (#1163). Thai typed in a font without Thai glyphs is set in an installed font made for Thai (Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai…) instead of whichever face with Thai glyphs loaded first, and Lao, Khmer and Myanmar likewise. With the Type tool, the Left and Right arrows and Delete step over whole clusters (a Thai consonant with its vowel and tone marks, a letter with a combining accent, an emoji sequence), so the caret never lands inside one; Backspace still takes back one character at a time, a mistyped tone mark say. Thai, Lao, Khmer and Myanmar area type breaks its lines between words, which those scripts don't separate with spaces, instead of running on to the next space; the words come from ICU4X's line segmenter (its dictionaries on the desktop, its smaller LSTM models on the web). The font menu's script filter has Thai beside Japanese and Latin: the families whose character map has Thai. With craft-fonts' Noto Sans Thai, Thai is set in it where no Thai font is installed, and the web build fetches it before the app starts, so Thai no longer shows as empty boxes there. With the Type tool, Ctrl/Alt+arrows and double-click take Thai, Lao, Khmer and Myanmar text word by word (ภาษา, ไทย…) instead of a whole run without spaces, and a word's vowel and tone marks (and combining accents) stay part of it. Releases embed the current craft-fonts (Noto Sans Thai, Noto Sans Arabic and Noto Sans CJK SC beside the Japanese fonts), as the other craft apps' releases do. Object › Compound Path › Make cuts holes where the paths in front overlap the backmost one, also when they were drawn running the same way (#1197). On Windows, Direct3D 12 shaders compile with FXC, part of Windows, so a dxcompiler.dll another program left on the search path without its dxil.dll no longer loses the graphics device at start-up and drops the app to OpenGL (#1194). With anchor points selected by Direct Selection, the Align buttons align those points (to their own bounds or the artboard) instead of moving the whole path (#1180). With Smart Guides, an object grabbed by an anchor and dropped near another object's anchor lands exactly on it, instead of its bounds lining up a fraction of a point beside it (#955). .ai import: a copied area type or type-on-a-path object now sits where its own art puts it, not on top of the original (#1208). EPS/.ai import: a font is found by its exact PostScript name before the name is split, so families with hyphens keep their name (#1207). EPS/.ai import: type from the text document keeps its line spacing across paragraphs instead of applying the paragraph spacing twice (#1206). Brazilian Portuguese translates the remaining UI labels and engine messages (#1205). Czech joins the complete languages: panels, dialogs, plurals and status and error messages (#1189). Type: Digits (Character panel menu, with the Middle Eastern options) draws every digit in the Default, Arabic, Hindi or Farsi numeral system (#1201). Linux: under Wayland, Automatic draws on the GPU the compositor says it draws with, so a hybrid laptop whose compositor runs on the discrete GPU no longer opens an empty window (#1200). Large documents: editing a big selection takes time linear in the document (a 25,000-path nudge 3.5 s → 48 ms), live geometry effects are kept across frames, Pathfinder Trim, Merge, Crop and Outline are 9–20× faster with identical output, and undo history holds 41% less (#1173). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 5ce13204..ff81c63b 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -34,6 +34,10 @@ webbrowser = { workspace = true } # The window's monitors, size and position (the same winit eframe runs on). winit = { version = "0.30", default-features = false } +# The win7 target retains the viewer fallback; craft-print's WinRT PDF renderer needs newer Windows. +[target.'cfg(all(not(target_arch = "wasm32"), not(target_vendor = "win7")))'.dependencies] +craft-print = { workspace = true } + [target.'cfg(windows)'.dependencies] clipboard-win = { workspace = true } # Which GPU drives which display (EnumDisplayDevices, src/gpu.rs), so the window is drawn on the diff --git a/apps/vectorcraft/src/printing.rs b/apps/vectorcraft/src/printing.rs index 005a5f64..525f8bc3 100644 --- a/apps/vectorcraft/src/printing.rs +++ b/apps/vectorcraft/src/printing.rs @@ -1,127 +1,54 @@ -//! File → Print on the desktop ([`SystemPrint`]): the system's printers and print queue. macOS and -//! Linux go through CUPS (`lpstat` lists the printers, `lp` queues a temporary copy of the job's -//! PDF); Windows asks PowerShell for the printers and prints through the shell's print verb of -//! the PDF viewer. - -use std::path::PathBuf; +//! Desktop printer transport. VectorCraft composes every page and copy before submission; +//! craft-print owns discovery and spooling. Windows 7 keeps its viewer-based transport because +//! Windows.Data.Pdf is unavailable there. +#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))] use std::process::Command; - use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer}; -/// The system's printing. -pub struct SystemPrint; +#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))] +mod windows7; +#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))] +use windows7 as system; +pub struct SystemPrint; impl PrintService for SystemPrint { fn printers(&mut self) -> Vec { system::printers() } - fn print(&mut self, job: &PrintJob) -> Result { - let file = job_file(job.pdf)?; - let r = system::print(job, &file); + let result = system::print(job); let to = job.printer.unwrap_or("the default printer"); - r.map(|note| format!("Sent “{}” to {to}{note}", job.title)) + result.map(|note| format!("Sent “{}” to {to}{note}", job.title)) + } + fn shared_print(&self) -> Option { + Some(std::sync::Arc::new(|job| SystemPrint.print(job))) } - fn has_setup(&self) -> bool { true } - fn setup(&mut self, printer: Option<&str>) -> Result<(), String> { system::setup(printer) } } -/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more -/// than an hour old (the print verb reads them after Print returns) are removed first. -fn job_file(pdf: &[u8]) -> Result { - let dir = std::env::temp_dir().join("vectorcraft-print"); - std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?; - for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() { - let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600); - if old { - // Another job may still be reading it: leave it for next time. - let _ = std::fs::remove_file(e.path()); - } - } - let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); - let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id())); - std::fs::write(&path, pdf).map_err(|e| format!("can't write {}: {e}", path.display()))?; - Ok(path) -} - -/// Run `c` → its output, or its error message. -fn output(c: &mut Command) -> Result { - let program = c.get_program().to_string_lossy().to_string(); - let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?; - if out.status.success() { - return Ok(String::from_utf8_lossy(&out.stdout).into_owned()); - } - let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); - Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err }) -} - -/// `lpstat -e` / `lpstat -a` → the printer names (the first word of each line). -#[cfg(any(test, not(windows)))] -fn lpstat_names(text: &str) -> Vec { - let mut names: Vec = vec![]; - for name in text.lines().filter_map(|l| l.split_whitespace().next()) { - if !names.iter().any(|n| n == name) { - names.push(name.to_string()); - } - } - names -} - -/// `lpstat -d` → the default printer ("system default destination: NAME"; none: "no system -/// default destination"). -#[cfg(any(test, not(windows)))] -fn lpstat_default(text: &str) -> Option { - let line = text.lines().find(|l| l.contains(':'))?; - let name = line.rsplit_once(':')?.1.trim(); - (!name.is_empty()).then(|| name.to_string()) -} - -/// PowerShell's `\t` lines → the printers. -#[cfg(any(test, windows))] -fn windows_printers(text: &str) -> Vec { - text.lines() - .filter_map(|l| l.trim_end_matches('\r').split_once('\t')) - .filter(|(_, name)| !name.trim().is_empty()) - .map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") }) - .collect() -} - -/// `s` as a PowerShell single-quoted string. -#[cfg(any(test, windows))] -fn ps_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "''")) -} - -#[cfg(not(windows))] +#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))] mod system { use super::*; - pub fn printers() -> Vec { - // `-e` lists every destination (CUPS 2); older systems answer `-a`. - let names = output(Command::new("lpstat").arg("-e")).or_else(|_| output(Command::new("lpstat").arg("-a"))).map(|t| lpstat_names(&t)); - let default = output(Command::new("lpstat").arg("-d")).ok().and_then(|t| lpstat_default(&t)); - names.unwrap_or_default().into_iter().map(|name| Printer { default: default.as_ref() == Some(&name), name }).collect() + craft_print::printers().into_iter().map(|p| Printer { name: p.name, default: p.default }).collect() } - - pub fn print(job: &PrintJob, file: &std::path::Path) -> Result { - let mut c = Command::new("lp"); - if let Some(p) = job.printer { - c.args(["-d", p]); - } - c.args(["-t", job.title]).arg(file); - let r = output(&mut c); - // `lp` has spooled its own copy (or failed). - let _ = std::fs::remove_file(file); - r.map(|out| Some(out.trim().to_string()).filter(|o| !o.is_empty()).map(|o| format!(" ({o})")).unwrap_or_default()) + pub fn print(job: &PrintJob) -> Result { + // Copies, layout, marks and colour management are already in the PDF. Preserve its + // physical sheet size and the queue's duplex default without printing copies twice. + let settings = craft_print::Job { printer: job.printer.map(str::to_string), title: job.title.into(), ..Default::default() }; + craft_print::submit(job.pdf, &settings).map(|note| if note.is_empty() { note } else { format!(" ({note})") }).map_err(|e| e.to_string()) } - pub fn setup(printer: Option<&str>) -> Result<(), String> { + #[cfg(windows)] + { + let _ = printer; + Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string()) + } #[cfg(target_os = "macos")] { let _ = printer; @@ -131,92 +58,13 @@ mod system { .map(|_| ()) .map_err(|e| e.to_string()) } - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(windows, target_os = "macos")))] { if Command::new("system-config-printer").spawn().is_ok() { return Ok(()); } - // CUPS' own pages. let url = printer.map_or_else(|| "http://localhost:631/printers/".to_string(), |p| format!("http://localhost:631/printers/{p}")); Command::new("xdg-open").arg(url).spawn().map(|_| ()).map_err(|e| e.to_string()) } } } - -#[cfg(windows)] -mod system { - use super::*; - - fn powershell(script: &str) -> Command { - use std::os::windows::process::CommandExt as _; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let mut c = Command::new("powershell"); - c.args(["-NoProfile", "-NonInteractive", "-Command", script]).creation_flags(CREATE_NO_WINDOW); - c - } - - pub fn printers() -> Vec { - #[cfg(feature = "windows7")] - let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }"; - #[cfg(not(feature = "windows7"))] - let script = "Get-CimInstance Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }"; - output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default() - } - - pub fn print(job: &PrintJob, file: &std::path::Path) -> Result { - // The PDF viewer's print verb reads the file after this returns: it stays for a while. - let path = ps_quote(&file.to_string_lossy()); - let script = match job.printer { - Some(p) => format!( - "Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden", - ps_quote(&format!("\"{}\"", p.replace('"', ""))) - ), - None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"), - }; - output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})")) - } - - pub fn setup(_printer: Option<&str>) -> Result<(), String> { - #[cfg(feature = "windows7")] - let mut command = { - let mut command = Command::new("control.exe"); - command.arg("printers"); - command - }; - #[cfg(not(feature = "windows7"))] - let mut command = { - let mut command = Command::new("explorer"); - command.arg("ms-settings:printers"); - command - }; - command.spawn().map(|_| ()).map_err(|e| e.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn lpstat_lists_names_and_the_default() { - let names = lpstat_names("Office_Laser accepting requests since Mon 05 Oct 2026\nPhoto-Inkjet accepting requests since …\n\n"); - assert_eq!(names, ["Office_Laser", "Photo-Inkjet"]); - assert_eq!(lpstat_names("Office_Laser\nOffice_Laser\n"), ["Office_Laser"]); - assert_eq!(lpstat_default("system default destination: Photo-Inkjet\n").as_deref(), Some("Photo-Inkjet")); - assert_eq!(lpstat_default("no system default destination\n"), None); - } - - #[test] - fn powershell_lines_become_printers() { - let got = windows_printers("False\tMicrosoft Print to PDF\r\nTrue\tOffice Laser\r\n\r\nFalse\t \r\n"); - assert_eq!(got, [Printer { name: "Microsoft Print to PDF".into(), default: false }, Printer { name: "Office Laser".into(), default: true }]); - assert_eq!(ps_quote("C:\\Temp\\it's.pdf"), "'C:\\Temp\\it''s.pdf'"); - } - - #[test] - fn job_files_hold_the_pdf() { - let path = job_file(b"%PDF-1.7 test").unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test"); - let _ = std::fs::remove_file(path); - } -} diff --git a/apps/vectorcraft/src/printing/windows7.rs b/apps/vectorcraft/src/printing/windows7.rs new file mode 100644 index 00000000..2d9a6b33 --- /dev/null +++ b/apps/vectorcraft/src/printing/windows7.rs @@ -0,0 +1,129 @@ +//! The legacy viewer print verb is needed on Windows 7, where Windows.Data.Pdf is absent. +use std::path::PathBuf; +use std::process::Command; +use vectorcraft_ui_egui::print::{PrintJob, Printer}; + +/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more +/// than an hour old (the print verb reads them after Print returns) are removed first. +fn job_file(pdf: &[u8]) -> Result { + let dir = std::env::temp_dir().join("vectorcraft-print"); + std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?; + for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() { + let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600); + if old { + // Another job may still be reading it: leave it for next time. + let _ = std::fs::remove_file(e.path()); + } + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id())); + // The viewer reads asynchronously, so this cannot use immediate drop cleanup. Never + // truncate an existing path, and remove incomplete files or refused launches immediately. + use std::io::Write as _; + let mut file = + std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| format!("can't create {}: {e}", path.display()))?; + if let Err(e) = file.write_all(pdf).and_then(|_| file.flush()) { + drop(file); + let _ = std::fs::remove_file(&path); + return Err(format!("can't write {}: {e}", path.display())); + } + Ok(path) +} + +/// Run `c` → its output, or its error message. +fn output(c: &mut Command) -> Result { + let program = c.get_program().to_string_lossy().to_string(); + let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?; + if out.status.success() { + return Ok(String::from_utf8_lossy(&out.stdout).into_owned()); + } + let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err }) +} + +/// PowerShell's `\t` lines → the printers. +fn windows_printers(text: &str) -> Vec { + text.lines() + .filter_map(|l| l.trim_end_matches('\r').split_once('\t')) + .filter(|(_, name)| !name.trim().is_empty()) + .map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") }) + .collect() +} + +/// `s` as a PowerShell single-quoted string. +fn ps_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "''")) +} + +fn powershell(script: &str) -> Command { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let mut c = Command::new( + std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into())) + .join("System32") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe"), + ); + c.args([ + "-NoProfile", + "-NonInteractive", + "-Command", + &format!("$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); {script}"), + ]) + .creation_flags(CREATE_NO_WINDOW); + c +} + +pub fn printers() -> Vec { + let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }"; + output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default() +} + +fn submit(job: &PrintJob, file: &std::path::Path) -> Result { + // The PDF viewer's print verb reads the file after this returns: it stays for a while. + let path = ps_quote(&file.to_string_lossy()); + let script = match job.printer { + Some(p) => format!( + "Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden", + ps_quote(&format!("\"{}\"", p.replace('"', ""))) + ), + None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"), + }; + output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})")) +} + +pub fn setup(_printer: Option<&str>) -> Result<(), String> { + let mut command = { + let mut command = Command::new("control.exe"); + command.arg("printers"); + command + }; + command.spawn().map(|_| ()).map_err(|e| e.to_string()) +} + +pub fn print(job: &PrintJob) -> Result { + let file = job_file(job.pdf)?; + let result = submit(job, &file); + if result.is_err() { + // No viewer accepted the launch, so no asynchronous reader needs this document. + let _ = std::fs::remove_file(file); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn powershell_lines_and_quotes_are_preserved() { + assert_eq!(windows_printers("True\tOffice Laser\r\nFalse\t "), [Printer { name: "Office Laser".into(), default: true }]); + assert_eq!(ps_quote("it's.pdf"), "'it''s.pdf'"); + } + #[test] + fn job_files_hold_the_pdf_for_the_async_viewer() { + let path = job_file(b"%PDF-1.7 test").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test"); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index 690ef5ac..27a9c679 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -290,7 +290,19 @@ mod tests { app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap(); let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned(); app.run("document.save", json!({"path": doc})).unwrap(); + let original_stamp = std::fs::metadata(&pic).unwrap().modified().unwrap(); + let mark_modified = |seconds| { + // Link stamps use milliseconds. Equal-size writes can finish within one tick; + // advance the fixture's mtime explicitly rather than relying on a sleep or load. + std::fs::OpenOptions::new() + .write(true) + .open(&pic) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(original_stamp + std::time::Duration::from_secs(seconds))) + .unwrap(); + }; std::fs::write(&pic, png([0, 0, 200])).unwrap(); + mark_modified(2); crate::io::open_path(&mut app, &doc).unwrap(); let d = app.ui.dialog.as_ref().expect("asks"); assert_eq!((d.kind.as_str(), d.str("__command")), (super::super::confirm::KIND, "links.update".to_string())); @@ -299,8 +311,10 @@ mod tests { // Manually: no question. app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap(); std::fs::write(&pic, png([0, 200, 0])).unwrap(); + mark_modified(4); crate::io::open_path(&mut app, &doc).unwrap(); assert!(app.ui.dialog.is_none()); + assert_eq!(app.run("links.check", json!({})).unwrap()["modified"], 1); let _ = std::fs::remove_dir_all(dir); } } diff --git a/crates/ui-egui/src/print.rs b/crates/ui-egui/src/print.rs index 8c92e45a..12e6e73a 100644 --- a/crates/ui-egui/src/print.rs +++ b/crates/ui-egui/src/print.rs @@ -28,12 +28,20 @@ pub struct PrintJob<'a> { pub pdf: &'a [u8], } +/// Submission callable from the desktop background worker. +pub type SharedPrintFn = std::sync::Arc) -> Result + Send + Sync>; + /// The platform's printing ([`crate::Services::print`]). pub trait PrintService { /// The printers the system knows (none: only the default one, or the browser's dialog). fn printers(&mut self) -> Vec; /// Send `job` to its printer → what happened, for the status bar. fn print(&mut self, job: &PrintJob) -> Result; + /// Desktop services can submit on the existing background worker; browser services stay + /// on the UI thread to invoke their print dialog. + fn shared_print(&self) -> Option { + None + } /// Whether Setup… can open the system's printer settings. fn has_setup(&self) -> bool { false @@ -88,6 +96,35 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { let title = app.session.active().map(|d| d.title()).unwrap_or_default(); let printer = s("printer").filter(|n| !n.is_empty()); let service = app.services.print.as_mut().ok_or("no print service")?; + if let Some(submit) = service.shared_print() { + let pending_printer = printer.clone(); + let mut pending = crate::background::run( + app, + true, + format!("Printing {title}"), + None, + move |_| { + submit(&PrintJob { printer: printer.as_deref(), title: &title, pdf: &pdf }).map(|note| json!({ "note": note, "printer": printer })) + }, + move |app, result| { + let receipt = result?; + app.status(receipt.get("note").and_then(Value::as_str).unwrap_or_default()); + Ok(json!({ "printer": receipt["printer"], "printed": true })) + }, + )?; + if pending.get("background").and_then(Value::as_bool) == Some(true) { + // Acceptance by the worker is not acceptance by the spooler. + pending["printer"] = json!(pending_printer); + pending["printed"] = json!(false); + } + // Preserve the engine's pages, byte count, format and warnings in automation replies. + if let Some(fields) = pending.as_object() { + for (key, value) in fields { + v[key] = value.clone(); + } + } + return Ok(v); + } let done = service.print(&PrintJob { printer: printer.as_deref(), title: &title, pdf: &pdf })?; app.status(done); v["printer"] = json!(printer); diff --git a/crates/ui-egui/src/tests_printps.rs b/crates/ui-egui/src/tests_printps.rs index 2814c565..a3aef360 100644 --- a/crates/ui-egui/src/tests_printps.rs +++ b/crates/ui-egui/src/tests_printps.rs @@ -63,3 +63,55 @@ fn postscript_is_saved_not_printed() { assert_eq!(*sent.borrow(), 1); assert!(app.run("file.print", json!({"format": "ps2"})).is_err()); } + +/// Submission stays off the frame and the eventual queue receipt/refusal reaches the status bar. +#[test] +fn desktop_print_pending_receipt_and_refusal_are_distinct() { + use std::sync::{Arc, Mutex, mpsc}; + struct AsyncPrinter { + release: Arc>>, + refused: bool, + } + impl PrintService for AsyncPrinter { + fn printers(&mut self) -> Vec { + Vec::new() + } + fn print(&mut self, _: &PrintJob) -> Result { + Err("UI thread must not submit".into()) + } + fn shared_print(&self) -> Option { + let release = self.release.clone(); + let refused = self.refused; + Some(Arc::new(move |job| { + release.lock().unwrap().recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + assert_eq!(job.printer, Some("Office Laser")); + assert!(job.pdf.starts_with(b"%PDF-")); + if refused { Err("queue refused this job".into()) } else { Ok("Sent to Office Laser".into()) } + })) + } + } + for refused in [false, true] { + let (release, wait) = mpsc::channel(); + let services = Services { + print: Some(Box::new(AsyncPrinter { release: Arc::new(Mutex::new(wait)), refused })), + write_shared: Some(Arc::new(|_, _| Err("printing must not write a document".into()))), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({ "width": 100, "height": 80 })).unwrap(); + let pending = app.run("file.print", json!({ "printer": "Office Laser" })).unwrap(); + assert_eq!(pending["background"], true); + assert_eq!(pending["printed"], false); + assert_eq!(pending["pages"], 1); + assert!(pending["bytes"].as_u64().is_some_and(|bytes| bytes > 0)); + assert!(!app.ui.status.starts_with("Sent")); + release.send(()).unwrap(); + crate::background::wait_all(&mut app); + assert!(app.background.jobs.is_empty()); + if refused { + assert!(app.ui.status.contains("queue refused this job")); + } else { + assert_eq!(app.ui.status, "Sent to Office Laser"); + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 5bca67dc..ceee01ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, written from the workspace as it is on origin/main `01e165af`) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (desktop printing uses craft-print; Windows 7 fallback retained) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft is built today. The design rationale lives in the local, gitignored `plan/architecture.md`; this file describes what is committed. Day-to-day rules (clean-room, never crash, quality gates) are in @@ -52,6 +52,13 @@ down to v1 and from the old `.drawcraft` name. The `FORMATS` table in `crates/en lists every format read or written; importers are fuzzed (`crates/engine/tests/import_fuzz.rs`). See [file-format-parity.md](file-format-parity.md). +Desktop printer discovery and PDF submission use `craft-print`, pinned to an immutable +craft-libs revision. The engine still owns sheet composition, copies, separations and colour +management. Submission runs on the existing background worker; its receipt or refusal +reaches the status bar, and Quit waits for pending submissions. Windows 7 retains WMI discovery +and the installed viewer's Print/PrintTo verb; +its target excludes the shared backend because Windows.Data.Pdf is unavailable there. + ## Agent control - **Control channel:** JSON lines on a local TCP port (`--control 7979`), with real egui pointer and keyboard @@ -69,5 +76,6 @@ saves copies in the background. See [development.md](development.md#robustness-v | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Desktop PDF spooler shared through craft-print; exact physical sizes, composed copies and Windows 7 fallback retained. Scores and remaining gaps unchanged. | | 2026-10-10 | minor | Added the `three-d` crate | | 2026-10-10 | major | First version, from the workspace, the layering table and the existing docs | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e66732c5..10e5a97d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -612,7 +612,10 @@ print queue (the web: the browser's print dialog), else, with `toFile` or no pri PDF. With `discard: true` it is Done: it only keeps the settings. Settings that can't print (a missing artboard, a range of no tiles) answer with an error and keep the dialog open. `file.print {settings?, printer?, toFile?, path?}` prints without the dialog; `print.printers` lists the printers and `print.printerSetup {printer?}` opens their system -settings (Setup…). +settings (Setup…). Desktop submission returns `{background: true, printed: false, printer, status}` +while the spooler runs on a worker. `ui.inspect` lists it under `background`; completion or refusal +appears in `ui.status`. Quit waits for submissions. PDF/PostScript export and the browser's print +dialog retain their existing command results. Print presets: in the `print` dialog, `preset` names the print preset whose settings were loaded last; setting it (`ui.dialog.set {field: "preset", value: "Posters"}`) loads that preset's settings, and the list reads `[Custom]` once diff --git a/docs/roadmap.md b/docs/roadmap.md index 83b6a87d..837c9acb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -17,6 +17,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | Core workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---| +| 2026-10-12 | minor | Desktop PDF spooler shared through craft-print; exact physical sizes, composed copies and Windows 7 fallback retained. Scores and remaining gaps unchanged. | | Draw and edit paths precisely: Pen, Curvature, Direct Selection on anchors and handles, smart guides and snapping, Pathfinder | yes | `crates/tools/src/pen.rs`, `direct.rs`, `guides.rs` and their tests; 10 exact Pathfinder booleans; [ui-parity.md](ui-parity.md). Shape Builder misses regions in some files (#937), Pathfinder covers the same results | 0 | | Paint and style: fills and strokes, global and spot swatches, gradients, Appearance, transparency, graphic styles | yes | M3 done (92 tasks); area scores 92–97% in [target-app-parity.md](target-app-parity.md#feature-areas) | 0 | | Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); hyphenation and justification options missing (not blocking) | 0 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 897eed84..99d14504 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -487,6 +487,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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; PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). 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). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - 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. + - Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft; Windows 7 retains WMI and the installed PDF viewer. The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged. - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), 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). - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). @@ -506,6 +507,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Desktop PDF spooler shared through craft-print; exact physical sizes, composed copies and Windows 7 fallback retained. Scores and remaining gaps unchanged. | | 2026-10-11 | minor | Image Trace: an image over 64 megapixels (up to 192) is traced scaled down to 64 megapixels with its paths at the image's own size, instead of being refused; score and hours unchanged (estimated) | | 2026-10-11 | minor | Czech render-site follow-up: file-dialog filters, diagnostics, preset numbering, contributor tooltips/plurals, gradient grammar and contextual printer mark styles; estimates unchanged | | 2026-10-12 | minor | Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged | diff --git a/docs/windows7.md b/docs/windows7.md index 52fa0ece..33018d69 100644 --- a/docs/windows7.md +++ b/docs/windows7.md @@ -6,6 +6,10 @@ and the VectorCraft maintainers have not verified it on Windows 7. This separate build targets **64-bit Windows 7 SP1**. It uses OpenGL instead of wgpu, and omits the native AccessKit accessibility adapter. Printer discovery supports the PowerShell version included with Windows 7, and printer setup opens Control Panel. +Printing retains the PDF viewer's Print/PrintTo verb on this build: Windows 7 has no +Windows.Data.Pdf renderer. The win7 target excludes `craft-print` from its dependency +tree; normal desktop builds use the shared spooler. The `windows7` feature and dedicated win7 target must be selected together; the +existing compile-time guards reject mixing this feature with the ordinary Windows target. The normal desktop and web builds retain their existing defaults. A vendor graphics driver providing **OpenGL 3.3 or newer** is required; the Windows built-in generic display driver is insufficient. GPU selection in Preferences and `WGPU_POWER_PREF` do not control this OpenGL build. @@ -13,7 +17,7 @@ in Preferences and `WGPU_POWER_PREF` do not control this OpenGL build. ## Build or download In your GitHub fork, open **Actions → Windows 7 x64 compatibility → Run workflow** (it -also runs weekly, never on pull requests). +also runs weekly and on pull requests changing the compatibility path). When it succeeds, download the `vectorcraft-windows7-x64-portable` artifact, extract the contained portable ZIP, then run `vectorcraft.exe` on Windows 7. The CLI is included. No MSI is provided: the existing installer remains for modern Windows. diff --git a/packaging/windows/windows7.ps1 b/packaging/windows/windows7.ps1 index cab9db70..81e9d66b 100644 --- a/packaging/windows/windows7.ps1 +++ b/packaging/windows/windows7.ps1 @@ -37,7 +37,7 @@ try { $CargoArgs = @('--locked') + $Patch + @('--target', $Target, '--no-default-features', '--features', 'vectorcraft/windows7') $Tree = cargo "+$Toolchain" tree @CargoArgs -p vectorcraft -e normal --prefix none if ($LASTEXITCODE -ne 0) { throw 'dependency inspection failed' } - if ($Tree -match '^(wgpu|wgpu-core|wgpu-hal|accesskit_windows) v') { throw 'unsupported Windows backend in compatibility build' } + if ($Tree -match '^(wgpu|wgpu-core|wgpu-hal|accesskit_windows|craft-print) v') { throw 'unsupported Windows backend in compatibility build' } if (-not ($Tree -match '^windows-link v[^ ]+ \(.*vendor[\\/]windows-link\)')) { throw 'the patched windows-link is not in use' } # The ordinary pc-windows-msvc standard library requires Windows 10. Rebuild std for win7. [Environment]::SetEnvironmentVariable($FlagName, '-C target-feature=+crt-static')