From ec42e542e909ba0ff75a4efb9a95012be11124ee Mon Sep 17 00:00:00 2001 From: Xus Badia Date: Thu, 8 Oct 2026 15:12:32 +0200 Subject: [PATCH 01/71] macOS DMG: branded window background and icon layout The DMG opened as a plain Finder window. It now shows the app icon's figure on the DesignCraft colour, with DesignCraft.app and the Applications link side by side. Same approach as PhotoCraft's and VectorCraft's (storytold/photocraft#1201, storytold/vectorcraft#493). - packaging/macos/dmg/: background.svg (source; text outlined, no paper grain, the figure an of assets/app-icon), background.tiff (1x + 2x HiDPI, 16-colour palette, Deflate, sRGB; ~90 KB) and dmg-layout.DS_Store (Finder view settings: window, icon size 128, positions, background). - generate.py writes the TIFF and the layout on any OS, byte-for-byte reproducible, with nothing from the machine that ran it (no paths, users, disk names or volume UUIDs). - package.sh copies them into the staging folder; the image is still built with hdiutil makehybrid. Nothing extra runs in the signing job. - The volume name drops the version: the layout finds the background through an alias that includes the volume name. The DMG file name keeps the version. Co-Authored-By: Claude Opus 5.5 --- ASSETS.md | 2 + packaging/macos/dmg/README.md | 44 +++++++ packaging/macos/dmg/background.svg | 31 +++++ packaging/macos/dmg/background.tiff | Bin 0 -> 92736 bytes packaging/macos/dmg/dmg-layout.DS_Store | Bin 0 -> 16388 bytes packaging/macos/dmg/generate.py | 158 ++++++++++++++++++++++++ packaging/macos/package.sh | 7 +- 7 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 packaging/macos/dmg/README.md create mode 100644 packaging/macos/dmg/background.svg create mode 100644 packaging/macos/dmg/background.tiff create mode 100644 packaging/macos/dmg/dmg-layout.DS_Store create mode 100644 packaging/macos/dmg/generate.py diff --git a/ASSETS.md b/ASSETS.md index cfe1f807..f243fdb3 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -60,3 +60,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | The mark; its outline is drawn in code by `crates/ui-egui/src/about.rs` (About splash, start screen) | | `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Mark for light backgrounds | | `docs/brand/LICENSE-brand.txt` | (licence text) | ArtCraft Team | — | Terms for the ArtCraft Marks in `docs/brand/` | +| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the DesignCraft app icon (`assets/app-icon/designcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | DesignCraft macOS DMG window background for the Finder window; `packaging/macos/dmg/generate.py` renders it to `background.tiff` (2026-10-08) | +| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") | diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md new file mode 100644 index 00000000..85bfcc77 --- /dev/null +++ b/packaging/macos/dmg/README.md @@ -0,0 +1,44 @@ +# macOS DMG window + +What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the +`Applications` link side by side. `package.sh` copies these files into the image; nothing here is +generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no +Finder scripting on CI, nothing extra installed in the signing job). + +| File | What | +|---|---| +| `background.svg` | Source of the background: the app icon (`assets/app-icon/designcraft.svg`, linked, not copied) cropped as a cover on the DesignCraft colour field (`#7bb51c`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. | +| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. | +| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, DesignCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. | +| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. | + +## The volume name has no version + +The mounted volume is called `DesignCraft`, not `DesignCraft `. The layout points at the background +through an alias that includes the volume name, and Finder resolves it by that name: with a +versioned name the window keeps its size and icon positions but shows no background (tested). +The DMG file name (`designcraft--macos-.dmg`) still carries the version. + +## Rules + +- **Finder draws the icon labels in black in light and dark mode** when a window has a background, + so the area under both icons stays light (Paper). +- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of + the label strip under it. + +## Regenerate + +Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS: + +```sh +pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3 +python3 packaging/macos/dmg/generate.py # needs resvg on PATH +``` + +Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the +SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background +alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG, +outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt +title bar; the content area is 660 × 400. + +This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493). diff --git a/packaging/macos/dmg/background.svg b/packaging/macos/dmg/background.svg new file mode 100644 index 00000000..99c2fa24 --- /dev/null +++ b/packaging/macos/dmg/background.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/macos/dmg/background.tiff b/packaging/macos/dmg/background.tiff new file mode 100644 index 0000000000000000000000000000000000000000..0a38d639ecdd71858e1663570e90d97c8ee5a8a2 GIT binary patch literal 92736 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%0Ae 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 JPi2M8`9Glnu+IPh literal 0 HcmV?d00001 diff --git a/packaging/macos/dmg/dmg-layout.DS_Store b/packaging/macos/dmg/dmg-layout.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..b9847fa15b84433675339c47b804e7285e9bf76c GIT binary patch literal 16388 zcmeI2J!}&(6o8+v1ya(IOH-f)hM))uM3sgDRn-APS`{)Ck*c)fr`#pZaq88M?k+8* zs??1ICd7oq%F54(5GxBSD;r{9U||KG&li$LLPCfI@qCKkv;Cg!_nq?NmjeJ+YSvBx zB%u8=`>}BkAe%&~ilGJULdgKkxJiNt5CI}U1c(3;_=gGf=)EV@_x&;a-XHjfW1+1R z0V42c1X%A(Wu}C__#^+9MU55lrR27jkvUe;IRRxac1H#EpN9q1i>edlww9=2Ve#>T z#cnJ*i>Q9>${-ut6=S)rrFI7vt%~;Xs!Sbau&{V#mQ^im7b8Ix5g-CYfCvx)B0vO) z01+SpM1Tko0U|&IhyW4z9|`FH_Vf07(6BveBZ6mk!*heMP2!K9Gc>gSz`=vVBZrnO-?xOrOB>5(#}j<5DLEj{U% zye7w}yLvDBoQq|1u&etDukElcuUZbj;JH?Cvu!!Q(csq{->uEn>j4iJJM^VUuQaTx zE1X5QDn-S;%Wrs=&AmBMl3^%Um(5gw^?K9}`D$6zY*NQ*B?JW*uHc!dIV%7)ea>e zy7hY7>bJE+YQC2BcPsz{aU!?X3(0(Y9hFGN#BS`B?$CAKcm(%%RsVl{d;Saz zjV(y8xypmFj!mg3O(WL|FjrjCYOZ)oD}9Mvv7l8`<-t$DX(+=j+}nM43{T-Dyn(my z9zMZW_{K79m>o498jp-8##7^&@!WV}ywcuA>rHZ4w_COG8Z~^AtW|vBN#R5-fPT0D z4ZJ*zw2Cn*1`;C<%ATyo9|xFDH_oB`J9W2IBv?|v;FHHWaK4@1Y_`ignu01KKm>>Y s5g-CYfCvx)B0vO)01+SpM1Tko0U|&IhyW2F0z`la5CI}U1pXp{A7aEL)Bpeg literal 0 HcmV?d00001 diff --git a/packaging/macos/dmg/generate.py b/packaging/macos/dmg/generate.py new file mode 100644 index 00000000..6bad87a0 --- /dev/null +++ b/packaging/macos/dmg/generate.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here). + +Needs resvg on PATH and: + pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3 + +Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing +from the machine that made them: no local paths, user or disk names, dates or volume UUIDs. + + python3 packaging/macos/dmg/generate.py +""" + +import datetime +import os +import shutil +import subprocess +import sys +import tempfile + +from ds_store import DSStore +from mac_alias import Alias, TargetInfo, VolumeInfo +from PIL import Image, ImageCms + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# Keep in sync with package.sh (volume name) and README.md (layout). +VOLUME = "DesignCraft" +WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size +TITLE_BAR = 32 +ICON_SIZE = 128 +PALETTE = 16 # colours in background.tiff +ICONS = {"DesignCraft.app": (326, 205), "Applications": (574, 205)} +# A fixed date for the alias and the colour profile. Finder never matches it against the image +# (each build is a new volume): it finds the background by volume name and path. +FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc) + + +def render(svg, width, out): + # The SVG's text is already outlined and no fonts are loaded, so every machine draws the + # same pixels. + subprocess.run( + ["resvg", "--skip-system-fonts", "-w", str(width), svg, out], + check=True, + ) + with Image.open(out) as im: + return im.convert("RGB") + + +def quantize(im): + # No dithering: dither noise compresses badly and the artwork has flat fills. + return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE) + + +def srgb_profile(): + # LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed. + icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()) + d = FIXED_DATE + icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0)) + if icc[84:100] != bytes(16): + sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date") + return bytes(icc) + + +def write_tiff(svg, out): + """1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them.""" + with tempfile.TemporaryDirectory() as tmp: + one, two = ( + quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2) + ) + two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page + # Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette + # keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's. + one.save( + out, + "TIFF", + save_all=True, + append_images=[two], + compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate) + dpi=(72, 72), + icc_profile=srgb_profile(), + ) + with Image.open(out) as im: + im.seek(1) + if im.info.get("dpi") != (144, 144): + sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)") + strips = [] + for page in (0, 1): + im.seek(page) + strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts + # TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff + # leaves the padding byte after it uninitialised, so the file changed from run to run with + # identical pixels. Zero it: nothing refers to that byte. + starts = {o for o, _ in strips} + with open(out, "r+b") as f: + for o, n in strips: + if (o + n) % 2 and o + n not in starts: + f.seek(o + n) + f.write(bytes(1)) + + +def background_alias(): + # What Finder stores for /.background/background.tiff on the volume, minus anything about the + # machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable), + # the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids + # are only hints, as the volume's creation date. + volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0") + volume.posix_path = f"/Volumes/{VOLUME}" + target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0") + target.folder_name = ".background" + target.cnid_path = [25] + target.carbon_path = f"{VOLUME}:.background:\0background.tiff" + target.posix_path = "/.background/background.tiff" + return Alias(volume=volume, target=target).to_bytes() + + +def write_ds_store(out): + if os.path.exists(out): + os.remove(out) + with DSStore.open(out, "w+") as d: + d["."]["bwsp"] = { + "ShowStatusBar": False, + "ShowToolbar": False, + "ShowTabView": False, + "ContainerShowSidebar": False, + "ShowSidebar": False, + "WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}", + } + d["."]["icvp"] = { + "viewOptionsVersion": 1, + "backgroundType": 2, + "backgroundImageAlias": background_alias(), + "backgroundColorRed": 1.0, + "backgroundColorGreen": 1.0, + "backgroundColorBlue": 1.0, + "gridOffsetX": 0.0, + "gridOffsetY": 0.0, + "gridSpacing": 100.0, + "arrangeBy": "none", + "showIconPreview": True, + "showItemInfo": False, + "labelOnBottom": True, + "textSize": 16.0, + "iconSize": float(ICON_SIZE), + } + d["."]["vSrn"] = ("long", 1) + for name, pos in ICONS.items(): + d[name]["Iloc"] = pos + + +def main(): + if not shutil.which("resvg"): + sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)") + write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff")) + write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store")) + + +if __name__ == "__main__": + main() diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 42dea96b..38fa2afb 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -131,10 +131,15 @@ STAGE="$WORK/dmg" mkdir -p "$STAGE" ditto "$APP" "$STAGE/DesignCraft.app" ln -s /Applications "$STAGE/Applications" +# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md). +mkdir -p "$STAGE/.background" +cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff" +cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" rm -f "$DMG" "$WORK/raw.dmg" # makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, # which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -hdiutil makehybrid -hfs -hfs-volume-name "DesignCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" +# The volume name has no version: .DS_Store finds the background through an alias that includes it. +hdiutil makehybrid -hfs -hfs-volume-name "DesignCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" From 4e0e3949f1eb91c874f2a47d58c2187031f4c97e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 21:02:05 -0400 Subject: [PATCH 02/71] Bump version to 0.5.0 (from 0.4.0) for release; refresh contributors Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 40 +++---- Cargo.toml | 2 +- contributors/contributors.json | 187 +++++++++++++++++++++++++++------ 3 files changed, 173 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 67134162..cfc7d351 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -855,7 +855,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-engine", "designcraft-ui-egui", @@ -871,7 +871,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -887,7 +887,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.4.0" +version = "0.5.0" dependencies = [ "kurbo", "moxcms", @@ -899,7 +899,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -915,7 +915,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-color", "designcraft-geom", @@ -927,7 +927,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -957,7 +957,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -966,7 +966,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.4.0" +version = "0.5.0" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -978,7 +978,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-doc", "serde", @@ -989,7 +989,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.4.0" +version = "0.5.0" dependencies = [ "flo_curves", "kurbo", @@ -1002,7 +1002,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1014,7 +1014,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-color", "designcraft-fonts", @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1038,7 +1038,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1060,7 +1060,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1078,7 +1078,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-doc", "quick-xml 0.38.4", @@ -1087,7 +1087,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1099,7 +1099,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1123,7 +1123,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.4.0" +version = "0.5.0" dependencies = [ "designcraft-engine", "designcraft-ui-egui", @@ -5562,7 +5562,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.4.0" +version = "0.5.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index c5918853..af62549e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.4.0" +version = "0.5.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/contributors/contributors.json b/contributors/contributors.json index c21fcc6e..cf12c54c 100644 --- a/contributors/contributors.json +++ b/contributors/contributors.json @@ -2,49 +2,49 @@ "schema": 1, "repo": "storytold/designcraft", "branch": "main", - "head": "f17397c305ecda9d644105f7a4e9b9d65d34bb60", - "head_date": "2026-10-07T18:55:09Z", - "commits": 294, + "head": "66c7ce7e96ec286a091878cc2541ee8369aa79d6", + "head_date": "2026-10-08T13:12:32Z", + "commits": 350, "unattributed_commits": 0, "contributors": [ { "login": "echelon", "people_entry": true, "github_name": null, - "prs": 22, - "commits": 260, - "lines_added": 99427, - "lines_deleted": 6061, + "prs": 25, + "commits": 270, + "lines_added": 100858, + "lines_deleted": 6338, "binary_added": 42, "binary_deleted": 2, "first_commit": "2026-10-01T13:00:02Z", - "last_commit": "2026-10-07T18:55:09Z" + "last_commit": "2026-10-08T13:50:30Z" }, { "login": "storybored379", "people_entry": false, "github_name": null, - "prs": 4, - "commits": 4, - "lines_added": 150, - "lines_deleted": 11, + "prs": 5, + "commits": 5, + "lines_added": 181, + "lines_deleted": 14, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-03T16:31:40Z", - "last_commit": "2026-10-05T03:53:26Z" + "last_commit": "2026-10-06T07:03:03Z" }, { "login": "CameronGilroy", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 18, - "lines_added": 5236, - "lines_deleted": 381, + "prs": 2, + "commits": 23, + "lines_added": 8982, + "lines_deleted": 609, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-05T06:15:59Z", - "last_commit": "2026-10-05T18:56:22Z" + "last_commit": "2026-10-07T10:22:06Z" }, { "login": "midasdf", @@ -63,14 +63,14 @@ "login": "ShaoWenbinSaleh", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 2, - "lines_added": 6020, - "lines_deleted": 1278, + "prs": 3, + "commits": 10, + "lines_added": 10282, + "lines_deleted": 2843, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-05T19:15:57Z", - "last_commit": "2026-10-06T14:32:13Z" + "last_commit": "2026-10-07T11:47:38Z" }, { "login": "japbcoelho", @@ -84,6 +84,123 @@ "binary_deleted": 0, "first_commit": "2026-10-06T01:43:44Z", "last_commit": "2026-10-06T01:45:41Z" + }, + { + "login": "aiongg", + "people_entry": false, + "github_name": null, + "prs": 3, + "commits": 19, + "lines_added": 5933, + "lines_deleted": 735, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-06T15:11:56Z", + "last_commit": "2026-10-07T13:18:17Z" + }, + { + "login": "epfabian", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 50, + "lines_deleted": 4, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-06T18:31:52Z", + "last_commit": "2026-10-06T18:31:52Z" + }, + { + "login": "EthanSuquet", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 2, + "lines_added": 689, + "lines_deleted": 5, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-06T19:15:09Z", + "last_commit": "2026-10-06T20:13:06Z" + }, + { + "login": "Luskish", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 259, + "lines_deleted": 32, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T02:49:05Z", + "last_commit": "2026-10-07T02:49:05Z" + }, + { + "login": "denizkoekden", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 7, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T19:23:57Z", + "last_commit": "2026-10-07T19:23:57Z" + }, + { + "login": "skylartaylor", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 4, + "lines_added": 528, + "lines_deleted": 28, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T20:27:50Z", + "last_commit": "2026-10-07T22:10:32Z" + }, + { + "login": "RicSchonfelder", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 1059, + "lines_deleted": 4, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T20:43:34Z", + "last_commit": "2026-10-07T20:43:34Z" + }, + { + "login": "h-a-n-a-s-h-i", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 2, + "lines_added": 42, + "lines_deleted": 42, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T12:13:12Z", + "last_commit": "2026-10-08T14:02:08Z" + }, + { + "login": "XusBadia", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 241, + "lines_deleted": 1, + "binary_added": 2, + "binary_deleted": 0, + "first_commit": "2026-10-08T13:12:32Z", + "last_commit": "2026-10-08T13:12:32Z" } ], "models": [ @@ -92,9 +209,18 @@ "company": "Anthropic", "model": "Claude Opus", "version": "5.5", - "commits": 266, - "lines_added": 83821, - "lines_deleted": 6091 + "commits": 282, + "lines_added": 86274, + "lines_deleted": 6420 + }, + { + "id": "openai/codex", + "company": "OpenAI", + "model": "Codex", + "version": "", + "commits": 2, + "lines_added": 57, + "lines_deleted": 8 }, { "id": "anthropic/claude-opus/4.8", @@ -104,15 +230,6 @@ "commits": 1, "lines_added": 1633, "lines_deleted": 3 - }, - { - "id": "openai/codex", - "company": "OpenAI", - "model": "Codex", - "version": "", - "commits": 1, - "lines_added": 26, - "lines_deleted": 5 } ] } From aeabc82ca64f7398d8e9d2ee5b724769954c8f12 Mon Sep 17 00:00:00 2001 From: jahala Date: Sat, 10 Oct 2026 06:09:30 +0100 Subject: [PATCH 03/71] Images: PSD decode passes clippy on Rust 1.98 (#136) Rust 1.98 adds the clippy lint chunks_exact_to_as_chunks. It flags two loops in the PSD decoder, so `cargo xtask ci` stops at the clippy step on unmodified main for anyone on current stable. Both loops now use as_chunks / as_chunks_mut (stable since 1.88, below the workspace rust-version 1.90). The chunks are arrays, so the try_from that could never fail goes away too. Decoded pixels are unchanged; the existing PSD tests cover both paths. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/images/src/psd.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/images/src/psd.rs b/crates/images/src/psd.rs index 7da9e3ac..18200527 100644 --- a/crates/images/src/psd.rs +++ b/crates/images/src/psd.rs @@ -144,8 +144,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Option { let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; let byte = |v: f32| v.round().clamp(0.0, 255.0) as u8; let white = mode.white(); - for (i, px) in rgba.chunks_exact_mut(4).enumerate() { - let Ok(px) = <&mut [u8; 4]>::try_from(px) else { continue }; + for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { let a = alpha.get(i).copied().unwrap_or(255); let mut v = px.map(f32::from); // The composite is matted against white where it's partly transparent: un-matte it. @@ -314,7 +313,7 @@ fn fill(dst: &mut [u8], slot: usize, step: usize, src: &[u8], depth: u16) { match depth { 1 => dst.zip(src.iter().flat_map(|&b| (0..8).rev().map(move |i| if (b >> i) & 1 == 1 { 0 } else { 255 }))).for_each(|(d, s)| *d = s), 16 => dst - .zip(src.chunks_exact(2).map(|c| c.iter().fold(0u32, |n, &b| n << 8 | u32::from(b)))) + .zip(src.as_chunks::<2>().0.iter().map(|c| c.iter().fold(0u32, |n, &b| n << 8 | u32::from(b)))) .for_each(|(d, s)| *d = ((s * 255 + 32_767) / 65_535) as u8), _ => dst.zip(src).for_each(|(d, &s)| *d = s), } From 07b95eb7b394f24ce96da62de6775f84bbf44d2c Mon Sep 17 00:00:00 2001 From: Haseom Shin Date: Sat, 10 Oct 2026 14:09:36 +0900 Subject: [PATCH 04/71] Compose: don't panic when a hyphenated paragraph starts with a no-break letter (#115) --- crates/compose/src/lib.rs | 5 +++++ crates/compose/src/tests.rs | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5a3056d8..3db5070d 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2080,6 +2080,11 @@ fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions: while j < glyphs.len() && (glyphs[j].is_letter() || glyphs[j].len == 0 || glyphs[j].ch == shape::SOFT_HYPHEN) && !glyphs[j].no_break { j += 1; } + // A no-break letter can't start a hyphenatable word. + if j == i { + i += 1; + continue; + } // A discretionary hyphen in a word (or right before it) replaces automatic hyphenation. let discretionary = glyphs[i..j].iter().any(|g| g.ch == shape::SOFT_HYPHEN) || (i > 0 && glyphs[i - 1].ch == shape::SOFT_HYPHEN); let (a, b) = (glyphs[i].byte, glyphs[j - 1].byte + glyphs[j - 1].len); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 549e7bcc..256d9a13 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -313,6 +313,18 @@ fn discretionary_hyphen_replaces_automatic_points() { assert!(lines.iter().filter(|l| l.hyphenated).count() <= 1); } +#[test] +fn hyphenation_skips_a_no_break_word_at_the_start() { + // Used to index before the first glyph and panic. + let text = "Extraordinarily long words wrap in a narrow column"; + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 60.0, 1000.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..15, |f| f.over.no_break = Some(true)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + // The no-break word stays whole on the first line. + let first = all_lines(&cs)[0]; + assert!(!first.hyphenated && first.range.end >= 15, "{:?}", &text[first.range.clone()]); +} + /// Story of `n` one-line filler paragraphs followed by `extra` paragraphs, in a 2-column frame. fn keep_doc(fillers: usize, extra: &[&str], height: f64) -> (Document, StoryId, Vec) { let mut parts: Vec = (0..fillers).map(|k| format!("Filler {k}")).collect(); From 9fbd6d24025597b09d9c872a29e90c9aa4e76ca8 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:09:41 -0400 Subject: [PATCH 05/71] Show an alert when a file can't be opened or placed (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Opening an InDesign document says to export IDML (#21) .indd and .indt files (recognised by their published 16-byte signature) failed to open or place with a confusing format error. They now explain that the format is closed and how to export IDML from InDesign instead. File › Open offers .indd and .indt files (desktop and web), and dropping one on the window opens it like a .designcraft or .idml file. A file that can't be opened or placed from File › Open, Place, a drop, the command line or the control channel's app.open shows an alert with the reason, as well as the status line. * File open: an .indd file is an unreadable file like any other Remove the INDD signature check and message, and .indd/.indt from the Open filters and drop routing. The alert tests use a generic unreadable file. * Control channel: a failed app.open answers with the error and opens no alert An alert waits for a click that a control client can't give. app.open reports the error in its response, as engine.execute does; File › Open, drops and the command line still show the alert. --- apps/designcraft/src/main.rs | 2 +- crates/ui-egui/src/dialogs.rs | 25 +++++-- crates/ui-egui/src/i18n.rs | 20 ++++++ crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/lib.rs | 130 ++++++++++++++++++++++++++++++---- crates/ui-egui/src/menus.rs | 2 +- 6 files changed, 160 insertions(+), 21 deletions(-) diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 101a5f5f..307bfe1a 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -245,7 +245,7 @@ fn main() -> eframe::Result { let _ = app.run("file.newSample", serde_json::json!({})); } for f in files { - if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) { + if let Err(e) = app.open_file("file.open", serde_json::json!({"path": f})) { eprintln!("designcraft: {f}: {e}"); } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 34832db1..450e64c2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -803,7 +803,9 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; let mut result: Option = None; + let alert_title = d.s("title"); let title = match d.id.as_str() { + "alert" => crate::i18n::tr(&app.ui.language, &alert_title), "ruby" => crate::i18n::tr(&app.ui.language, "Ruby"), "newDocument" => crate::i18n::tr(&app.ui.language, "New Document"), "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), @@ -837,7 +839,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }; egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| { ui.set_min_width(380.0); - ui.set_max_width(if d.id == "newDocument" && crate::i18n::is_rtl(&app.ui.language) { 380.0 } else { 640.0 }); + ui.set_max_width(match d.id.as_str() { + "alert" => 440.0, + "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0, + _ => 640.0, + }); if crate::i18n::is_rtl(&app.ui.language) { ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -1366,6 +1372,14 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } "documentSetup" => document_setup(app, ui, &mut d), + "alert" => { + let file = d.s("file"); + if !file.is_empty() { + crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0))); + ui.add_space(4.0); + } + crate::rtl::label(ui, d.s("message")); + } _ => {} } ui.add_space(12.0); @@ -1381,7 +1395,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { { result = Some(true); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // An alert only has OK. + let cancel = d.id != "alert" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Cancel"))).clicked(); + if cancel || ui.input(|i| i.key_pressed(egui::Key::Escape)) { result = Some(false); } }); @@ -1498,7 +1514,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "pdfImport" => { let crop = d.s("crop"); - app.run("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) + app.open_file("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) } "print" => { let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) }; @@ -1601,7 +1617,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run("document.preferences", doc) } "newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})), - "importOptions" => app.run( + "importOptions" => app.open_file( "file.place", json!({"path": d.s("path"), "removeStyles": d.b("removeStyles"), "styleConflicts": d.s("styleConflicts"), "styleMap": d.fields.get("map").cloned().unwrap_or(json!({}))}), ), @@ -1669,6 +1685,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { } app.run("footnote.options", p) } + "alert" => Ok(Value::Null), "findChange" if d.b("objectMode") => Ok(Value::Null), "findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})), id if id.starts_with("cmd:") => { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 878657a5..fbb5fd27 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -230,6 +230,26 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Business Card", ["Business Card", "Business Card", "Business Card", "Business Card", "名片"]), ("CMYK", ["CMYK", "CMYK", "CMYK", "CMYK", "CMYK"]), ("Cancel", ["Cancel", "Cancel", "Cancel", "Cancel", "取消"]), + ( + "Can't Open the File", + [ + "Datei kann nicht geöffnet werden", + "Impossible d'ouvrir le fichier", + "No se puede abrir el archivo", + "ファイルを開けません", + "无法打开文件", + ], + ), + ( + "Can't Place the File", + [ + "Datei kann nicht platziert werden", + "Impossible d'importer le fichier", + "No se puede colocar el archivo", + "ファイルを配置できません", + "无法置入文件", + ], + ), ("Center Content", ["Center Content", "Center Content", "Center Content", "Center Content", "内容居中"]), ("Change All", ["Change All", "Change All", "Change All", "Change All", "全部更改"]), ("Change to:", ["Change to:", "Change to:", "Change to:", "Change to:", "更改为:"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 783f33e5..34a50b57 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -167,6 +167,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Dock panel", "إرساء اللوحة"), ("OK", "موافق"), ("Cancel", "إلغاء"), + ("Can't Open the File", "تعذّر فتح الملف"), + ("Can't Place the File", "تعذّر إدراج الملف"), ("Apply", "تطبيق"), ("Done", "تم"), ("Reset", "إعادة ضبط"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7b474fa2..43a08167 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,8 +58,8 @@ pub struct Services { /// Hand bytes to the user as a named file (browser download). When set, Save uses it /// instead of writing to a path. pub download: Option, - /// Files delivered asynchronously, drained every frame: `.designcraft` → `file.openBytes`, - /// anything else → `file.place`. + /// Files delivered asynchronously, drained every frame: documents ([`opens_as_document`]) → + /// `file.openBytes`, `.ase` → `swatch.load`, anything else → `file.place`. pub inbox: Option, } @@ -539,10 +539,10 @@ impl DesignApp { self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages}))); Ok(Value::Null) } else { - self.run(cmd, json!({"path": path})) + self.open_file(cmd, json!({"path": path})) } } - Some(path) => self.run(cmd, json!({"path": path})), + Some(path) => self.open_file(cmd, json!({"path": path})), None => Ok(Value::Null), }; } @@ -561,11 +561,11 @@ impl DesignApp { let lower = name.to_ascii_lowercase(); let r = if lower.ends_with(".ase") { self.run("swatch.load", json!({"base64": b64})) - } else if lower.ends_with(".designcraft") || lower.ends_with(".idml") { + } else if opens_as_document(&name) { let title = name.rsplit_once('.').map_or(name.as_str(), |(stem, _)| stem); - self.run("file.openBytes", json!({"name": title, "base64": b64})) + self.open_file("file.openBytes", json!({"name": title, "base64": b64})) } else { - self.run("file.place", json!({"name": name, "base64": b64})) + self.open_file("file.place", json!({"name": name, "base64": b64})) }; if let Err(e) = r { self.status(format!("{name}: {e}")); @@ -636,18 +636,34 @@ impl DesignApp { } #[cfg(not(target_arch = "wasm32"))] for f in ctx.input(|i| i.raw.dropped_files.clone()) { - { - let p = f.path().to_string_lossy().to_string(); - if p.is_empty() { - continue; - } - let lp = p.to_ascii_lowercase(); - let cmd = if lp.ends_with(".designcraft") || lp.ends_with(".idml") { "file.open" } else { "file.place" }; - let _ = self.run(cmd, json!({"path": p})); + let p = f.path().to_string_lossy().to_string(); + if !p.is_empty() { + let _ = self.open_dropped(&p); } } } + /// A file dropped on the window: documents open, anything else is placed. + pub fn open_dropped(&mut self, path: &str) -> Result { + let cmd = if opens_as_document(path) { "file.open" } else { "file.place" }; + self.open_file(cmd, json!({"path": path})) + } + + /// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file + /// that can't be opened or placed gets an alert with the reason, as well as the status line. + pub fn open_file(&mut self, cmd: &str, params: Value) -> Result { + let file = match params.get("path").and_then(Value::as_str) { + Some(path) => std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()), + None => params.get("name").and_then(Value::as_str).unwrap_or_default().to_string(), + }; + let r = self.run(cmd, params); + if let Err(e) = &r { + let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" }; + self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e}))); + } + r + } + /// Inject synthetic events (one pointer event per frame). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { if self.synthetic.is_empty() { @@ -856,6 +872,13 @@ impl DesignApp { } } +/// Files that open as documents (when dropped or picked) rather than being placed: DesignCraft +/// and IDML. +pub fn opens_as_document(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + [".designcraft", ".idml"].iter().any(|ext| n.ends_with(ext)) +} + pub fn now_ms() -> f64 { #[cfg(not(target_arch = "wasm32"))] { @@ -883,4 +906,81 @@ mod tests { assert!(!ui.snap_view().snap_to_guides); assert_eq!(ui.snap_view().zone_px, 0.0); } + + /// A temporary file named `name` whose bytes aren't a document or a graphic. + fn unreadable_file(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("designcraft-ui-{}-{name}", std::process::id())); + std::fs::write(&path, b"neither a document nor a graphic").unwrap(); + path + } + + /// The open alert dialog: (title, message). + fn alert(app: &DesignApp) -> (String, String) { + let d = app.ui.dialog.as_ref().expect("an alert is open"); + assert_eq!(d.id, "alert"); + (d.fields["title"].as_str().unwrap().to_string(), d.fields["message"].as_str().unwrap().to_string()) + } + + #[test] + fn dropped_documents_open_and_the_rest_is_placed() { + for name in ["a.designcraft", "B.IDML"] { + assert!(opens_as_document(name), "{name}"); + } + for name in ["a.png", "b.pdf", "idml.txt", "c.idml.zip"] { + assert!(!opens_as_document(name), "{name}"); + } + } + + #[test] + fn dropping_an_unreadable_file_alerts() { + // A document opens, so it says it can't be opened. + let path = unreadable_file("broken.designcraft"); + let mut app = DesignApp::new(Session::new(), Services::default()); + let r = app.open_dropped(&path.to_string_lossy()); + let _ = std::fs::remove_file(&path); + let e = r.unwrap_err(); + let (title, message) = alert(&app); + assert_eq!(title, "Can't Open the File", "a document opens, it isn't placed"); + assert_eq!(message, e); + assert!(app.ui.status.contains(&e), "the status line says it too"); + assert!(app.session.documents().is_empty()); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "OK closes the alert"); + // Anything else is placed, so it says it can't be placed. + app.run("file.new", json!({})).unwrap(); + let path = unreadable_file("notes.xyz"); + let r = app.open_dropped(&path.to_string_lossy()); + let _ = std::fs::remove_file(&path); + let e = r.unwrap_err(); + assert_eq!(alert(&app), ("Can't Place the File".to_string(), e)); + } + + #[test] + fn file_open_failures_show_an_alert() { + let path = unreadable_file("open.designcraft"); + let picked = path.to_string_lossy().to_string(); + let services = Services { pick_open: Some(Box::new(move |_| Some(picked.clone()))), ..Default::default() }; + let mut app = DesignApp::new(Session::new(), services); + let e = app.run("app.openDialog", json!({})).unwrap_err(); + assert_eq!(alert(&app), ("Can't Open the File".to_string(), e)); + // Placing it says so too. + app.ui.dialog = None; + app.run("file.new", json!({})).unwrap(); + let e = app.run("app.placeDialog", json!({})).unwrap_err(); + let _ = std::fs::remove_file(&path); + assert_eq!(alert(&app), ("Can't Place the File".to_string(), e)); + } + + #[test] + fn other_command_errors_stay_in_the_status_line() { + let mut app = DesignApp::new(Session::new(), Services::default()); + assert!(app.run("file.open", json!({"path": "/nonexistent/x.designcraft"})).is_err()); + assert!(app.ui.dialog.is_none(), "a scripted file.open doesn't raise an alert"); + assert!(!app.ui.status.is_empty()); + // The control channel's app.open answers with the error; no alert waits for a click. + let (req, _rx) = control::ControlRequest::new("app.open", json!({"path": "/nonexistent/x.designcraft"})); + let control::Outcome::Done(r) = control::handle(&mut app, &egui::Context::default(), &req) else { panic!("app.open answers") }; + assert_eq!(r["ok"], json!(false), "{r}"); + assert!(app.ui.dialog.is_none(), "a control-channel app.open doesn't raise an alert"); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index abff58c6..5774ce09 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -704,7 +704,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option v, From 4564ec6707112f9c9bebd4ab61326b84ed405e8c Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:09:47 -0400 Subject: [PATCH 06/71] UI: text fields take Select All, Copy, Cut, Paste, Undo and Redo from the Mac menu (#30) (#111) The macOS menu bar takes Cmd+A, Cmd+C, Cmd+X, Cmd+V, Cmd+Z and Cmd+Shift+Z before the window sees them, so in a panel, dialog or search field they ran the document's commands: Select All selected every frame, Paste pasted into the layout, Undo undid the last document change. While a text field has keyboard focus, these Edit items now act on the field, whether chosen by shortcut or from the menu: Select All, Undo and Redo reach it as their keys, Copy and Cut as clipboard events, and Paste asks the window for the system clipboard. The items stay enabled for the field even when the document has nothing to copy or undo. With no field focused, and for every other item, the menu works as before. --- apps/designcraft/src/main.rs | 2 +- apps/designcraft/src/native_menu.rs | 6 +- crates/ui-egui/src/menus.rs | 161 ++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 4 deletions(-) diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 307bfe1a..a5cb13fb 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -25,7 +25,7 @@ impl eframe::App for App { self.1 = Some(native_menu::NativeMenu::install(&mut self.0)); } if let Some(m) = &mut self.1 { - m.poll(&mut self.0); + m.poll(&mut self.0, ctx); } } self.0.logic(ctx); diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index 4c039efd..8bf75b15 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -72,10 +72,10 @@ impl NativeMenu { Self { _menu: menu, items, last_refresh: 0.0 } } - pub fn poll(&mut self, app: &mut DesignApp) { + pub fn poll(&mut self, app: &mut DesignApp, ctx: &egui::Context) { while let Ok(ev) = MenuEvent::receiver().try_recv() { if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) { - designcraft_ui_egui::menus::activate(app, &cmd.clone(), ¶ms.clone()); + menus::activate_native(app, ctx, &cmd.clone(), ¶ms.clone()); } } let now = designcraft_ui_egui::now_ms(); @@ -84,7 +84,7 @@ impl NativeMenu { } self.last_refresh = now; for (cmd, params, h) in self.items.values() { - let en = menus::menu_enabled(app, cmd); + let en = menus::native_menu_enabled(app, ctx, cmd); match h { Handle::Plain(i) => i.set_enabled(en), Handle::Check(c) => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5774ce09..be0b0303 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1743,6 +1743,44 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { let _ = app.run(id, p); } +/// A native menu bar item was chosen (macOS): like [`activate`], except that the menu bar takes +/// ⌘A, ⌘C, ⌘X, ⌘V, ⌘Z and ⇧⌘Z before the window sees them, so while a text field has keyboard +/// focus, Select All, Copy, Cut, Paste, Undo and Redo act on that field, as in any Mac app. +pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, params: &Value) { + if !(ctx.text_edit_focused() && params.is_null() && forward_to_text_field(app, ctx, id)) { + activate(app, id, params); + } +} + +/// Whether a native menu bar item can be chosen: as in the in-window menus, but the editing +/// commands a focused text field takes are always available to it. +pub fn native_menu_enabled(app: &DesignApp, ctx: &egui::Context, id: &str) -> bool { + (ctx.text_edit_focused() && TEXT_FIELD_COMMANDS.contains(&id)) || menu_enabled(app, id) +} + +/// The editing commands a focused text field takes from the native menu bar. +const TEXT_FIELD_COMMANDS: [&str; 6] = ["edit.selectAll", "edit.copy", "edit.cut", "edit.paste", "edit.undo", "edit.redo"]; + +/// Hand a standard editing command to the focused text field as next frame's input; false when +/// `id` isn't one of them. +fn forward_to_text_field(app: &mut DesignApp, ctx: &egui::Context, id: &str) -> bool { + let key = |key: egui::Key, modifiers: egui::Modifiers| { + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }) + }; + match id { + "edit.selectAll" => app.synthetic.extend(key(egui::Key::A, egui::Modifiers::COMMAND)), + "edit.undo" => app.synthetic.extend(key(egui::Key::Z, egui::Modifiers::COMMAND)), + "edit.redo" => app.synthetic.extend(key(egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT)), + "edit.copy" => app.synthetic.push(egui::Event::Copy), + "edit.cut" => app.synthetic.push(egui::Event::Cut), + // Only the integration reads the system clipboard: it answers with an `Event::Paste`. + "edit.paste" => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), + _ => return false, + } + ctx.request_repaint(); + true +} + /// The menu bar contents (inside the app bar; macOS uses the native menu instead). pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) { let lang = app.ui.language.clone(); @@ -2433,6 +2471,129 @@ mod tests { assert!(run_ui(&mut app, "window.floatPanel", &json!({"panel": "nope"})).unwrap().is_err()); } + /// One frame as the desktop app runs it: queued synthetic input, then the native menu item + /// chosen during the frame (`menu`), then the app's logic and ui. + fn native_frame(app: &mut crate::DesignApp, ctx: &egui::Context, events: Vec, menu: Option<&str>) -> egui::FullOutput { + let mut input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + app.raw_input_hook(&mut input); + let mut menu = menu; + let mut out = ctx.run_ui(input, |ui| { + let ctx = ui.ctx().clone(); + if let Some(id) = menu.take() { + activate_native(app, &ctx, id, &Value::Null); + } + app.logic(&ctx); + app.ui(ui); + }); + out.textures_delta.clear(); + out + } + + /// A document with two frames, nothing selected, and Quick Apply's search field focused + /// holding `text`. + fn app_with_focused_field(ctx: &egui::Context, text: &str) -> crate::DesignApp { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap(); + app.session.execute("frame.create", &json!({"rect": [236, 36, 400, 200]})).unwrap(); + app.session.execute("edit.deselectAll", &json!({})).unwrap(); + app.ui.palette = Some(text.into()); + for _ in 0..3 { + native_frame(&mut app, ctx, vec![], None); + } + assert!(ctx.text_edit_focused()); + app + } + + fn field_selection(ctx: &egui::Context) -> Option<(usize, usize)> { + let id = ctx.memory(|m| m.focused())?; + let r = egui::TextEdit::load_state(ctx, id)?.cursor.char_range()?; + Some((r.primary.index.min(r.secondary.index).into(), r.primary.index.max(r.secondary.index).into())) + } + + fn document_items(app: &crate::DesignApp) -> (usize, usize) { + let d = app.session.active().unwrap(); + (d.doc.spreads.iter().flat_map(|sp| &sp.items).count(), d.selection.items.len()) + } + + #[test] + fn native_select_all_selects_the_focused_fields_text() { + // The Mac menu bar takes ⌘A before the window sees it (#30). + let ctx = egui::Context::default(); + let mut app = app_with_focused_field(&ctx, "frame"); + native_frame(&mut app, &ctx, vec![], Some("edit.selectAll")); + native_frame(&mut app, &ctx, vec![], None); + assert_eq!(field_selection(&ctx), Some((0, 5)), "the field's whole text is selected"); + assert_eq!(document_items(&app), (2, 0), "the document selection is unchanged"); + } + + #[test] + fn native_paste_and_copy_act_on_the_focused_field() { + let ctx = egui::Context::default(); + let mut app = app_with_focused_field(&ctx, "frame"); + native_frame(&mut app, &ctx, vec![], Some("edit.selectAll")); + native_frame(&mut app, &ctx, vec![], None); + // Copy puts the field's selected text on the clipboard. + native_frame(&mut app, &ctx, vec![], Some("edit.copy")); + let out = native_frame(&mut app, &ctx, vec![], None); + assert!(out.platform_output.commands.contains(&egui::OutputCommand::CopyText("frame".into())), "{:?}", out.platform_output.commands); + // Paste asks the integration for the clipboard, which it hands to the field as a paste. + let out = native_frame(&mut app, &ctx, vec![], Some("edit.paste")); + let commands = &out.viewport_output[&egui::ViewportId::ROOT].commands; + assert!(commands.contains(&egui::ViewportCommand::RequestPaste), "{commands:?}"); + native_frame(&mut app, &ctx, vec![egui::Event::Paste("text".into())], None); + assert_eq!(app.ui.palette.as_deref(), Some("text")); + assert_eq!(document_items(&app), (2, 0), "nothing was pasted into the document"); + } + + #[test] + fn native_undo_and_redo_act_on_the_focused_field() { + let ctx = egui::Context::default(); + let mut app = app_with_focused_field(&ctx, ""); + native_frame(&mut app, &ctx, vec![egui::Event::Text("abc".into())], None); + assert_eq!(app.ui.palette.as_deref(), Some("abc")); + native_frame(&mut app, &ctx, vec![], Some("edit.undo")); + native_frame(&mut app, &ctx, vec![], None); + assert_eq!(app.ui.palette.as_deref(), Some(""), "the field's typing is undone"); + assert_eq!(document_items(&app), (2, 0), "the document's last frame is still there"); + native_frame(&mut app, &ctx, vec![], Some("edit.redo")); + native_frame(&mut app, &ctx, vec![], None); + assert_eq!(app.ui.palette.as_deref(), Some("abc")); + assert_eq!(document_items(&app), (2, 0)); + } + + #[test] + fn native_editing_items_are_enabled_for_a_focused_field() { + let ctx = egui::Context::default(); + let mut app = app_with_focused_field(&ctx, "frame"); + // Nothing is selected in the document, so its Copy and Cut are off; the field's are on. + assert!(!menu_enabled(&app, "edit.copy") && !menu_enabled(&app, "edit.cut")); + for id in ["edit.selectAll", "edit.copy", "edit.cut", "edit.paste", "edit.undo", "edit.redo"] { + assert!(native_menu_enabled(&app, &ctx, id), "{id}"); + } + assert!(!native_menu_enabled(&app, &ctx, "edit.duplicate"), "other items follow the document"); + app.ui.palette = None; + native_frame(&mut app, &ctx, vec![], None); + native_frame(&mut app, &ctx, vec![], None); + assert!(!native_menu_enabled(&app, &ctx, "edit.copy"), "without a focused field, as the document says"); + } + + #[test] + fn native_select_all_without_a_focused_field_selects_the_documents_items() { + let ctx = egui::Context::default(); + let mut app = app_with_focused_field(&ctx, "frame"); + app.ui.palette = None; + native_frame(&mut app, &ctx, vec![], None); + native_frame(&mut app, &ctx, vec![], None); + assert!(!ctx.text_edit_focused()); + native_frame(&mut app, &ctx, vec![], Some("edit.selectAll")); + assert_eq!(document_items(&app), (2, 2)); + } + #[test] fn quick_apply_lists_styles_then_commands() { let mut s = designcraft_engine::Session::new(); From d730d8fa271e19a5e3dddeae15a5e6edb862e784 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:09:52 -0400 Subject: [PATCH 07/71] UI: toolbars that don't fit the window switch to two columns or scroll (#57) (#112) * UI: toolbars that don't fit the window switch to two columns or scroll (#57) In a short window the Tools panel lays its tools out in two columns, without changing the single/double-column preference, which comes back when the window grows again. While the window forces two columns the column toggle is disabled. Whatever two columns can't fit scrolls vertically. The Control panel and the application bar scroll sideways when their controls are wider than the window. The Control panel keeps its height. In the application bar the workspace controls follow the menus instead of overlapping them. A plain mouse wheel scrolls these one-direction bars, and a thin floating scroll bar shows only when something overflows, so nothing changes while everything fits. Tool buttons, icon buttons and the Fill proxy carry accessibility labels, which the new window-size tests use to find them. * UI: captions in a sideways-scrolled bar stay on one line The last group of a scrolled Control bar row got only the width left over, so a caption like Columns wrapped one letter per line. --- crates/ui-egui/src/chrome.rs | 268 ++++++++++++++++++++++++---------- crates/ui-egui/src/icons.rs | 2 + crates/ui-egui/src/lib.rs | 51 +++++++ crates/ui-egui/src/toolbar.rs | 126 +++++++++++++--- crates/ui-egui/src/widgets.rs | 15 ++ 5 files changed, 364 insertions(+), 98 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index dfb5e699..23876f3a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -21,86 +21,105 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { let full = ui.max_rect(); let mut menus_end = full.min.x; let mut tools_start = full.max.x; - ui.horizontal_centered(|ui| { - if icons::button(ui, "home", 24.0, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() { - app.session_home(); - } - ui.add_space(6.0); - if !app.native_menu { - ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; - ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; - ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; - ui.style_mut().spacing.button_padding = vec2(7.0, 3.0); - crate::menus::menu_bar(app, ui); - } - menus_end = ui.min_rect().max.x; - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(4.0); - // Search field. - let (r, _) = ui.allocate_exact_size(vec2(125.0, 18.0), Sense::click()); - ui.painter().rect(r, 1.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(3.0, 2.0), vec2(14.0, 14.0)), "search", t.icon); - crate::rtl::paint( - ui.painter(), - r.min + vec2(20.0, 9.0), - egui::Align2::LEFT_CENTER, - crate::i18n::tr(&app.ui.language, "Search"), - egui::FontId::proportional(11.0), - t.text_dim, - ); - ui.add_space(8.0); - let current = app.ui.workspace.clone(); - let shown = crate::i18n::workspace_name(&app.ui.language, ¤t); - ui.menu_button(crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| { - let customs: Vec = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); - for w in - ["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"] - .into_iter() - .map(str::to_string) - .chain(customs.iter().cloned()) - { - if ui.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))).clicked() + // The right-hand controls' width, measured last frame: in a window too narrow for them + // and the menus they follow the menus, and the bar scrolls sideways. + let tools_w_id = ui.id().with("tools_width"); + let tools_w: f32 = ui.data(|d| d.get_temp(tools_w_id)).unwrap_or(0.0); + crate::widgets::overflow_scrolling(ui); + egui::ScrollArea::horizontal().id_salt("app_bar_scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.horizontal_centered(|ui| { + if icons::button(ui, "home", 24.0, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() { + app.session_home(); + } + ui.add_space(6.0); + if !app.native_menu { + ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; + ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; + ui.style_mut().spacing.button_padding = vec2(7.0, 3.0); + crate::menus::menu_bar(app, ui); + } + menus_end = ui.min_rect().max.x; + let size = vec2(ui.available_width().max(tools_w), ui.available_height()); + let tools = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + // Search field. + let (r, _) = ui.allocate_exact_size(vec2(125.0, 18.0), Sense::click()); + ui.painter().rect(r, 1.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(3.0, 2.0), vec2(14.0, 14.0)), "search", t.icon); + crate::rtl::paint( + ui.painter(), + r.min + vec2(20.0, 9.0), + egui::Align2::LEFT_CENTER, + crate::i18n::tr(&app.ui.language, "Search"), + egui::FontId::proportional(11.0), + t.text_dim, + ); + ui.add_space(8.0); + let current = app.ui.workspace.clone(); + let shown = crate::i18n::workspace_name(&app.ui.language, ¤t); + ui.menu_button(crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| { + let customs: Vec = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); + for w in + ["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"] + .into_iter() + .map(str::to_string) + .chain(customs.iter().cloned()) { - let _ = app.run("window.workspace", json!({"name": w})); + if ui + .selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))) + .clicked() + { + let _ = app.run("window.workspace", json!({"name": w})); + ui.close(); + } + } + ui.separator(); + if ui + .button(crate::rtl::widget( + ui, + format!( + "{} {}", + crate::i18n::tr(&app.ui.language, "Reset"), + crate::i18n::workspace_name(&app.ui.language, ¤t) + ), + )) + .clicked() + { + let _ = app.run("window.resetWorkspace", json!({})); ui.close(); } - } - ui.separator(); - if ui - .button(crate::rtl::widget( - ui, - format!("{} {}", crate::i18n::tr(&app.ui.language, "Reset"), crate::i18n::workspace_name(&app.ui.language, ¤t)), - )) - .clicked() - { - let _ = app.run("window.resetWorkspace", json!({})); - ui.close(); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() { - let _ = app.run("window.newWorkspace", json!({})); - ui.close(); - } - if !customs.is_empty() { - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| { - for w in &customs { - if ui.button(w).clicked() { - let _ = app.run("window.deleteWorkspace", json!({"name": w})); - ui.close(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() { + let _ = app.run("window.newWorkspace", json!({})); + ui.close(); + } + if !customs.is_empty() { + ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| { + for w in &customs { + if ui.button(w).clicked() { + let _ = app.run("window.deleteWorkspace", json!({"name": w})); + ui.close(); + } } - } - }); + }); + } + }); + ui.add_space(6.0); + if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() { + app.status("Export a PDF, IDML or package to share — no cloud account needed."); } + ui.add_space(8.0); + // Always one click away: the ArtCraft community Discord. + if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) { + let _ = app.run("help.discord", json!({})); + } + tools_start = ui.min_rect().min.x; + ui.min_rect().width() }); - ui.add_space(6.0); - if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() { - app.status("Export a PDF, IDML or package to share — no cloud account needed."); + if (tools.inner - tools_w).abs() > 0.5 { + ui.data_mut(|d| d.insert_temp(tools_w_id, tools.inner)); + ui.ctx().request_discard("app bar: the right-hand controls changed width"); } - ui.add_space(8.0); - // Always one click away: the ArtCraft community Discord. - if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) { - let _ = app.run("help.discord", json!({})); - } - tools_start = ui.min_rect().min.x; }); }); // Centred title. @@ -142,14 +161,18 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 8, right: 8, top: 5, bottom: 3 }).stroke(Stroke::new(1.0, t.divider)), ) .show(ui, |ui| { - ui.horizontal(|ui| { - let text_mode = - matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some()); - if text_mode { - control_text(app, ui); - } else { - control_object(app, ui); - } + // Wider than the window: the bar scrolls sideways. + crate::widgets::overflow_scrolling(ui); + egui::ScrollArea::horizontal().id_salt("control_bar_scroll").auto_shrink([false, true]).show(ui, |ui| { + ui.horizontal(|ui| { + let text_mode = + matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some()); + if text_mode { + control_text(app, ui); + } else { + control_object(app, ui); + } + }); }); }); } @@ -716,3 +739,86 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); } + +#[cfg(test)] +mod tests { + use egui::{pos2, vec2}; + use egui_kittest::kittest::Queryable; + + use crate::test_window::{self, wheel}; + + /// The Control panel's last control with nothing selected. + const LAST_CONTROL: &str = "Jump to Next Column"; + const FIRST_CONTROL: &str = "Rotate 90° Counterclockwise"; + + fn app() -> crate::DesignApp { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.control_bar = true; + app + } + + fn rect(h: &egui_kittest::Harness<'static, test_window::Window>, label: &str) -> egui::Rect { + h.get_by_label(label).rect() + } + + #[test] + fn a_narrow_window_scrolls_the_control_bar() { + let w = 700.0; + let mut h = test_window::open(app(), vec2(w, 600.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + assert!(rect(&h, LAST_CONTROL).max.x > w, "the controls are wider than the window"); + // A plain mouse wheel scrolls the bar sideways. + let first = rect(&h, FIRST_CONTROL).center(); + wheel(&mut h, first, vec2(0.0, -3000.0)); + let last = rect(&h, LAST_CONTROL); + let window = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(w, 600.0)); + assert!(window.contains_rect(last) && bar.contains_rect(last), "{last:?} outside the bar {bar:?}"); + assert_eq!(test_window::panel_rect(&h, "control_bar").height(), bar.height(), "the bar keeps its height"); + // A popup opened from a scrolled control opens at that control. + let fill = h.get_by_label("Fill").rect(); + let chip = pos2(fill.max.x + 15.0, fill.center().y); + h.hover_at(chip); + h.drag_at(chip); + h.drop_at(chip); + h.run_steps(4); + let item = rect(&h, "[Black]"); + assert!(item.min.y > fill.max.y && (item.min.x - chip.x).abs() < 120.0, "popup item at {item:?}, control at {chip:?}"); + } + + #[test] + fn a_narrow_window_scrolls_the_app_bar() { + let w = 640.0; + let mut h = test_window::open(app(), vec2(w, 600.0)); + let (help, share) = (rect(&h, "Help"), rect(&h, "Share")); + assert!(help.max.x <= share.min.x, "the menus ({help:?}) and the workspace controls ({share:?}) overlap"); + wheel(&mut h, help.center(), vec2(0.0, -3000.0)); + let share = rect(&h, "Share"); + let window = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(w, 600.0)); + assert!(window.contains_rect(share), "{share:?} outside the window"); + } + + #[test] + fn a_wide_window_does_not_scroll_the_bars() { + let mut h = test_window::open(app(), vec2(1400.0, 900.0)); + let before = [rect(&h, FIRST_CONTROL), rect(&h, LAST_CONTROL), rect(&h, "Help"), rect(&h, "Share")]; + assert!(before.iter().all(|r| r.max.x <= 1400.0), "{before:?}"); + wheel(&mut h, before[0].center(), vec2(0.0, -3000.0)); + wheel(&mut h, before[2].center(), vec2(0.0, -3000.0)); + let after = [rect(&h, FIRST_CONTROL), rect(&h, LAST_CONTROL), rect(&h, "Help"), rect(&h, "Share")]; + assert_eq!(after, before); + } + + #[test] + fn a_scrolled_bar_keeps_its_captions_on_one_line() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap(); + app.run("selection.set", serde_json::json!({"ids": [f["id"]]})).unwrap(); + let mut h = test_window::open(app, vec2(700.0, 600.0)); + let bar = test_window::panel_rect(&h, "control_bar").center(); + wheel(&mut h, bar, vec2(0.0, -3000.0)); + // The text frame's last group: its caption gets only what is left of the row. + let columns = h.get_by_label("Columns").rect(); + assert!(columns.height() < 20.0 && columns.width() > columns.height(), "Columns wraps: {columns:?}"); + } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7e214603..69415e9b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -805,6 +805,8 @@ pub fn button(ui: &mut egui::Ui, name: &str, size: f32, selected: bool, tip: &st } let pad = size * 0.16; paint(ui.painter(), r.shrink(pad), name, if selected { t.text_strong } else { t.icon }); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, tip)); if tip.is_empty() { resp } else { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 43a08167..275018cb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -984,3 +984,54 @@ mod tests { assert!(app.ui.dialog.is_none(), "a control-channel app.open doesn't raise an alert"); } } + +/// The whole window in a headless test harness, at a chosen window size. +#[cfg(test)] +pub(crate) mod test_window { + use egui_kittest::Harness; + + pub struct Window { + pub app: crate::DesignApp, + ready: bool, + } + + /// The window at `size` with a new document open. + pub fn open(mut app: crate::DesignApp, size: egui::Vec2) -> Harness<'static, Window> { + if app.session.active().is_none() { + app.run("file.new", serde_json::json!({})).unwrap(); + } + let mut h = Harness::builder().with_size(size).with_max_steps(1000).build_ui_state( + |ui, w: &mut Window| { + // The builder runs a frame before the texture size can be raised: skip it. + if !w.ready { + return; + } + let ctx = ui.ctx().clone(); + w.app.logic(&ctx); + w.app.ui(ui); + }, + Window { app, ready: false }, + ); + h.input_mut().max_texture_side = Some(8192); + h.state_mut().ready = true; + h.run_steps(6); + h + } + + /// The mouse wheel turned over `pos`: positive `delta` moves the content right and down. + pub fn wheel(h: &mut Harness<'static, Window>, pos: egui::Pos2, delta: egui::Vec2) { + h.hover_at(pos); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta, + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(10); + } + + /// The outer rect of a side or top panel, from egui's memory. + pub fn panel_rect(h: &Harness<'static, Window>, id: &str) -> egui::Rect { + egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new(id)).map(|s| s.outer_rect).unwrap() + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index a3d49547..5a5e1f08 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -12,27 +12,33 @@ const BTN: f32 = 24.0; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let cols = if app.ui.tools_double_column { 2 } else { 1 }; + let frame = egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.divider)); + // A single column that doesn't fit the window's height becomes two, without changing the + // preference; whatever two columns still can't fit scrolls. + let single_h_id = egui::Id::new("tools_single_column_height"); + let single_h: Option = ui.data(|d| d.get_temp(single_h_id)); + let viewport_h = ui.available_height() - frame.total_margin().sum().y; + let crowded = !app.ui.tools_double_column && single_h.is_some_and(|h| h > viewport_h); + let cols = if app.ui.tools_double_column || crowded { 2 } else { 1 }; let width = 8.0 + BTN * cols as f32 + 4.0 * (cols as f32 - 1.0) + 8.0; - let r = egui::Panel::left("tools") - .exact_size(width) - .resizable(false) - .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.divider))) - .show(ui, |ui| { + let r = egui::Panel::left("tools").exact_size(width).resizable(false).frame(frame).show(ui, |ui| { + crate::widgets::overflow_scrolling(ui); + let scroll = egui::ScrollArea::vertical().id_salt("tools_scroll").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 1.0; ui.add_space(3.0); - // Collapse chevrons. + // Collapse chevrons (nothing to toggle while the window forces two columns). ui.horizontal(|ui| { ui.add_space(width / 2.0 - 8.0); - if icons::button( - ui, - if cols == 1 { "double-chevron-right" } else { "double-chevron-left" }, - 16.0, - false, - crate::i18n::tr(&app.ui.language, "Toggle double column"), - ) - .clicked() - { + let toggle = ui.add_enabled_ui(!crowded, |ui| { + icons::button( + ui, + if cols == 1 { "double-chevron-right" } else { "double-chevron-left" }, + 16.0, + false, + crate::i18n::tr(&app.ui.language, "Toggle double column"), + ) + }); + if toggle.inner.clicked() { app.ui.tools_double_column = !app.ui.tools_double_column; } }); @@ -169,6 +175,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(8.0); fill_stroke_proxy(app, ui, width); }); + if cols == 1 { + let h = scroll.content_size.y; + ui.data_mut(|d| d.insert_temp(single_h_id, h)); + // Laid out in one column that turned out too tall: lay out again in two. + if h > viewport_h { + ui.ctx().request_discard("tools panel: one column does not fit"); + } + } + }); if std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some() { eprintln!("tools panel rect {:?} (wanted width {width}); remaining {:?}", r.response.rect, ui.available_rect_before_wrap()); } @@ -252,9 +267,11 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { paint_stroke(p); paint_fill(p); } + let fill_tip = crate::i18n::tr(&app.ui.language, "Fill (X)"); let fresp = ui.interact(fill_r, ui.id().with("proxy_fill"), Sense::click()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fill (X)")); + crate::rtl::label(ui, fill_tip); }); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fill_tip)); let sresp = ui.interact(stroke_r.translate(vec2(0.0, 0.0)), ui.id().with("proxy_stroke"), Sense::click()).on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Stroke (X)")); }); @@ -387,7 +404,82 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui:: ui.painter().rect_filled(egui::Rect::from_center_size(r.center(), egui::vec2(28.0, 20.0)), 2.0, t.hover); } icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), egui::vec2(17.0, 17.0)), icon, if active { t.text_strong } else { t.icon }); + let enabled = ui.is_enabled(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); resp.on_hover_ui(|ui| { crate::rtl::label(ui, tip); }) } + +#[cfg(test)] +mod tests { + use egui::{pos2, vec2}; + use egui_kittest::kittest::Queryable; + + use crate::test_window::{self, wheel}; + + /// The last tool group's button and the Fill proxy, near the bottom of the panel. + const LAST_TOOL: &str = "Zoom Tool (Z)"; + const FILL: &str = "Fill (X)"; + /// Status bar height: the Tools panel ends above it. + const STATUS_H: f32 = 17.0; + + fn app(double: bool) -> crate::DesignApp { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.tools_double_column = double; + app + } + + fn column_width(cols: f32) -> f32 { + 8.0 + super::BTN * cols + 4.0 * (cols - 1.0) + 8.0 + } + + fn rect(h: &egui_kittest::Harness<'static, test_window::Window>, label: &str) -> egui::Rect { + h.get_by_label(label).rect() + } + + #[test] + fn a_short_window_reaches_every_tool() { + for double in [false, true] { + let (w, ht) = (1000.0, 500.0); + let mut h = test_window::open(app(double), vec2(w, ht)); + let panel = test_window::panel_rect(&h, "tools"); + wheel(&mut h, panel.center(), vec2(0.0, -2000.0)); + let visible = egui::Rect::from_min_max(pos2(0.0, panel.min.y), pos2(w, ht - STATUS_H)); + for label in [LAST_TOOL, FILL] { + let r = rect(&h, label); + assert!(visible.contains_rect(r), "double column {double}: {label} at {r:?} is outside {visible:?}"); + } + assert_eq!(h.state().app.ui.tools_double_column, double, "the preference is the user's"); + } + } + + #[test] + fn tools_take_two_columns_while_one_does_not_fit() { + let (w, ht) = (1086.0, 612.0); + let mut h = test_window::open(app(false), vec2(w, ht)); + assert_eq!(test_window::panel_rect(&h, "tools").width(), column_width(2.0)); + let visible = egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(w, ht - STATUS_H)); + for label in [LAST_TOOL, FILL] { + let r = rect(&h, label); + assert!(visible.contains_rect(r), "{label} at {r:?} is outside {visible:?} without scrolling"); + } + assert!(!h.state().app.ui.tools_double_column); + // A taller window brings the single column back. + h.set_size(vec2(1400.0, 900.0)); + h.run_steps(4); + assert_eq!(test_window::panel_rect(&h, "tools").width(), column_width(1.0)); + assert!(!h.state().app.ui.tools_double_column); + } + + #[test] + fn a_tall_window_keeps_one_column_and_does_not_scroll() { + let mut h = test_window::open(app(false), vec2(1400.0, 900.0)); + let panel = test_window::panel_rect(&h, "tools"); + assert_eq!(panel.width(), column_width(1.0)); + let before = (rect(&h, LAST_TOOL), rect(&h, FILL)); + assert!(panel.contains_rect(before.0) && panel.contains_rect(before.1), "{before:?} outside {panel:?}"); + wheel(&mut h, panel.center(), vec2(0.0, -2000.0)); + assert_eq!((rect(&h, LAST_TOOL), rect(&h, FILL)), before); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8e6b7216..dd7f3d8b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -572,6 +572,21 @@ pub fn segmented(ui: &mut Ui, labels: &[&str], active: usize, width: f32) -> Opt out } +/// Scrolling for a bar that a small window cuts off (Tools panel, Control panel, application +/// bar): the mouse wheel scrolls the bar's only direction, and a thin scroll bar floats over the +/// contents, so nothing moves and nothing shows while everything fits. +pub fn overflow_scrolling(ui: &mut Ui) { + let style = ui.style_mut(); + style.always_scroll_the_only_direction = true; + // A caption at the end of a scrolled row gets only what is left of it: extend, don't wrap. + style.wrap_mode = Some(egui::TextWrapMode::Extend); + let s = &mut style.spacing.scroll; + *s = egui::style::ScrollStyle::floating(); + s.bar_width = 6.0; + // A faint handle while the pointer is elsewhere: the bar shows there is more. + s.dormant_handle_opacity = 0.5; +} + #[cfg(test)] mod tests { use super::*; From 788e0eac669bf280d9a44f5515596d2ca773f131 Mon Sep 17 00:00:00 2001 From: Haseom Shin Date: Sat, 10 Oct 2026 14:13:17 +0900 Subject: [PATCH 08/71] Images: set SVG text in the shared fonts when the bundled ones lack it (#116) --- crates/fonts/src/testing.rs | 9 ++-- crates/images/Cargo.toml | 3 ++ crates/images/src/lib.rs | 100 ++++++++++++++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/crates/fonts/src/testing.rs b/crates/fonts/src/testing.rs index e03ad1b6..830f3d35 100644 --- a/crates/fonts/src/testing.rs +++ b/crates/fonts/src/testing.rs @@ -29,14 +29,15 @@ pub fn font_mapping(family: &str, glyphs: &[(char, char)]) -> Option> { let mut tables = tables_of(base)?; tables.retain(|(tag, _)| tag != b"name" && tag != b"cmap"); - // `name`: format 0, family (1) and subfamily (2), Windows Unicode English. - let strings: Vec> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect(); + // `name`: format 0, family (1), subfamily (2) and PostScript name (6), Windows Unicode English. + let ps: String = family.chars().filter(char::is_ascii_alphanumeric).chain("-Regular".chars()).collect(); + let strings: Vec> = [family, "Regular", &ps].iter().map(|s| s.encode_utf16().flat_map(u16::to_be_bytes).collect()).collect(); let mut name = Vec::new(); - for v in [0u16, 2, 6 + 2 * 12] { + for v in [0u16, 3, 6 + 3 * 12] { name.extend_from_slice(&v.to_be_bytes()); } let mut at = 0usize; - for (id, s) in [1u16, 2].iter().zip(&strings) { + for (id, s) in [1u16, 2, 6].iter().zip(&strings) { for v in [3u16, 1, 0x409, *id, u16::try_from(s.len()).ok()?, u16::try_from(at).ok()?] { name.extend_from_slice(&v.to_be_bytes()); } diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index de878d59..0db9d190 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -14,3 +14,6 @@ designcraft-fonts = { workspace = true } image = { workspace = true } tiff = { workspace = true } resvg = { workspace = true } + +[dev-dependencies] +designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index b770140f..1e81115c 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -1,7 +1,7 @@ //! Placed graphics: format sniffing, sizes and decoding for the renderer and PDF export. //! Rasters go through `image` (PNG, JPEG, GIF, WebP, TIFF, BMP) or [`psd`] (Photoshop's merged -//! composite); SVG is parsed with usvg (text set in the bundled fonts) and rasterised with resvg -//! for the screen — PDF export draws the same tree as vectors. +//! composite); SVG is parsed with usvg (text set in the bundled fonts, then the shared ones) and +//! rasterised with resvg for the screen — PDF export draws the same tree as vectors. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::sync::{Arc, OnceLock}; @@ -67,10 +67,67 @@ fn svg_options() -> &'static usvg::Options<'static> { db.set_serif_family("Source Serif 4"); db.set_sans_serif_family("Source Sans 3"); db.set_monospace_family("JetBrains Mono"); - usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), ..Default::default() } + let font_resolver = usvg::FontResolver { select_font: select_font(), select_fallback: select_fallback() }; + usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), font_resolver, ..Default::default() } }) } +/// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too), +/// as it would in a text frame. +fn select_font() -> usvg::FontSelectionFn<'static> { + let bundled = usvg::FontResolver::default_font_selector(); + Box::new(move |font, db| { + let fonts = designcraft_fonts::FontDb::global(); + for family in font.families() { + if let usvg::FontFamily::Named(name) = family + && !db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))) + && fonts.has_family(name) + { + for style in fonts.styles(name) { + add_face(db, fonts.face(name, &style)); + } + } + } + bundled(font, db) + }) +} + +/// Characters the chosen fonts lack (Korean, Arabic…) fall back to a shared face that has them. +fn select_fallback() -> usvg::FallbackSelectionFn<'static> { + let loaded = usvg::FontResolver::default_fallback_selector(); + Box::new(move |c, exclude, db| { + loaded(c, exclude, db).or_else(|| { + let id = add_face(db, designcraft_fonts::FontDb::global().fallback_for(c, 0, None)?)?; + // usvg asks again until we run out: a face it already tried ends the search. + (!exclude.contains(&id)).then_some(id) + }) + }) +} + +/// A shared face's bytes, handed to usvg without a copy. +struct FaceBytes(Arc); + +impl AsRef<[u8]> for FaceBytes { + fn as_ref(&self) -> &[u8] { + self.0.data() + } +} + +/// Add `face` to the SVG's font database (once) and return its id there. +fn add_face(db: &mut Arc, face: Arc) -> Option { + let same = |f: &usvg::fontdb::FaceInfo| { + f.index == face.index() + && matches!(&f.source, usvg::fontdb::Source::Binary(b) if std::ptr::eq(b.as_ref().as_ref().as_ptr(), face.data().as_ptr())) + }; + if let Some(f) = db.faces().find(|f| same(f)) { + return Some(f.id); + } + let index = face.index(); + let ids = Arc::make_mut(db).load_font_source(usvg::fontdb::Source::Binary(Arc::new(FaceBytes(face)))); + // A collection loads all its faces (skipping any it can't read): pick ours by its index. + ids.into_iter().find(|id| db.face(*id).is_some_and(|f| f.index == index)) +} + /// The parsed SVG. pub fn svg_tree(bytes: &[u8]) -> Option { usvg::Tree::from_data(bytes, svg_options()).ok() @@ -211,6 +268,43 @@ mod tests { assert!(!is_svg(b"\x89PNG")); } + /// The family of the font that sets each text glyph of `svg`, and the glyph's id. + fn glyph_families(svg: &str) -> Vec<(String, u16)> { + fn walk(g: &usvg::Group, tree: &usvg::Tree, out: &mut Vec<(String, u16)>) { + for n in g.children() { + match n { + usvg::Node::Group(g) => walk(g, tree, out), + usvg::Node::Text(t) => { + for g in t.layouted().iter().flat_map(|s| &s.positioned_glyphs) { + out.extend(tree.fontdb().face(g.font).map(|f| (f.families[0].0.clone(), g.id.0))); + } + } + _ => {} + } + } + } + let tree = svg_tree(svg.as_bytes()).unwrap(); + let mut out = Vec::new(); + walk(tree.root(), &tree, &mut out); + out + } + + #[test] + fn svg_text_uses_the_shared_fonts() { + let font = designcraft_fonts::testing::font_with("DC Test SVG Hangul", &['한', '글']).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + let text = |attrs: &str, body: &str| { + format!(r#"{body}"#) + }; + let named = r#" font-family="DC Test SVG Hangul""#; + // A family named in the SVG. + let set = glyph_families(&text(named, "한글")); + assert!(set.len() == 2 && set.iter().all(|(f, gid)| f == "DC Test SVG Hangul" && *gid != 0), "{set:?}"); + // Characters the bundled default lacks: a shared face that has them, not the default. + let set = glyph_families(&text("", "한글")); + assert!(set.len() == 2 && set.iter().all(|(f, gid)| f != "Source Sans 3" && *gid != 0), "{set:?}"); + } + /// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image. fn tiny_psd() -> Vec { let mut b = Vec::new(); From 4c6aa4cd4b1b1c516cf88226a89ee760d2780140 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 9 Oct 2026 23:13:23 -0600 Subject: [PATCH 09/71] browser-correctness: fix print, startup flags, and document MIME (#117) --- Cargo.lock | 73 ++++++++++++++++++++++ apps/designcraft-web/Cargo.toml | 2 + apps/designcraft-web/src/browser_policy.rs | 67 ++++++++++++++++++++ apps/designcraft-web/src/main.rs | 6 +- apps/designcraft-web/src/web.rs | 16 +---- crates/engine/Cargo.toml | 3 + crates/engine/src/cmd/print.rs | 49 ++++++++------- crates/engine/tests/browser_print.rs | 31 +++++++++ 8 files changed, 211 insertions(+), 36 deletions(-) create mode 100644 apps/designcraft-web/src/browser_policy.rs create mode 100644 crates/engine/tests/browser_print.rs diff --git a/Cargo.lock b/Cargo.lock index cfc7d351..6473b84f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -635,6 +635,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.5.1" @@ -952,6 +958,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "wasm-bindgen-test", "zip", ] @@ -1126,9 +1133,11 @@ name = "designcraft-web" version = "0.5.0" dependencies = [ "designcraft-engine", + "designcraft-format", "designcraft-ui-egui", "eframe", "egui", + "form_urlencoded", "js-sys", "log", "rfd", @@ -2550,6 +2559,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2673,6 +2692,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3022,6 +3050,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "open" version = "5.4.4" @@ -4746,6 +4780,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe" + [[package]] name = "wayland-backend" version = "0.3.17" diff --git a/apps/designcraft-web/Cargo.toml b/apps/designcraft-web/Cargo.toml index d27b3c41..75775c31 100644 --- a/apps/designcraft-web/Cargo.toml +++ b/apps/designcraft-web/Cargo.toml @@ -15,6 +15,8 @@ path = "src/main.rs" [dependencies] designcraft-engine = { workspace = true } +designcraft-format = { workspace = true } +form_urlencoded = "1.2" designcraft-ui-egui = { workspace = true } egui = { workspace = true } log = { workspace = true } diff --git a/apps/designcraft-web/src/browser_policy.rs b/apps/designcraft-web/src/browser_policy.rs new file mode 100644 index 00000000..524df6cc --- /dev/null +++ b/apps/designcraft-web/src/browser_policy.rs @@ -0,0 +1,67 @@ +//! Pure browser startup and download metadata policies. + +/// Query flags are case-sensitive decoded keys. Values are ignored: `?sample=false` +/// still enables sample startup, and repeated keys have the same presence semantics. +pub(crate) fn has_query_flag(query: &str, flag: &str) -> bool { + let query = query.strip_prefix('?').unwrap_or(query); + form_urlencoded::parse(query.as_bytes()).any(|(key, _)| key == flag) +} + +pub(crate) fn mime_for(name: &str) -> &'static str { + match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() { + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("pdf") => "application/pdf", + Some("designcraft") => designcraft_format::MIME, + Some("idml") => "application/vnd.adobe.indesign-idml-package", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::{has_query_flag, mime_for}; + + #[test] + fn startup_flags_match_complete_decoded_keys() { + for flag in ["sample", "webgl"] { + for query in [format!("?{flag}"), format!("?{flag}=false"), format!("?{flag}=1&{flag}=0"), format!("?other=value&{flag}")] { + assert!(has_query_flag(&query, flag), "{query}"); + } + for query in [ + String::new(), + format!("?note={flag}"), + format!("?{flag}d=1"), + format!("?note=not_a_{flag}"), + "?note=%73ample%77ebgl".to_string(), + format!("?{flag}+extra=1"), + format!("?{flag}%26extra=1"), + format!("?{flag}%3Dextra=1"), + ] { + assert!(!has_query_flag(&query, flag), "{query}"); + } + } + assert!(has_query_flag("?webgl&sample", "sample")); + assert!(has_query_flag("?webgl&sample", "webgl")); + assert!(has_query_flag("?%73ample", "sample")); + assert!(has_query_flag("?%77ebgl", "webgl")); + assert!(!has_query_flag("?Sample&WEBGL", "sample")); + assert!(!has_query_flag("?Sample&WEBGL", "webgl")); + } + + #[test] + fn download_mime_matches_native_package_and_existing_exports() { + for (name, expected) in [ + ("Quarterly.designcraft", designcraft_format::MIME), + ("日本語.multi.DEsignCraft", designcraft_format::MIME), + ("page.pdf", "application/pdf"), + ("page.png", "image/png"), + ("page.jpg", "image/jpeg"), + ("page.JPEG", "image/jpeg"), + ("document.idml", "application/vnd.adobe.indesign-idml-package"), + ("unknown.bin", "application/octet-stream"), + ] { + assert_eq!(mime_for(name), expected, "{name}"); + } + } +} diff --git a/apps/designcraft-web/src/main.rs b/apps/designcraft-web/src/main.rs index ed05d595..527baff4 100644 --- a/apps/designcraft-web/src/main.rs +++ b/apps/designcraft-web/src/main.rs @@ -12,9 +12,13 @@ //! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox. //! //! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?sample` opens the -//! sample magazine on start. +//! sample magazine on start. Flags match case-sensitive, percent-decoded query keys; values +//! are ignored and repeated keys have the same presence semantics (`?sample=false` enables it). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(any(target_arch = "wasm32", test))] +mod browser_policy; + #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index c0466b69..3879f083 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -1,5 +1,6 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. +use super::browser_policy::{has_query_flag, mime_for}; use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, Inbox, Services}; use wasm_bindgen::JsCast as _; @@ -41,7 +42,7 @@ pub fn start() { return; }; let mut options = eframe::WebOptions::default(); - if query().contains("webgl") + if has_query_flag(&query(), "webgl") && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { create.instance_descriptor.backends = eframe::wgpu::Backends::GL; @@ -56,7 +57,7 @@ pub fn start() { } let inbox: Inbox = Inbox::default(); let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); - if query().contains("sample") { + if has_query_flag(&query(), "sample") { let _ = app.run("file.newSample", serde_json::json!({})); } Ok(Box::new(WebShell { app, inbox })) @@ -173,14 +174,3 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?; Ok(()) } - -fn mime_for(name: &str) -> &'static str { - match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() { - Some("png") => "image/png", - Some("jpg" | "jpeg") => "image/jpeg", - Some("pdf") => "application/pdf", - Some("designcraft") => "application/json", - Some("idml") => "application/vnd.adobe.indesign-idml-package", - _ => "application/octet-stream", - } -} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ff02d62b..241fd281 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -36,3 +36,6 @@ hayro-syntax = "0.7.2" hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } hayro-cmap = "0.1" kurbo = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index bbae1b38..b944b623 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -2,8 +2,12 @@ use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; -use crate::{EngineError, Result, Session}; +#[cfg(not(target_arch = "wasm32"))] +use super::str_param; +use super::{CommandSpec, bad, cmd, has_doc}; +#[cfg(not(target_arch = "wasm32"))] +use crate::EngineError; +use crate::{Result, Session}; pub fn specs() -> Vec { vec![ @@ -39,31 +43,32 @@ fn printers() -> Value { fn print(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; let opts = super::export::options(p, d.page_count())?; - let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999); - let printer = str_param(p, "printer").map(str::to_string); - let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); - let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - let file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_"))); - let mut cmd: Vec = vec!["lpr".into()]; - if let Some(pr) = &printer { - cmd.extend(["-P".into(), pr.clone()]); - } - if copies > 1 { - cmd.extend(["-#".into(), copies.to_string()]); - } - cmd.extend(["-T".into(), d.title.clone(), file.to_string_lossy().to_string()]); - let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len()); - let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()}); - if dry { - return Ok(out); - } #[cfg(target_arch = "wasm32")] { - let _ = pdf; + // Even a dry run cannot construct a native spool command in the browser. + let _ = opts; Err(bad("file.print", "printing isn't available on the web: export a PDF and print it")) } #[cfg(not(target_arch = "wasm32"))] { + let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999); + let printer = str_param(p, "printer").map(str::to_string); + let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_"))); + let mut cmd: Vec = vec!["lpr".into()]; + if let Some(pr) = &printer { + cmd.extend(["-P".into(), pr.clone()]); + } + if copies > 1 { + cmd.extend(["-#".into(), copies.to_string()]); + } + cmd.extend(["-T".into(), d.title.clone(), file.to_string_lossy().to_string()]); + let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len()); + let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()}); + if dry { + return Ok(out); + } std::fs::write(&file, &pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.display())))?; let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?; if !st.status.success() { @@ -73,7 +78,7 @@ fn print(s: &mut Session, p: &Value) -> Result { } } -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use serde_json::json; diff --git a/crates/engine/tests/browser_print.rs b/crates/engine/tests/browser_print.rs new file mode 100644 index 00000000..fda224b9 --- /dev/null +++ b/crates/engine/tests/browser_print.rs @@ -0,0 +1,31 @@ +//! Browser Print must reject native spooler operations without trapping the editor. +#![cfg(target_arch = "wasm32")] + +use designcraft_engine::Session; +use serde_json::json; +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + +wasm_bindgen_test_configure!(run_in_browser); + +fn print_is_safe(dry_run: bool) { + let mut session = Session::new(); + session.execute("file.new", &json!({"pages": 1})).unwrap(); + let error = session.execute("file.print", &json!({"dryRun": dry_run})).unwrap_err().to_string(); + assert!(error.contains("printing isn't available on the web"), "{error}"); + assert!(error.contains("export a PDF"), "{error}"); + session.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text"})).unwrap(); + let exported = session.execute("file.exportPdf", &json!({})).unwrap(); + assert!(exported["bytes"].as_u64().unwrap() > 500); + assert_eq!(exported["pages"], 1); + assert!(session.execute("file.print", &json!({"pages": "9", "dryRun": dry_run})).is_err()); +} + +#[wasm_bindgen_test] +fn print_confirmation_preserves_editing_and_pdf_export() { + print_is_safe(false); +} + +#[wasm_bindgen_test] +fn print_dry_run_is_also_unsupported_without_native_operations() { + print_is_safe(true); +} From fc2739a8cdeeaa0f0fd5153a6108da5ab2db3605 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 9 Oct 2026 23:13:29 -0600 Subject: [PATCH 10/71] #61: honor MCP typing, story and page contracts (#118) --- crates/engine/src/cmd/text.rs | 18 ++++- crates/mcp/src/headless.rs | 18 ++++- crates/mcp/src/tests.rs | 84 +++++++++++++++++++++++ crates/mcp/src/tools.rs | 19 ++++-- crates/ui-egui/src/control.rs | 125 ++++++++++++++++++++++++++++++++-- crates/ui-egui/src/menus.rs | 2 +- 6 files changed, 250 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a75d6d47..552ce519 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -114,8 +114,8 @@ pub fn specs() -> Vec { cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, vertical, overset", has_doc, |s, p| { let st = s.doc()?; let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?; + let mut v = st.doc.story_summary(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let cs = s.cache.get(&st.doc, sid, None); - let mut v = st.doc.story_summary(sid).unwrap_or_default(); v["overset"] = json!(cs.overset_at); v["lines"] = json!(cs.line_count()); Ok(v) @@ -1638,3 +1638,19 @@ mod vertical_caret_tests { assert_eq!(mv(&mut s, "up", false, false), 3 * c); } } + +#[cfg(test)] +mod story_query_contract_tests { + use serde_json::json; + #[test] + fn missing_story_errors_but_valid_empty_and_selected_stories_succeed() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert!(s.execute("story.get", &json!({"story":999})).unwrap_err().to_string().contains("no such story")); + let f = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text"})).unwrap(); + assert_eq!(s.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], ""); + assert_eq!(s.execute("story.get", &json!({"frame":f["id"]})).unwrap()["text"], ""); + s.execute("text.select", &json!({"story":f["story"],"anchor":0})).unwrap(); + assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], ""); + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index ee9d11d1..aea3e876 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -180,7 +180,7 @@ impl Headless { fn text(&mut self, p: &Value) -> Result { let t = s(p, "text").ok_or("missing `text`")?; - if !self.session.wants_text() { + if self.session.active().is_none_or(|st| st.selection.text.is_none_or(|t| st.doc.text_story(t.story, t.cell).is_none())) { return Err("no text insertion point: click into a text frame with the type tool first (pointer with tool \"type\"), \ or select text with execute text.select, or use set_story_text" .into()); @@ -191,7 +191,7 @@ impl Headless { fn render(&mut self, p: &Value) -> Result { let st = self.session.active().ok_or("no document open")?; - let page = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize; + let page = page_index(p, st.doc.page_count())?; let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.05, 16.0); let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false); let img = self @@ -213,7 +213,7 @@ impl Headless { fn export(&mut self, p: &Value) -> Result { let path = s(p, "path").ok_or("missing `path`")?; let st = self.session.active().ok_or("no document open")?; - let page = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize; + let page = page_index(p, st.doc.page_count())?; let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(2.0).clamp(0.05, 16.0); let img = self .renderer @@ -263,3 +263,15 @@ impl Backend for Headless { "headless (in-process engine, no window)".into() } } + +/// A supplied page must be a representable unsigned index; only omission selects page zero. +fn page_index(p: &Value, count: usize) -> Result { + let page = match p.get("page") { + None => 0, + Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or("`page` must be a nonnegative integer representable as a page index")?, + }; + if page >= count { + return Err(format!("no page {page} (the document has {count} pages, indices are 0-based)")); + } + Ok(page) +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 2804d13f..3051a5ad 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -416,3 +416,87 @@ fn batch_steps_use_earlier_results() { ); assert_eq!(v["completed"], 2); } + +#[test] +fn explicit_typing_honors_caret_range_and_undo_without_changing_shortcuts() { + let mut s = server(); + assert_eq!(call(&mut s, "type_text", json!({"text":"X"}))["isError"], true); + let f = ok(&mut s, "execute", json!({"command":"frame.create","params":{"rect":[36,36,300,200],"content":"text","text":"Hello"}})); + let story = f["story"].as_u64().unwrap(); + for (anchor, focus, expected) in [(5, 5, "HelloX"), (0, 5, "X")] { + ok(&mut s, "set_story_text", json!({"story":story,"text":"Hello"})); + ok(&mut s, "execute", json!({"command":"text.select","params":{"story":story,"anchor":anchor,"focus":focus}})); + ok(&mut s, "type_text", json!({"text":"X"})); + assert_eq!(ok(&mut s, "get_story", json!({"story":story}))["text"], expected); + ok(&mut s, "execute", json!({"command":"edit.undo"})); + assert_eq!(ok(&mut s, "get_story", json!({"story":story}))["text"], "Hello"); + } + ok(&mut s, "execute", json!({"command":"text.placeCaret","params":{"frame":f["id"],"point":[100,50]}})); + ok(&mut s, "type_text", json!({"text":"X"})); + assert_eq!(ok(&mut s, "get_story", json!({"story":story}))["text"], "HelloX"); + assert_eq!(ok(&mut s, "key", json!({"key":"T"}))["handledBy"], "tool.select"); + assert_eq!(ok(&mut s, "get_story", json!({"story":story}))["text"], "HelloX"); +} + +#[test] +fn story_queries_distinguish_missing_from_empty() { + let mut s = server(); + assert_eq!(call(&mut s, "get_story", json!({"story":999}))["isError"], true); + let f = ok(&mut s, "execute", json!({"command":"frame.create","params":{"rect":[36,36,300,200],"content":"text"}})); + assert_eq!(ok(&mut s, "get_story", json!({"story":f["story"]}))["text"], ""); + assert_eq!(ok(&mut s, "get_story", json!({"frame":f["id"]}))["text"], ""); + ok(&mut s, "execute", json!({"command":"text.select","params":{"story":f["story"],"anchor":0}})); + assert_eq!(ok(&mut s, "get_story", json!({}))["text"], ""); +} + +#[test] +fn page_contract_rejects_supplied_invalid_values_without_writes() { + let mut s = server(); + ok(&mut s, "new_document", json!({"sample":true})); + let path = tmp("page-contract.png"); + for tool in ["export_png", "render_page"] { + for page in + [json!(-1), json!(-2), json!(1.5), Value::Null, json!("0"), json!(true), json!(u64::MAX), json!(18446744073709551616_f64), json!(99)] + { + std::fs::write(&path, b"KEEP").unwrap(); + let args = json!({"page":page,"path":path,"scale":0.1}); + assert_eq!(call(&mut s, tool, args)["isError"], true, "{tool}: {page}"); + assert_eq!(std::fs::read(&path).unwrap(), b"KEEP"); + } + } + let first = image_of(&call(&mut s, "render_page", json!({"page":0,"scale":0.1}))); + let next = image_of(&call(&mut s, "render_page", json!({"page":1,"scale":0.1}))); + assert_ne!(first, next); + assert_eq!(first, image_of(&call(&mut s, "render_page", json!({"scale":0.1})))); + ok(&mut s, "export_png", json!({"page":1,"path":path,"scale":0.1})); + assert_eq!(std::fs::read(&path).unwrap(), next); + ok(&mut s, "export_png", json!({"path":path,"scale":0.1})); + assert_eq!(std::fs::read(&path).unwrap(), first); + for tool in ["render_page", "export_png"] { + let definition = tool_definitions().into_iter().find(|v| v["name"] == tool).unwrap(); + assert_eq!(definition["inputSchema"]["properties"]["page"]["minimum"], 0); + } + let mut b = Headless::with_document(); + for method in ["ui.render", "app.export"] { + for page in [json!(-1), Value::Null, json!("0"), json!(99)] { + std::fs::write(&path, b"KEEP").unwrap(); + assert!(b.call(method, json!({"page":page,"path":path,"scale":0.1})).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"KEEP"); + } + std::fs::remove_file(&path).unwrap(); + assert!(b.call(method, json!({"page":-1,"path":path,"scale":0.1})).is_err()); + assert!(!path.exists()); + } +} + +#[test] +fn explicit_typing_rejects_stale_selection_and_closed_document() { + let mut b = Headless::with_document(); + let f = b.session.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text","text":"Hello"})).unwrap(); + b.session.execute("text.select", &json!({"story":f["story"],"anchor":5})).unwrap(); + b.session.active_mut().unwrap().selection.text.as_mut().unwrap().story.0 = 999; + assert!(b.call("ui.text", json!({"text":"X"})).is_err()); + assert_eq!(b.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello"); + b.session.execute("file.close", &json!({})).unwrap(); + assert!(b.call("ui.text", json!({"text":"X"})).is_err()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index dc2604c5..0ad99622 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -223,7 +223,7 @@ pub fn tool_definitions() -> Vec { also write it to `path`.", obj( json!({ - "page": int("Page index, 0-based (default 0; in the desktop app default = the current page)"), + "page": {"type": "integer", "minimum": 0, "description": "Page index, 0-based (default 0; in the desktop app default = the current page)"}, "scale": num("Pixels per point (default 1 = 72 ppi)"), "bleed": boolean("Include the bleed area"), "path": string("Also write the PNG here"), @@ -237,7 +237,7 @@ pub fn tool_definitions() -> Vec { "Export PNG", "Export one page as a PNG (or JPEG if the path ends in .jpg) file. Returns the path and pixel size.", obj( - json!({"path": string("Destination file"), "page": int("Page index, 0-based (default 0)"), "scale": num("Pixels per point (default 2)")}), + json!({"path": string("Destination file"), "page": {"type": "integer", "minimum": 0, "description": "Page index, 0-based (default 0; in the desktop app default = the current page)"}, "scale": num("Pixels per point (default 2)")}), &["path"], ), false, @@ -303,7 +303,7 @@ pub fn tool_definitions() -> Vec { tool( "type_text", "Type text", - "Type text at the text insertion point (as the keyboard would with the type tool), or into the focused dialog field in \ + "Type text at the text insertion point, independently of the active tool, or into the focused dialog field in \ the desktop app. Put a caret first with pointer (tool \"type\") or execute text.placeCaret / text.select.", obj(json!({"text": string("Text to type (\\n = new paragraph)")}), &["text"]), false, @@ -409,6 +409,15 @@ fn pick(a: &Args, keys: &[&str]) -> Value { Value::Object(keys.iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect()) } +/// Page omission selects a default, but an explicitly supplied null must reach validation. +fn page_params(a: &Args, keys: &[&str]) -> Value { + let mut p = pick(a, keys); + if let Some(page) = a.get("page") { + p["page"] = page.clone(); + } + p +} + fn command_params(v: Option<&Value>) -> Result { match v { None | Some(Value::Null) => Ok(json!({})), @@ -463,7 +472,7 @@ fn story_id(b: &mut dyn Backend, a: &Args) -> Result { } fn render_page(b: &mut dyn Backend, a: &Args) -> Result { - let r = b.call("ui.render", pick(a, &["page", "scale", "bleed"]))?; + let r = b.call("ui.render", page_params(a, &["page", "scale", "bleed"]))?; let b64 = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?.to_string(); let path = a.get("path").and_then(Value::as_str); if let Some(path) = path { @@ -550,7 +559,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result render_page(b, a), "export_png" => { req_str(a, "path")?; - j(b.call("app.export", pick(a, &["path", "page", "scale"]))?) + j(b.call("app.export", page_params(a, &["path", "page", "scale"]))?) } "screenshot" => screenshot(b, a), "select_tool" => j(b.call("ui.tool.select", json!({"tool": req_str(a, "tool")?}))?), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 32450259..e81e4c19 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -8,7 +8,8 @@ //! - `ui.tool.select {tool}`, `ui.tool.list` //! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`: //! drive the active tool through the same path as the mouse -//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: synthetic keyboard input +//! - `ui.key {key, shift?, alt?, cmd?}`: synthetic keyboard input +//! - `ui.text {text}`: insert at the explicit caret, or type into a focused UI field //! - `ui.move {x, y}` / `ui.click {x, y, button?, count?, shift?…}` / `ui.drag {x, y, toX, toY, steps?}`: //! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs) //! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}` @@ -216,9 +217,19 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> ok(Value::Null) } "ui.text" => { - app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string())); - ctx.request_repaint(); - ok(Value::Null) + let Some(text) = s("text") else { return err("missing `text`") }; + // Focused widgets retain the synthetic keyboard route. Document automation uses + // the explicit caret independently of the active tool, unlike ordinary typing. + if ctx.text_edit_focused() || app.ui.dialog.is_some() || app.ui.palette.is_some() { + app.synthetic.push(egui::Event::Text(text.to_string())); + ctx.request_repaint(); + ok(Value::Null) + } else { + if app.session.active().is_none_or(|st| st.selection.text.is_none_or(|t| st.doc.text_story(t.story, t.cell).is_none())) { + return err("no valid text insertion point: use text.select or text.placeCaret first"); + } + wrap(app.run("text.insert", json!({"text": text}))) + } } "ui.set" => { let mut r = Ok(Value::Null); @@ -313,7 +324,10 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.render" => { let Some(st) = app.session.active() else { return err("no document") }; - let page = p.get("page").and_then(Value::as_u64).map(|v| v as usize).or_else(|| crate::canvas::current_page(app)).unwrap_or(0); + let page = match page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count()) { + Ok(page) => page, + Err(e) => return err(e), + }; let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0); let Some(img) = app.canvas.renderer.render_page( &st.doc, @@ -336,7 +350,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "app.open" => wrap(app.run("file.open", json!({"path": s("path")}))), "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), - "app.export" => wrap(app.run("app.exportPng", json!({"path": s("path"), "page": p.get("page"), "scale": p.get("scale")}))), + "app.export" => wrap(app.run("app.exportPng", p.clone())), "app.quit" => { ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Value::Null) @@ -361,3 +375,102 @@ pub fn save_screenshot(app: &mut DesignApp, image: &egui::ColorImage, path: Opti None => json!({"ok": false, "error": "no writer configured"}), } } + +/// Parse explicit page indices without treating invalid values as omission. +pub(crate) fn page_index(p: &Value, default: usize, count: usize) -> Result { + let page = match p.get("page") { + None => default, + Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or("`page` must be a nonnegative integer representable as a page index")?, + }; + if page >= count { + return Err(format!("no page {page} (the document has {count} pages, indices are 0-based)")); + } + Ok(page) +} + +#[cfg(test)] +mod automation_contract_tests { + use super::*; + fn app() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages":2})).unwrap(); + app + } + fn call(app: &mut DesignApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (req, _) = ControlRequest::new(method, params); + match handle(app, ctx, &req) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => panic!("unexpected screenshot"), + } + } + fn frame(app: &mut DesignApp, ctx: &egui::Context, events: Vec) { + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }, + |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }, + ); + output.textures_delta.clear(); + } + #[test] + fn control_typing_uses_explicit_caret_but_retains_widget_events() { + let mut app = app(); + let ctx = egui::Context::default(); + assert_eq!(call(&mut app, &ctx, "ui.text", json!({"text":"X"}))["ok"], false); + let f = app.session.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text","text":"Hello"})).unwrap(); + app.session.execute("text.select", &json!({"story":f["story"],"anchor":5})).unwrap(); + assert_eq!(app.session.tool_id(), "selection"); + assert_eq!(call(&mut app, &ctx, "ui.text", json!({"text":"X"}))["ok"], true); + assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "HelloX"); + app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello"); + app.select_tool("type"); + assert!(app.session.wants_text(), "the document would receive ordinary typing without widget focus"); + app.ui.palette = Some(String::new()); + for _ in 0..3 { + frame(&mut app, &ctx, vec![]); + } + assert!(ctx.text_edit_focused()); + assert_eq!(call(&mut app, &ctx, "ui.text", json!({"text":"field"}))["ok"], true); + let events = std::mem::take(&mut app.synthetic); + frame(&mut app, &ctx, events); + assert_eq!(app.ui.palette.as_deref(), Some("field")); + assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello"); + } + #[test] + fn control_pages_validate_before_writer_and_preserve_omission() { + let mut app = app(); + let ctx = egui::Context::default(); + let writes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = writes.clone(); + app.services.write = Some(Box::new(move |_, bytes| { + sink.lock().unwrap().push(bytes.to_vec()); + Ok(()) + })); + app.session.execute("frame.create", &json!({"rect":[36,36,300,200]})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + crate::canvas::go_to_page(&mut app, 1); + assert_eq!(crate::canvas::current_page(&app), Some(1)); + for method in ["ui.render", "app.export"] { + for page in + [json!(-1), json!(-2), json!(1.5), Value::Null, json!("0"), json!(true), json!(u64::MAX), json!(18446744073709551616_f64), json!(99)] + { + assert_eq!(call(&mut app, &ctx, method, json!({"page":page,"path":"unchanged.png","scale":0.1}))["ok"], false, "{method}: {page}"); + assert!(writes.lock().unwrap().is_empty()); + } + assert_eq!(call(&mut app, &ctx, method, json!({"path":"omitted.png","scale":0.1}))["ok"], true); + assert_eq!(call(&mut app, &ctx, method, json!({"page":1,"path":"explicit.png","scale":0.1}))["ok"], true); + let mut bytes = writes.lock().unwrap(); + assert_eq!(bytes.len(), 2); + assert_eq!(bytes[0], bytes[1]); + bytes.clear(); + } + assert!(app.run("app.exportPng", json!({"page":Value::Null,"path":"unchanged.png"})).is_err()); + assert!(writes.lock().unwrap().is_empty()); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index be0b0303..2a04e7cd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1393,7 +1393,7 @@ fn download_document(app: &mut DesignApp) -> Result { fn export_png(app: &mut DesignApp, p: &Value) -> Result { let st = app.session.active().ok_or("no document")?; - let page = p.get("page").and_then(Value::as_u64).map(|v| v as usize).or_else(|| crate::canvas::current_page(app)).unwrap_or(0); + let page = crate::control::page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count())?; let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(2.0); let mut r = designcraft_render::Renderer::new(); let img = r From 691d0de18353b5fd19a2683476e67e6812c40099 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 9 Oct 2026 23:13:35 -0600 Subject: [PATCH 11/71] #72: preserve typed generic dialog values (#120) --- crates/ui-egui/src/dialogs.rs | 75 +++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 450e64c2..f44503f7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1700,10 +1700,12 @@ pub fn confirm(app: &mut DesignApp) -> Result { let parsed = serde_json::from_str::(v).ok().filter(|x| !x.is_string()); p.insert(f.key, parsed.unwrap_or_else(|| json!(v))); } - Some(Value::Bool(b)) => { - p.insert(f.key, json!(b)); + Some(Value::String(_)) | None => {} + Some(value) => { + // Control/MCP callers can supply JSON directly. Preserve its type, + // including explicit null, for the command to interpret. + p.insert(f.key, value.clone()); } - _ => {} } } let r = app.run(cid, Value::Object(p)); @@ -2671,6 +2673,73 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + fn command_app(id: &str, fields: Value) -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.dialog = Some(Dialog::new(&format!("cmd:{id}"), fields)); + app + } + + #[test] + fn generic_new_document_preserves_typed_values_and_text_entry() { + for pages in [json!(2), json!(" 2 ")] { + let mut app = command_app( + "file.new", + json!({ + "preset": "A4", "pages": pages, "facingPages": false, "gutter": 0, + "margins": {"top": 10, "bottom": 20, "inside": 30, "outside": 40}, + "title": " ", "status": "internal", "primaryTextFrame": true + }), + ); + confirm(&mut app).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.page_count(), 2); + assert!(!doc.settings.facing_pages); + assert!(doc.title.starts_with("Untitled-")); + for page in doc.spreads.iter().flat_map(|s| &s.pages) { + assert!((page.width - 595.2755905511812).abs() < 0.01); + assert!((page.height - 841.8897637795277).abs() < 0.01); + assert_eq!(page.columns.gutter, 0.0); + assert_eq!(page.margins.top, 10.0); + assert_eq!(page.margins.outside, 40.0); + } + assert!(doc.spreads.iter().all(|s| s.items.is_empty()), "undocumented fields stay excluded"); + assert!(app.ui.dialog.is_none()); + } + } + + #[test] + fn generic_command_preserves_array_geometry() { + let mut app = command_app("file.new", json!({})); + confirm(&mut app).unwrap(); + app.ui.dialog = Some(Dialog::new("cmd:frame.create", json!({"rect": [10, 20, 110, 220]}))); + confirm(&mut app).unwrap(); + let doc = &app.session.active().unwrap().doc; + let item = doc.spreads.iter().flat_map(|s| &s.items).next().unwrap(); + assert_eq!(item.bounds(), designcraft_geom::Rect::new(10.0, 20.0, 110.0, 220.0)); + } + + #[test] + fn generic_explicit_null_uses_the_commands_existing_semantics() { + // file.new accepts null width by using the selected preset's width. + for width in [Value::Null, json!("null")] { + let mut app = command_app("file.new", json!({"preset": "A4", "width": width, "title": " Sample layout "})); + confirm(&mut app).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.title, "Sample layout"); + assert!((doc.settings.page_width - 595.2755905511812).abs() < 0.01); + } + } + + #[test] + fn generic_invalid_number_retains_dialog_without_creating_document() { + let mut app = command_app("file.new", json!({"width": 0})); + assert!(confirm(&mut app).is_err()); + assert!(app.session.active().is_none()); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.fields["width"], 0); + assert!(dialog.fields["status"].as_str().unwrap().contains("page size out of range")); + } + #[test] fn parses_command_params() { let f = command_fields("{name, type: custom|lastPageNumber|chapterNumber, text?, rule?: {on, weight}, flag?: bool} — creates"); From 3033c8cc64d23c88c7171758c9e6cd0969a95a05 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:13:42 -0400 Subject: [PATCH 12/71] CLI: export options after the last export are an error; MCP and README docs match the code (#127) * CLI: run rejects an export option with no export after it (#67) --page, --scale and --pdf-options apply to the exports that follow them; one given after the last --export or --all-pages did nothing and said nothing. The usage text and docs/agents.md say so. * MCP: document that text offsets and story lengths are UTF-8 bytes (#68) * MCP: docs name the UI-only commands precisely; app.links works headless (#62) * README: PDF and EPUB export ship (#60) --- README.md | 3 ++- apps/designcraft-cli/src/main.rs | 34 +++++++++++++++++++++++++++++-- apps/designcraft-cli/tests/cli.rs | 22 ++++++++++++++++++++ crates/mcp/src/tools.rs | 12 ++++++----- docs/agents.md | 4 ++++ docs/mcp.md | 10 ++++++++- 6 files changed, 76 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 41af2b45..c6c08faf 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,8 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or Line breaks are identical on screen and in PDF. - **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo snapshots, and cached composition. -- **Open.** A documented native format, IDML import and export, PNG export, and PDF on the roadmap. +- **Open.** A documented native format, IDML import and export, and PNG, PDF (PDF/X-4, PDF/A-2b) + and EPUB export. No subscription, no licence server, no telemetry. - **Agent-native.** Every menu item, tool gesture, panel control and dialog can be driven over a JSON control channel and an **MCP server**, so Claude and other agents can lay out and edit diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 9e9eba25..a564befc 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -1,7 +1,8 @@ //! Headless DesignCraft. //! //! ```text -//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--pdf-options JSON] [--all-pages DIR] +//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--all-pages DIR] +//! # --page, --scale and --pdf-options apply to the exports that follow them //! designcraft-cli commands [FILTER] # list commands (JSON), optionally only ids/labels/menus containing FILTER //! designcraft-cli describe ID # one command: label, menu, shortcut, parameters //! designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] @@ -77,7 +78,7 @@ fn main() -> ExitCode { } _ => { eprintln!( - "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version" + "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version" ); eprintln!( "\nCommunity: {} · {} · {}", @@ -136,7 +137,36 @@ fn mcp(args: &[String]) -> Result<(), String> { Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) } +/// The first `--page`, `--scale` or `--pdf-options` with no `--export` or `--all-pages` after it. +/// They apply to the exports that follow them, so one after the last export would do nothing. +fn option_after_last_export(args: &[String]) -> Option<&str> { + let mut unused = None; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--page" | "--scale" | "--pdf-options" => { + unused = unused.or(Some(a.as_str())); + it.next(); + } + "--export" | "--all-pages" => { + unused = None; + it.next(); + } + "--in" | "--cmd" => { + it.next(); + } + _ => {} + } + } + unused +} + fn run(args: &[String]) -> Result<(), String> { + if let Some(opt) = option_after_last_export(args) { + return Err(format!( + "{opt} has no --export or --all-pages after it; options apply to the exports that follow them, so put it before its --export" + )); + } let mut s = Session::new(); let mut page = 0usize; let mut scale = 1.0f64; diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 75f6d90b..8c2b0f14 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -80,3 +80,25 @@ fn closed_stdout_ends_quietly() { let err = String::from_utf8_lossy(&out.stderr); assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status); } + +/// `--scale`, `--page` and `--pdf-options` apply to the exports after them; one with no export +/// after it was silently ignored. +#[test] +fn run_export_options_come_before_their_export() { + let dir = std::env::temp_dir().join(format!("dc-cli-order-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.png"), dir.join("b.png")); + let (a, b) = (a.to_str().unwrap(), b.to_str().unwrap()); + for (opt, val) in [("--scale", "0.5"), ("--page", "1"), ("--pdf-options", r#"{"pages":"1"}"#)] { + let out = cli().args(["run", "--sample", "--export", a, opt, val]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{opt} after the last --export: {err}"); + assert!(err.contains(opt) && err.contains("before"), "{err}"); + } + assert!(!std::path::Path::new(a).exists(), "nothing is exported when the arguments are wrong"); + let out = cli().args(["run", "--sample", "--scale", "0.5", "--export", a, "--scale", "0.25", "--export", b]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{err}"); + assert!(err.contains("wrote") && std::path::Path::new(a).exists() && std::path::Path::new(b).exists(), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 0ad99622..802f615d 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -106,7 +106,8 @@ pub fn tool_definitions() -> Vec { "Run any DesignCraft command by id with JSON params (ids and params from list_commands). Examples: \ {\"command\":\"frame.create\",\"params\":{\"rect\":[36,36,576,300],\"content\":\"text\",\"text\":\"Hello\"}} → {id, story}; \ {\"command\":\"type.char\",\"params\":{\"size\":24}}; {\"command\":\"edit.undo\"}. Coordinates are points in spread space. \ - In the desktop app UI-only commands (view.*, window.*, app.*) work too.", + In the desktop app the UI-only commands (view.*, window.*, help.*, edit.dynamicSpelling, and app.* except app.links, \ + which works everywhere) work too.", obj(json!({"command": string("Command id, e.g. frame.create"), "params": params_schema()}), &["command"]), false, ), @@ -137,16 +138,17 @@ pub fn tool_definitions() -> Vec { "inspect_document", "Inspect document", "Summary of the active document: settings, pages (index, name, bounds, margins, columns), spreads with items (id, kind, \ - bounds, fill, stroke, story), stories (id, frames, length, overset, preview), layers, paragraph/character styles, \ - swatches, selection, active tool.", + bounds, fill, stroke, story), stories (id, frames, length in UTF-8 bytes, overset, preview), layers, \ + paragraph/character styles, swatches, selection, active tool.", empty(), true, ), tool( "get_story", "Get story", - "Full text of a story plus its frames, paragraph count, line count and overset position. Identify it by `story` id or \ - by a text `frame` id (default: the selection).", + "Full text of a story plus its length, frames, paragraph count, line count and overset position (length and positions \ + are UTF-8 byte offsets, the unit text.select takes). Identify it by `story` id or by a text `frame` id (default: the \ + selection).", obj(json!({"story": int("Story id"), "frame": int("Text frame id")}), &[]), true, ), diff --git a/docs/agents.md b/docs/agents.md index 4571b279..0dd23f06 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -41,6 +41,10 @@ The result is JSON: `{"completed": n, "results": [...]}`, plus `failedIndex` / ` fails (the exit status is non-zero; `--keep-going` records errors and continues). `run --cmd ID=JSON` accepts the same references, and MCP's `batch` tool takes `commands` or `script` text with them. +`run` works through its arguments in order. `--page`, `--scale` and `--pdf-options` apply to the `--export` / +`--all-pages` that follow them, so one run can write several sizes +(`run --sample --scale 0.5 --export small.png --scale 2 --export large.png`); one with no export after it is an error. + ## Data merge `data.source.select {path, sheet?}` links a CSV/TSV/`.xlsx` file to the document (`data.source.update` re-reads it, diff --git a/docs/mcp.md b/docs/mcp.md index 2181fef9..f53c5676 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -31,7 +31,7 @@ Other MCP clients use the same command line, e.g. in a JSON config: | Engine | In-process `designcraft_engine::Session`, starts with an empty Letter document (`--sample` opens the sample magazine) | The app's session, through its loopback control channel ([control-protocol.md](control-protocol.md)) | | Rendering | `designcraft-render` (CPU) | The app's renderer | | Window tools (`screenshot`, `click`, `drag`, `menu_list`, `ui_inspect`, `ui_set`, `dialog_*`) | Return an error explaining how to connect | Work | -| UI-only commands (`view.*`, `window.*`, `app.*`) via `execute` | Error | Work | +| UI-only commands via `execute` (`view.*`, `window.*`, `help.*`, `edit.dynamicSpelling`, and `app.*` except `app.links`) | Error | Work | In connected mode every tool maps to a control-channel method (`engine.execute`, `ui.render`, `ui.pointer`, …), so what the agent does shows up live in the window. The connection is re-established once if the app restarts. @@ -43,6 +43,14 @@ spread the right page starts at x = page width. `inspect_document` lists page bo `render_page` / `export_png` are 0-based. `pointer` events in connected mode may use `"space": "screen"` (egui points, see `ui_inspect` → `canvasRect`). +## Text offsets + +Positions in a story's text are **UTF-8 byte offsets**, not character counts: `anchor` / `focus` of `text.select`, +`start` / `end` of `story.replaceRange` and of `find.find` matches, the `pos` that `text.placeCaret`, `text.insert` +and `text.move` return, a story's `length` in `get_story` and `inspect_document`, and its `overset` position in +`get_story`. A character outside ASCII takes 2 to 4 bytes (é 2, — and は 3), so the story `はただ商店` has length 15 +and its second character starts at 3. An offset inside a character snaps back to the start of that character. + ## Tools | Tool | What it does | From fe16e00e3753714cef96818fdc2d6e8a373d431b Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:13:48 -0400 Subject: [PATCH 13/71] PDF/A keeps placed images; right-to-left tables and columns (#128) * PDF: PDF/A exports keep placed PNG, JPEG, GIF and WebP images by writing them without interpolation (#71) * Tables: new tables take their paragraph's direction, and the Table panel sets a table's direction (#100) * Text: stories made in a right-to-left-bound document fill their columns from the right (#100) --- crates/doc/src/edit.rs | 7 ++++ crates/engine/src/cmd/layout.rs | 29 +++++++++++++++ crates/engine/src/cmd/object.rs | 1 + crates/engine/src/cmd/path_type.rs | 1 + crates/engine/src/cmd/place_text.rs | 1 + crates/engine/src/cmd/table.rs | 55 ++++++++++++++++++++++++++++- crates/pdf/src/export.rs | 30 +++++++++------- crates/pdf/src/tests.rs | 49 +++++++++++++++++++++++++ crates/ui-egui/src/panels/table.rs | 10 ++++++ 9 files changed, 169 insertions(+), 14 deletions(-) diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 188f514d..3bb82713 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -171,11 +171,18 @@ impl Document { Ok(removed) } + /// The column direction a new story starts with: right to left in a document whose binding is + /// right to left (Document Setup), else left to right. Stories already made keep theirs. + pub fn new_story_direction(&self) -> crate::TextDirection { + if self.settings.right_to_left_binding { crate::TextDirection::RightToLeft } else { crate::TextDirection::LeftToRight } + } + /// Create a text frame with a new (or given) story at `rect` on spread `r`. pub fn add_text_frame(&mut self, r: SpreadRef, rect: Rect, layer: LayerId, text: &str, para: ParaFormat) -> Result<(ItemId, StoryId)> { let id = ItemId(self.alloc()); let sid = StoryId(self.alloc()); let mut story = Story::with_text(sid, text, para); + story.direction = self.new_story_direction(); story.frames.push(id); self.stories.insert(sid, Arc::new(story)); let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect)); diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 2a1b0751..bd532079 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -759,6 +759,35 @@ mod setup_tests { let d = &s.doc().unwrap().doc; assert_eq!(d.spreads[1].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Left, 0.0), (Right, w)]); } + + /// Multi-column frames filled the left column first in a right-to-left document (#100): + /// stories made in a right-to-left-bound document start their columns on the right. + #[test] + fn new_stories_follow_the_binding_direction() { + use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; + let text = (1..=12).map(|i| format!("الفقرة {i}")).collect::>().join("\n"); + for (binding, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [40, 170, 555, 260], "content": "text", "text": text})).unwrap(); + s.execute("object.textFrameOptions", &json!({"ids": [f["id"]], "columns": 2, "gutter": 20})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().direction, expected, "{binding}"); + let cs = s.cache.get(&st.doc, sid, None); + let fr = &cs.frames[0]; + let (first, last) = (fr.lines.first().unwrap(), fr.lines.last().unwrap()); + assert!(fr.columns.len() == 2 && first.x0 != last.x0, "the text fills both columns: {:?}", fr.columns); + assert_eq!(first.x0 > last.x0, expected == RightToLeft, "{binding}: the first line is in the first column"); + // An empty frame given text content starts a story too. + let g = s.execute("frame.create", &json!({"rect": [40, 400, 555, 500]})).unwrap(); + s.execute("object.content", &json!({"ids": [g["id"]], "type": "text"})).unwrap(); + let d = &s.doc().unwrap().doc; + let tf = d.item(designcraft_doc::ItemId(g["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(d.story(tf).unwrap().direction, expected, "{binding}: object.content"); + } + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 40802d54..dc456a13 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1379,6 +1379,7 @@ fn content_type(s: &mut Session, p: &Value) -> Result { ("text", Some(Content::Unassigned | Content::Graphic(_))) => { let sid = StoryId(d.alloc()); let mut st = Story::new(sid); + st.direction = d.new_story_direction(); st.frames.push(*id); d.stories.insert(sid, Arc::new(st)); if let Some(it) = d.item_mut(*id) { diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index 883f0c20..77bbf87a 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -54,6 +54,7 @@ fn on_path(s: &mut Session, p: &Value) -> Result { } let sid = StoryId(d.alloc()); let mut st = Story::with_text(sid, &text, Default::default()); + st.direction = d.new_story_direction(); st.frames = vec![id]; d.stories.insert(sid, std::sync::Arc::new(st)); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 39805bfb..766811b5 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -200,6 +200,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - Some(_) => { let sid = StoryId(d.alloc()); let mut ns = Story::new(sid); + ns.direction = d.new_story_direction(); ns.frames = vec![fid]; d.stories.insert(sid, std::sync::Arc::new(ns)); if let Some(it) = d.item_mut(fid) { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c2f925c0..3bb188b4 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -372,6 +372,14 @@ fn column_width(s: &Session, t: &TextSel) -> f64 { spec.and_then(|f| f.columns().first().map(|c| c.width())).unwrap_or(300.0) } +/// The direction of the paragraph at `offset` in `story`, as composition resolves it (its style +/// chain, then local overrides). A table created there takes it. +fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_doc::TextDirection { + d.story(story) + .and_then(|st| st.paras.get(st.para_at(offset))) + .map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction) +} + fn insert(s: &mut Session, p: &Value) -> Result { let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?; if t.cell.is_some() { @@ -385,6 +393,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { s.edit(|d, sel| { let id = d.alloc(); let mut table = Table::new(id, rows, cols, header, footer, width); + table.options.direction = para_direction(d, t.story, t.range().start); let st = d.story_mut(t.story).ok_or_else(|| bad("table.insert", "no story"))?; // Cells start with the paragraph format at the caret. let pf = st.paras[st.para_at(t.range().start)].clone(); @@ -410,6 +419,7 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result { let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); s.edit(|d, sel| { let id = d.alloc(); + let direction = para_direction(d, t.story, t.range().start); let st = d.story_mut(t.story).ok_or_else(|| bad("table.convertFromText", "no story"))?; let ranges = st.para_ranges(); let (pa, pb) = (st.para_at(t.range().start), st.para_at(t.range().end)); @@ -420,7 +430,8 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result { let data: Vec> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect(); let pf = st.paras[pa].clone(); let cf = st.format_after(a).clone(); - let table = Table::from_strings(id, &data, width, &pf, &cf); + let mut table = Table::from_strings(id, &data, width, &pf, &cf); + table.options.direction = direction; st.delete(a..b); let anchor = st.insert_table(a, table); *sel = Selection::text(TextSel::caret(t.story, anchor)); @@ -1435,4 +1446,46 @@ mod arabic_tests { assert_eq!(t.ncols(), 2); assert_eq!(t.options.direction, designcraft_doc::TextDirection::RightToLeft); } + + /// A text frame whose paragraphs have a paragraph style with `direction`, with the story's + /// text selected. Returns the session and the story id. + fn styled_frame(direction: &str, text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Dir", "para": {"direction": direction}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [40, 330, 555, 420], "content": "text", "text": text, "caret": true})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Dir"})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + (s, r["story"].as_u64().unwrap()) + } + + fn only_table(s: &Session) -> std::sync::Arc { + s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap().clone() + } + + /// Text converted to a table in a right-to-left paragraph laid its columns out left to right + /// (#100): the first column belongs on the right. + #[test] + fn tables_take_the_direction_of_their_paragraph() { + use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; + let (mut s, sid) = styled_frame("rightToLeft", "١\t٢\t٣\nأ\tب\tج"); + s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap(); + assert_eq!(only_table(&s).options.direction, RightToLeft); + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, designcraft_doc::StoryId(sid), None); + let placed = &cs.frames[0].tables[0]; + let (first, last) = (placed.cell(0, 0).unwrap().rect, placed.cell(0, 2).unwrap().rect); + assert!(first.x0 > last.x0, "cell ١ is the rightmost: {first:?} {last:?}"); + + let (mut s, _) = styled_frame("leftToRight", "1\t2\t3\na\tb\tc"); + s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap(); + assert_eq!(only_table(&s).options.direction, LeftToRight); + + for (direction, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { + let (mut s, _) = styled_frame(direction, "x"); + s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); + assert_eq!(only_table(&s).options.direction, expected, "table.insert in a {direction} paragraph"); + } + } } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0c77e69b..095ea77c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -154,6 +154,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) fonts: HashMap::new(), reverse_cmaps: HashMap::new(), rgb_only: archival.is_some(), + interpolate: archival.is_none(), tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, @@ -327,6 +328,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R fonts: HashMap::new(), reverse_cmaps: HashMap::new(), rgb_only: false, + interpolate: true, tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, @@ -421,6 +423,8 @@ pub(crate) struct Exporter<'a> { pub reverse_cmaps: HashMap>>, /// Convert CMYK to RGB (PDF/A: krilla needs a CMYK output profile we don't ship yet). pub rgb_only: bool, + /// Ask viewers to smooth upscaled images (`/Interpolate`). Off for PDF/A, which forbids it. + interpolate: bool, /// Tagged PDF: the structure in reading order (stories gather their frames' content). tags: Vec, story_tags: HashMap>, @@ -1168,21 +1172,21 @@ impl Exporter<'_> { let data = asset.data.clone(); let fmt = image::guess_format(&data).ok(); let img = match fmt { - Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), true).ok(), + Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), self.interpolate).ok(), // A CMYK TIFF keeps its ink values (`image` decodes it to RGB, and 100% K would print // as four-colour black). PDF/A exports are RGB only. - Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data) { + Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, self.interpolate) { Some(img) => Some(img), None => { self.warn(format!( "{}: converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)", asset.name )); - lossless(&data, fmt) + lossless(&data, fmt, self.interpolate) } }, - _ if self.opts.compress_images => recompress(&data).or_else(|| lossless(&data, fmt)), - _ => lossless(&data, fmt), + _ if self.opts.compress_images => recompress(&data, self.interpolate).or_else(|| lossless(&data, fmt, self.interpolate)), + _ => lossless(&data, fmt, self.interpolate), }; if img.is_none() { self.warn(format!("image `{}` could not be decoded and was skipped", asset.name)); @@ -1276,11 +1280,11 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option> { Some(out) } -fn lossless(data: &Arc>, fmt: Option) -> Option { +fn lossless(data: &Arc>, fmt: Option, interpolate: bool) -> Option { let direct = match fmt { - Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), true).ok(), - Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), true).ok(), - Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), true).ok(), + Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), interpolate).ok(), + Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), interpolate).ok(), + Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), interpolate).ok(), _ => None, }; direct.or_else(|| { @@ -1320,18 +1324,18 @@ impl krilla::image::CustomImage for CmykImage { } /// A CMYK TIFF as a DeviceCMYK image. `None` when it can't be read as CMYK. -fn cmyk_tiff(data: &[u8]) -> Option { +fn cmyk_tiff(data: &[u8], interpolate: bool) -> Option { let r = designcraft_images::decode_cmyk_tiff(data)?; // krilla panics when the channel lengths don't match the size. let pixels = usize::try_from(r.width).ok()?.checked_mul(usize::try_from(r.height).ok()?)?; if r.cmyk.len() != pixels.checked_mul(4)? || r.alpha.as_ref().is_some_and(|a| a.len() != pixels) { return None; } - Image::from_custom(CmykImage(Arc::new(r)), true).ok() + Image::from_custom(CmykImage(Arc::new(r)), interpolate).ok() } /// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded. -fn recompress(data: &[u8]) -> Option { +fn recompress(data: &[u8], interpolate: bool) -> Option { let img = image::load_from_memory(data).ok()?; if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) { return None; @@ -1340,5 +1344,5 @@ fn recompress(data: &[u8]) -> Option { let mut buf = Vec::new(); let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90); image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?; - Image::from_jpeg(buf.into(), true).ok() + Image::from_jpeg(buf.into(), interpolate).ok() } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 1771c4e4..83744a3e 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -271,6 +271,55 @@ fn cmyk_tiff_keeps_its_inks() { } } +/// An 8×8 raster encoded as `fmt`; `alpha` gives it a transparent corner. +fn raster(fmt: image::ImageFormat, alpha: bool) -> Vec { + let img = + image::RgbaImage::from_fn(8, 8, |x, y| image::Rgba([(x * 30) as u8, (y * 30) as u8, 128, if alpha && x == 0 && y == 0 { 0 } else { 255 }])); + let img = if fmt == image::ImageFormat::Jpeg { + image::DynamicImage::ImageRgb8(image::DynamicImage::ImageRgba8(img).to_rgb8()) + } else { + image::DynamicImage::ImageRgba8(img) + }; + let mut b = std::io::Cursor::new(Vec::new()); + img.write_to(&mut b, fmt).unwrap(); + b.into_inner() +} + +/// Each image XObject's /Interpolate value (`None` when absent). +fn interpolate_flags(bytes: &[u8]) -> Vec> { + use hayro_syntax::object::Name; + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse"); + pdf.objects() + .into_iter() + .filter_map(|o| o.into_stream()) + .filter(|s| s.dict().get::("Subtype").is_some_and(|n| n.as_str() == "Image")) + .map(|s| s.dict().get::("Interpolate")) + .collect() +} + +/// PDF/A-2b rejected placed PNG, JPEG, GIF and WebP images (#71): they were written with +/// /Interpolate true, which PDF/A forbids. Ordinary exports keep interpolation. +#[test] +fn archival_exports_keep_placed_images_without_interpolation() { + use image::ImageFormat as F; + let mut d = doc_with_text("x"); + for (fmt, alpha) in [(F::Png, true), (F::Png, false), (F::Jpeg, false), (F::Gif, true), (F::WebP, true)] { + place(&mut d, raster(fmt, alpha), (8, 8)); + } + for compress_images in [false, true] { + let opts = PdfOptions { compress_images, created: Some(1_700_000_000), ..Default::default() }; + let a = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, ..opts.clone() }); + let a = a.unwrap_or_else(|e| panic!("PDF/A-2b with images (compress {compress_images}): {e:?}")); + let flags = interpolate_flags(&a.bytes); + assert!(flags.len() >= 5, "every placed image is in the PDF/A file: {flags:?}"); + assert!(flags.iter().all(|f| *f != Some(true)), "{flags:?}"); + let plain = export_pdf(&d, &Cache::new(), &opts).unwrap(); + let flags = interpolate_flags(&plain); + assert!(flags.len() >= 5, "{flags:?}"); + assert!(flags.contains(&Some(true)), "ordinary exports interpolate: {flags:?}"); + } +} + /// What a page draws: each glyph (its text and outline bounds) and each filled path's bounds. fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec) { use hayro_interpret::font::Glyph; diff --git a/crates/ui-egui/src/panels/table.rs b/crates/ui-egui/src/panels/table.rs index 3f2d871f..7a1cb3aa 100644 --- a/crates/ui-egui/src/panels/table.rs +++ b/crates/ui-egui/src/panels/table.rs @@ -191,6 +191,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } }); divider(ui); + // Column order: right to left puts the first column on the right. + let rtl = info["options"]["direction"] == "rightToLeft"; + ui.horizontal(|ui| { + caption(ui, crate::i18n::tr(&app.ui.language, "Direction")); + for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] { + if ui.selectable_label(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() && !on { + run(app, "table.options", json!({"direction": dir})); + } + } + }); let mut alt = !info["options"]["altRows"].is_null(); if ui.checkbox(&mut alt, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Alternating Row Fills"))).changed() { let v = if alt { json!({"first": 1, "firstColor": "[Black]", "firstTint": 0.1, "next": 1, "nextColor": "[None]"}) } else { Value::Null }; From ce3deb651aa1e5bcaf10e4b2632e4799f5d396a1 Mon Sep 17 00:00:00 2001 From: jahala Date: Sat, 10 Oct 2026 06:13:54 +0100 Subject: [PATCH 14/71] Commands: huge row and column counts can't exhaust memory (#137) Three commands took a count from their parameters and used it unchecked. Parameters come from MCP, the control channel and scripts, so the count is hostile input (never-crash: cap input-sized allocations, checked arithmetic). - layout.createGuides made one or two guides per row and column with no upper limit: 30 million rows took 1.2 GB, and 2^32 - 1 would exhaust memory, which aborts the process past the command guard. A count over 2^32 wrapped to a small one. Both counts are now capped at 100. - edit.stepAndRepeat multiplied rows by columns as usize. In debug builds the overflow panicked; in release builds 2^32 x 2^32 wraps to 0, passes the 1000-copy limit and starts a grid of 2^64 copies. The product is now checked, and a grid that large falls back to `count` copies like any other grid over 1000. - file.new stored any column count in the page (4 billion, or a wrapped value past 2^32). Page::column_rects builds one rect per column each time the columns are laid out. The count is now clamped to 1..=216, the range layout.marginsAndColumns already uses. Each case has a regression test that failed before the fix. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/guides.rs | 22 +++++++++++++++++++--- crates/engine/src/cmd/object.rs | 2 +- crates/engine/src/tests.rs | 22 ++++++++++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index e4000009..767c6228 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -124,7 +124,7 @@ fn file_new(s: &mut Session, p: &Value) -> Result { nd.height = f64_or(p, "height", nd.height); nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999); nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages); - nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(nd.columns); + nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns); nd.gutter = f64_or(p, "gutter", nd.gutter); nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(false); match p.get("margins") { diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index df578e30..8a0fee5e 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -51,7 +51,7 @@ pub fn specs() -> Vec { "Create Guides…", ["Layout"], None, - "{rows?: 0, columns?: 0, rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)}", + "{rows?: 0, columns?: 0 (at most 100 each), rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)}", has_doc, create_guides ), @@ -157,9 +157,13 @@ pub(crate) fn grid_positions(a: f64, b: f64, n: u32, gutter: f64) -> Vec { v } +/// The most rows or columns Create Guides lays out. The count comes from the caller, and every +/// guide is stored in the document. +const MAX_GUIDE_GRID: u32 = 100; + fn create_guides(s: &mut Session, p: &Value) -> Result { - let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(0) as u32; - let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(0) as u32; + let count = |key: &str| p.get(key).and_then(Value::as_u64).unwrap_or(0).min(u64::from(MAX_GUIDE_GRID)) as u32; + let (rows, cols) = (count("rows"), count("columns")); let rg = p.get("rowGutter").and_then(Value::as_f64).unwrap_or(12.0); let cg = p.get("columnGutter").and_then(Value::as_f64).unwrap_or(12.0); let to_page = str_param(p, "fitTo") == Some("page"); @@ -238,6 +242,18 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].guides.len(), 5); } + + /// A huge row or column count once made that many guides (2³² − 1 of them exhaust memory, which + /// no guard catches), and a count past 2³² wrapped to a small one. + #[test] + fn create_guides_caps_the_row_and_column_counts() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let args = json!({"spread": 0, "rows": 4_294_967_298_u64, "columns": 3_000_000, "rowGutter": 0.0, "columnGutter": 0.0}); + let r = s.execute("layout.createGuides", &args).unwrap(); + let per_page = 2 * (u64::from(MAX_GUIDE_GRID) - 1); + assert_eq!(r["guides"], per_page * s.doc().unwrap().doc.spreads[0].pages.len() as u64); + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index dc456a13..dba63ca7 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1919,7 +1919,7 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result { let cols = p.get("columns").and_then(Value::as_u64).map(|v| v as usize); let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize; let offsets: Vec = match (rows, cols) { - (Some(r), Some(c)) if r * c <= 1000 => (0..r) + (Some(r), Some(c)) if r.checked_mul(c).is_some_and(|n| n <= 1000) => (0..r) .flat_map(|i| (0..c).map(move |j| (i, j))) .filter(|&(i, j)| i + j > 0) .map(|(i, j)| Vec2::new(j as f64 * dx, i as f64 * dy)) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8f2f39b0..1f5f7189 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -372,6 +372,28 @@ fn step_and_repeat_grid() { assert_eq!(last.y0, 36.0 + 2.0 * 40.0); } +/// rows × columns once overflowed: a panic in debug builds, and in release builds a product that +/// wrapped to 0 passed the 1000-copy limit and started a grid of 2⁶⁴ copies. +#[test] +fn step_and_repeat_with_a_huge_grid_makes_no_grid() { + let mut s = session(); + s.execute("frame.create", &json!({"rect": [36, 36, 66, 66], "content": "unassigned"})).unwrap(); + let r = s.execute("edit.stepAndRepeat", &json!({"rows": 4_294_967_296_u64, "columns": 4_294_967_296_u64})).unwrap(); + // As for any grid of more than 1000 copies: `count` (default 1) copies instead. + assert_eq!(r["created"], 1); +} + +/// The column count is the caller's and sizes a list every time the page's columns are laid out. +#[test] +fn new_document_caps_the_column_count() { + let mut s = Session::new(); + s.execute("file.new", &json!({"columns": 4_000_000_000_u64})).unwrap(); + assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].columns.count, 216); + // Past u32 the count wrapped (2³² + 3 became 3). + s.execute("file.new", &json!({"columns": 4_294_967_299_u64})).unwrap(); + assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].columns.count, 216); +} + #[test] fn snippets_roundtrip_between_documents() { let mut s = Session::new(); From d323b4e7020dc456a91c1398958ef789d32cad25 Mon Sep 17 00:00:00 2001 From: jahala Date: Sat, 10 Oct 2026 06:13:59 +0100 Subject: [PATCH 15/71] Web: AltGr and other named keys no longer type their names (#134) (#138) In the browser, pressing AltGr while editing text on the canvas inserted the word "AltGraph" into the story. eframe's web backend (0.36.2, web/input.rs text_from_keyboard_event) turns a keydown into a text event unless the key is on a fixed list of names. Browsers report many more named keys than that list holds: AltGraph, Dead, Unidentified, the media keys. Each one arrived as text. Panel text fields are not affected, because a focused egui text field takes its text from the browser's input events instead of keydown. The web shell now drops a text event that is a key name (ASCII letters and digits, starting with a capital, longer than one character) unless an egui text field has the keyboard. One key press never types such a word; a text field's input events can, so they are left alone. Co-authored-by: Claude Opus 5.5 (1M context) --- apps/designcraft-web/src/web.rs | 3 ++- crates/ui-egui/src/lib.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index 3879f083..f5659863 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -104,7 +104,8 @@ impl eframe::App for WebShell { self.app.logic(ctx); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused()); self.app.raw_input_hook(raw); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 275018cb..4ec0b8b0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -891,10 +891,42 @@ pub fn now_ms() -> f64 { } } +/// Is `text` the name of a key ("AltGraph", "Dead", "AudioVolumeUp") instead of typed text? +/// Browsers report a key press as a string: the character it types, or a name made of ASCII +/// letters and digits that starts with a capital. One key press never types such a word. +pub fn is_key_name(text: &str) -> bool { + text.len() > 1 && text.starts_with(|c: char| c.is_ascii_uppercase()) && text.chars().all(|c| c.is_ascii_alphanumeric()) +} + +/// Drop text events that are key names. eframe's web backend sends the name of every key it +/// doesn't know as text, so AltGr typed "AltGraph" into the story. `text_field_focused`: an +/// egui text field has the keyboard; its text comes from the browser's input events, which can +/// hold whole words, and is left alone. +pub fn drop_key_name_text(raw: &mut egui::RawInput, text_field_focused: bool) { + if !text_field_focused { + raw.events.retain(|e| !matches!(e, egui::Event::Text(t) if is_key_name(t))); + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn key_names_sent_as_text_are_dropped() { + let text = |t: &str| egui::Event::Text(t.to_string()); + let events = || { + vec![text("AltGraph"), text("é"), text("Dead"), text("A"), text("Unidentified"), text("ß"), text("AudioVolumeUp"), text("ab"), text("Æ")] + }; + let mut raw = egui::RawInput { events: events(), ..Default::default() }; + drop_key_name_text(&mut raw, false); + assert_eq!(raw.events, vec![text("é"), text("A"), text("ß"), text("ab"), text("Æ")]); + // A focused text field gets its text from input events, which can be whole words. + let mut raw = egui::RawInput { events: events(), ..Default::default() }; + drop_key_name_text(&mut raw, true); + assert_eq!(raw.events, events()); + } + #[test] fn snap_view_uses_the_saved_switches() { let mut ui = UiState::default(); From c614e0ab9e404bddf423aba09d7aae4f163a22db Mon Sep 17 00:00:00 2001 From: jahala Date: Sat, 10 Oct 2026 06:14:05 +0100 Subject: [PATCH 16/71] Docs, About: the Illustrator-class sibling is VectorCraft (#59) (#139) DrawCraft was renamed VectorCraft, and its repository is now storytold/vectorcraft. Three places still used the old name: - AGENTS.md and CLAUDE.md told contributors and agents that the sibling checkout lives at ../drawcraft. A clone made from the README link lands in ../vectorcraft, so that path does not exist. - The About window's sibling list used the app-page slug "drawcraft". getartcraft.com/apps/drawcraft still redirects to /apps/vectorcraft, so the link worked, but the README (which the list says it matches) uses /apps/vectorcraft. Co-authored-by: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- CLAUDE.md | 2 +- crates/ui-egui/src/about.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3a2bcb59..bedbfb6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # DesignCraft — instructions for agents -DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom). +DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom). ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app). diff --git a/CLAUDE.md b/CLAUDE.md index 7d067399..dfe78adb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # DesignCraft — instructions for agents -DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../drawcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom). +DesignCraft is a clean-room, open-source, Rust-native page-layout application targeting Adobe InDesign parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings with the same conventions: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom). ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/indesign/*.md` (`11-observed-ui.md` holds measured observations of the running app). diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs index a3875609..4eae2e7c 100644 --- a/crates/ui-egui/src/about.rs +++ b/crates/ui-egui/src/about.rs @@ -20,7 +20,7 @@ const MARK_PATH: &str = "M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,7 /// The other ArtCraft apps (app-page slug, name, what it is) — same list as the README. pub const SIBLINGS: &[(&str, &str, &str)] = &[ ("photocraft", "PhotoCraft", "image editing"), - ("drawcraft", "VectorCraft", "vector illustration"), + ("vectorcraft", "VectorCraft", "vector illustration"), ("filmcraft", "FilmCraft", "video editing, color and sound"), ("lightcraft", "LightCraft", "photo library and raw development"), ("pdfcraft", "PdfCraft", "reading, organizing and protecting PDFs"), From 9b8aaadae0967bcad8b28f13e20445c7d99387c2 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:14:10 -0400 Subject: [PATCH 17/71] UI: ruler units by right-click, Language in Properties (#145) * UI: right-clicking a ruler picks that ruler's units, and the vertical ruler measures in the vertical units (#133) * UI: the Properties panel's Character section sets the text's language, from the same list as the Character panel (#130) --- ROADMAP.md | 4 +- crates/ui-egui/src/canvas.rs | 116 +++++++++++++++++++++++- crates/ui-egui/src/panels/properties.rs | 70 ++++++++++++-- 3 files changed, 177 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index aa4c1739..58824013 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -88,7 +88,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. -- Language (Character panel ▸ Language, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Hyphenation dictionaries for other languages aren't bundled yet. +- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Hyphenation dictionaries for other languages aren't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. @@ -161,7 +161,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. -- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. +- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. - Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu. - Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `` in IDML. - Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8288dd35..b4b17d3a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -318,6 +318,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { draw_tool_overlays(app, &painter, &xf); if rulers { draw_rulers(app, ui, full, rect, &xf, &doc, &layout, &t); + ruler_units_menus(app, &resp, full, rect, &doc); } if let Some(r) = sel_rect && !preview @@ -1247,8 +1248,7 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { #[allow(clippy::too_many_arguments)] fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let painter = ui.painter_at(full); - let top = Rect::from_min_max(pos2(rect.min.x, full.min.y), pos2(full.max.x, rect.min.y)); - let left = Rect::from_min_max(pos2(full.min.x, rect.min.y), pos2(rect.min.x, full.max.y)); + let (top, left) = ruler_rects(full, rect); painter.rect_filled(top, 0.0, t.ruler); painter.rect_filled(left, 0.0, t.ruler); painter.rect_filled(Rect::from_min_max(full.min, rect.min), 0.0, t.ruler); @@ -1262,11 +1262,11 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, let Some(i) = current_slot(app, layout) else { return }; let slot = &layout.slots[i]; let origin = Point::new(slot.bounds.x0, slot.bounds.y0); - let unit = doc.settings.horizontal_units; - let (major, sub) = unit.ruler_ticks(xf.zoom); let font = egui::FontId::proportional(9.0); let tick = Stroke::new(1.0, t.ruler_tick); // Horizontal. + let unit = doc.settings.horizontal_units; + let (major, sub) = unit.ruler_ticks(xf.zoom); let c0 = xf.to_canvas(top.min).x - origin.x; let c1 = xf.to_canvas(pos2(top.max.x, 0.0)).x - origin.x; let mut k = (c0 / major).floor() as i64; @@ -1291,6 +1291,8 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, k += 1; } // Vertical. + let unit = doc.settings.vertical_units; + let (major, sub) = unit.ruler_ticks(xf.zoom); let c0 = xf.to_canvas(pos2(0.0, left.min.y)).y - origin.y; let c1 = xf.to_canvas(pos2(0.0, left.max.y)).y - origin.y; let mut k = (c0 / major).floor() as i64; @@ -1325,6 +1327,43 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, } } +/// The horizontal (top) and vertical (left) rulers around the view `rect` in `full`. +fn ruler_rects(full: Rect, rect: Rect) -> (Rect, Rect) { + ( + Rect::from_min_max(pos2(rect.min.x, full.min.y), pos2(full.max.x, rect.min.y)), + Rect::from_min_max(pos2(full.min.x, rect.min.y), pos2(rect.min.x, full.max.y)), + ) +} + +/// Right-clicking a ruler opens a menu of units for that ruler. +fn ruler_units_menus(app: &mut DesignApp, resp: &egui::Response, full: Rect, rect: Rect, doc: &Document) { + let (top, left) = ruler_rects(full, rect); + let at = resp.interact_pointer_pos(); + for (ruler, key, cur) in [(top, "horizontalUnits", doc.settings.horizontal_units), (left, "verticalUnits", doc.settings.vertical_units)] { + let open = if resp.secondary_clicked() { + Some(egui::SetOpenCommand::Bool(at.is_some_and(|p| ruler.contains(p)))) + } else if resp.clicked() { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + egui::Popup::context_menu(resp).id(resp.id.with(("ruler_units", key))).open_memory(open).show(|ui| ruler_units_menu(app, ui, key, cur)); + } +} + +/// Every unit, the current one checked; picking one sets `key` (`horizontalUnits` or +/// `verticalUnits`) through `document.preferences`. +fn ruler_units_menu(app: &mut DesignApp, ui: &mut egui::Ui, key: &str, cur: designcraft_geom::Unit) { + for u in designcraft_geom::Unit::ALL { + let label = crate::i18n::tr(&app.ui.language, u.label()); + let text = if u == cur { format!("✓ {label}") } else { format!(" {label}") }; + if ui.button(crate::rtl::widget(ui, text)).clicked() { + let _ = app.run("document.preferences", json!({ key: u })); + ui.close(); + } + } +} + fn fmt_tick(v: f64) -> String { let r = v.round(); if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") } @@ -1726,3 +1765,72 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec } true } + +#[cfg(test)] +mod tests { + use designcraft_geom::Unit; + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h + } + + fn right_click(h: &mut Harness<'_, DesignApp>, p: Pos2) { + h.hover_at(p); + h.step(); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + } + h.run_steps(3); + } + + fn units(h: &Harness<'_, DesignApp>) -> (Unit, Unit) { + let s = &h.state().session.doc().unwrap().doc.settings; + (s.horizontal_units, s.vertical_units) + } + + #[test] + fn right_clicking_a_ruler_sets_that_rulers_units() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut h = harness(app); + let rect = h.state().canvas_rect.unwrap(); + assert_eq!(units(&h), (Unit::Picas, Unit::Picas)); + // Vertical ruler: the current unit is checked; picking one sets only the vertical units. + right_click(&mut h, pos2(rect.min.x - RULER / 2.0, rect.center().y)); + h.get_by_label("✓ Picas"); + h.get_by_label(" Millimeters").click(); + h.run_steps(3); + assert_eq!(units(&h), (Unit::Picas, Unit::Millimeters)); + // Horizontal ruler. + right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0)); + h.get_by_label("✓ Picas"); + h.get_by_label(" Inches").click(); + h.run_steps(3); + assert_eq!(units(&h), (Unit::Inches, Unit::Millimeters)); + // The page and the ruler corner open no units menu. + for p in [rect.center(), rect.min - vec2(RULER / 2.0, RULER / 2.0)] { + right_click(&mut h, p); + assert!(h.query_by_label(" Points").is_none()); + } + } + + #[test] + fn rulers_open_no_units_menu_without_a_document() { + let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); + let Some(rect) = h.state().canvas_rect else { return }; + right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0)); + assert!(h.query_by_label(" Points").is_none()); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 8644fe79..0d1bc69a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1070,6 +1070,18 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { }) { let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); } + // Language: hyphenation, spelling and typographer's quotes follow it. + ui.add_space(1.0); + let lang = c["language"].as_str().unwrap_or("English: USA").to_string(); + let label = crate::i18n::tr(&app.ui.language, "Language"); + let resp = widgets::dropdown(ui, crate::i18n::tr(&app.ui.language, &lang), fw).on_hover_ui(|ui| { + crate::rtl::label(ui, label); + }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label)); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(fw); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| language_menu(app, ui, &lang)); + }); if more_options(ui, &app.ui.language).clicked() { app.ui.open_panel = Some("character".into()); } @@ -1212,6 +1224,17 @@ const LANGUAGES: &[&str] = &[ "Hebrew", ]; +/// The Language list (Character panel, Properties ▸ Character): picking one sets the language of +/// the selected text. +fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { + for l in LANGUAGES { + if ui.selectable_label(*l == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() { + let _ = app.run("type.char", json!({"attrs": {"language": l}})); + ui.close(); + } + } +} + pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let Some(a) = text_attrs(app) else { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select text or a text frame.")); @@ -1231,13 +1254,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { egui::ComboBox::from_id_salt("char_language") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &lang))) .width(170.0) - .show_ui(ui, |ui| { - for l in LANGUAGES { - if ui.selectable_label(*l == lang, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() { - let _ = app.run("type.char", json!({"attrs": {"language": l}})); - } - } - }); + .show_ui(ui, |ui| language_menu(app, ui, &lang)); }); // Digits (World-Ready): shown for right-to-left paragraphs or once set. let digits = c["digits"].as_str().unwrap_or("default").to_string(); @@ -2120,3 +2137,42 @@ fn variable_font_axes(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, styl let _ = app.run("type.char", json!({"attrs": {"fontStyle": format!("{base} {{{spec}}}")}})); } } + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + fn language(app: &mut DesignApp) -> Value { + text_attrs(app).unwrap()["chars"]["language"].clone() + } + + #[test] + fn properties_character_section_sets_the_language() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "bunun", "caret": true})).unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + assert_ne!(language(&mut app), "French"); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + // The panel's fonts are installed before its first frame. + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Language").click(); + h.run_steps(2); + h.get_by_label("French").click(); + h.run_steps(2); + assert_eq!(language(h.state_mut()), "French"); + } +} From 4349919a4a3a6dcb88825cd2c1616138d38de8f5 Mon Sep 17 00:00:00 2001 From: jahala Date: Sat, 10 Oct 2026 06:14:17 +0100 Subject: [PATCH 18/71] Import: RTF and Tagged Text keep characters past U+FFFF (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placing an RTF or Tagged Text file dropped every character outside the Basic Multilingual Plane: emoji, mathematical letters and the rarer CJK ideographs such as 𠮷 (U+20BB7, used in Japanese names). The text around them came in, so the loss was silent. It also broke our own round trip: Export Text writes these characters correctly and Place lost them. Both formats carry such a character as a UTF-16 surrogate pair, two units in a row: \u-10179?\u-8704? in RTF, <0xD83D><0xDE00> in Tagged Text. Both readers passed each unit to char::from_u32 on its own. Half a pair is not a character, so each half was dropped. A small shared Utf16Units joins the units: a first half waits for the second, and the pair becomes one character. Half a pair with no partner is still left out, as before. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/textimport/src/lib.rs | 27 +++++++++++++++++++++++++++ crates/textimport/src/rtf.rs | 22 ++++++++++++++++++++-- crates/textimport/src/tagged.rs | 19 ++++++++++++++++++- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs index 8dc50ea7..22f79c51 100644 --- a/crates/textimport/src/lib.rs +++ b/crates/textimport/src/lib.rs @@ -28,6 +28,33 @@ pub fn xlsx_records(bytes: &[u8], sheet: Option<&str>) -> Result use designcraft_doc::{CharAttrs, ParaAttrs, Story}; +/// Joins UTF-16 code units that arrive one at a time (RTF `\uN`, Tagged Text `<0xNNNN>`) into +/// characters. A character past U+FFFF comes as two units, a surrogate pair: the first half +/// waits for the second. +#[derive(Default)] +pub(crate) struct Utf16Units { + high: Option, +} + +impl Utf16Units { + /// The character that `unit` is or completes. Half a pair without its partner is no character. + pub(crate) fn push(&mut self, unit: u32) -> Option { + match (self.high.take(), unit) { + (Some(high), 0xDC00..=0xDFFF) => char::from_u32(0x10000 + ((high - 0xD800) << 10) + (unit - 0xDC00)), + (_, 0xD800..=0xDBFF) => { + self.high = Some(unit); + None + } + _ => char::from_u32(unit), + } + } + + /// Other text came in between: a waiting first half has no partner. + pub(crate) fn reset(&mut self) { + self.high = None; + } +} + /// A paragraph or character style found in the source. #[derive(Clone, Debug, Default, PartialEq)] pub struct ImportedStyle { diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index c02e5e55..9189b52d 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -70,6 +70,7 @@ pub fn import(bytes: &[u8]) -> Result { let mut st = Story::new(StoryId(0)); let mut stack: Vec = vec![State { uc: 1, ..Default::default() }]; let mut pending = 0usize; // characters still to skip after \uN + let mut units = crate::Utf16Units::default(); let mut buf = String::new(); let mut fmt = stack[0].format(); let mut warnings = Vec::new(); @@ -124,6 +125,7 @@ pub fn import(bytes: &[u8]) -> Result { if let Ok(b) = u8::from_str_radix(&hex, 16) && !stack.last().is_some_and(|s| s.skip) { + units.reset(); buf.push(cp1252(b)); } continue; @@ -225,8 +227,8 @@ pub fn import(bytes: &[u8]) -> Result { } "u" => { if let Some(n) = param { - let cp = if n < 0 { (n + 65536) as u32 } else { n as u32 }; - if let Some(ch) = char::from_u32(cp) { + let unit = if n < 0 { (n + 65536) as u32 } else { n as u32 }; + if let Some(ch) = units.push(unit) { buf.push(ch); } pending = stack.last().map_or(1, |s| s.uc); @@ -243,6 +245,7 @@ pub fn import(bytes: &[u8]) -> Result { continue; } if !stack.last().is_some_and(|s| s.skip) { + units.reset(); buf.push(c); } } @@ -287,4 +290,19 @@ Caf\'e9 \u8364? price\tab tab\line next\par assert_eq!(st.format_after(0).over.size, Some(12.0)); assert!(!st.text.contains("Times"), "font table skipped"); } + + /// Characters past U+FFFF are written as two `\uN` words, a UTF-16 surrogate pair (the form + /// our own exporter and Word write). Each half was converted alone and dropped. + #[test] + fn surrogate_pairs_become_one_character() { + let i = import(br"{\rtf1\ansi\uc1 A\u-10179?\u-8704?B \u-10174?\u-8265?\u30000?}").unwrap(); + assert_eq!(i.story.text, "A\u{1F600}B \u{20BB7}\u{7530}"); + // What the exporter writes comes back. + let mut st = Story::new(StoryId(1)); + st.insert(0, "x \u{1F600} \u{20BB7}\u{7530} y"); + let doc = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); + assert_eq!(import(crate::export::rtf(&doc, &st).as_bytes()).unwrap().story.text, st.text); + // Half a pair is not a character: it is still left out. + assert_eq!(import(br"{\rtf1 A\u-10179?B\u-8704?C}").unwrap().story.text, "ABC"); + } } diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index ca78bc4b..cfd08eea 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -110,12 +110,14 @@ pub fn import(bytes: &[u8]) -> Result { _ => runs.push((c.to_string(), fmt.clone())), } }; + let mut units = crate::Utf16Units::default(); let mut i = 0; let mut skipped = 0; while i < chars.len() { let c = chars[i]; match c { '\\' if i + 1 < chars.len() => { + units.reset(); push(&mut paras, chars[i + 1], &fmt); i += 2; } @@ -143,7 +145,7 @@ pub fn import(bytes: &[u8]) -> Result { match name { n if n.starts_with("0x") => { if let Ok(u) = u32::from_str_radix(&n[2..], 16) - && let Some(ch) = char::from_u32(u) + && let Some(ch) = units.push(u) { push(&mut paras, ch, &fmt); } @@ -193,6 +195,7 @@ pub fn import(bytes: &[u8]) -> Result { i += 1; } c => { + units.reset(); push(&mut paras, c, &fmt); i += 1; } @@ -226,6 +229,20 @@ pub fn import(bytes: &[u8]) -> Result { mod tests { use super::*; + /// Characters past U+FFFF are written as two tags, a UTF-16 surrogate pair. Each half was + /// converted alone and dropped. + #[test] + fn surrogate_pairs_become_one_character() { + let i = import("\r\n\r\nA<0xD83D><0xDE00>B <0xD842><0xDFB7><0x7530>".as_bytes()).unwrap(); + assert_eq!(i.story.text, "A\u{1F600}B \u{20BB7}\u{7530}"); + // What the exporter writes comes back. + let doc = Document::new(&designcraft_doc::build::NewDocument::default()); + let back = import(tagged_text(&doc, &i.story).as_bytes()).unwrap(); + assert_eq!(back.story.text, i.story.text); + // Half a pair is not a character: it is still left out. + assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); + } + #[test] fn tagged_text_round_trips() { let src = "\r\n\r\nCaf<0x00E9> \\<1\\>\r\nA word and big text"; From 06322045a57823523dccced119dba3185a911487 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 9 Oct 2026 23:14:22 -0600 Subject: [PATCH 19/71] Security: escape HTML and EPUB metadata (#152) --- crates/epub/src/lib.rs | 233 +++++++++++++++++++++++++++++++++-------- 1 file changed, 191 insertions(+), 42 deletions(-) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 3a61b509..643f5934 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -39,7 +39,7 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions let io = |e: std::io::Error| EpubError(e.to_string()); let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let lang = &opts.language; + let lang = language_tag(&opts.language); let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); let mut manifest = String::from("\n"); let mut spine = String::new(); @@ -50,8 +50,8 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions let (w, h) = (p.width.round().max(1.0) as u32, p.height.round().max(1.0) as u32); let xhtml = format!( "\n\n\n{} — {n}\n\n\"\"/
{}
\n\n", - esc(&title), - esc(&p.text).replace('\n', "
") + xml_text(&title), + story_markup(&p.text).replace('\n', "
") ); let _ = writeln!( manifest, @@ -66,13 +66,13 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions let (vw, vh) = pages.first().map_or((612, 792), |p| (p.width.round() as u32, p.height.round() as u32)); let nav = format!( "\n\n\n{}\n\n\n", - esc(&title) + xml_text(&title) ); - let author = opts.author.as_deref().map(|a| format!("{}", esc(a))).unwrap_or_default(); + let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); let opf = format!( "\n\n\n{}\n{}\n{lang}{author}\n2026-01-01T00:00:00Z\npre-paginated\nauto\n\n\n\n\n{manifest}\n\n{spine}\n\n", - esc(&id), - esc(&title) + xml_text(&id), + xml_text(&title) ); let container = "\n\n"; let mut buf = std::io::Cursor::new(Vec::new()); @@ -114,7 +114,11 @@ impl std::fmt::Display for EpubError { } impl std::error::Error for EpubError {} -fn esc(s: &str) -> String { +fn valid_xml_char(c: char) -> bool { + matches!(c, '\u{9}' | '\u{A}' | '\u{D}' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}') +} + +fn escape_markup(s: &str, attr: bool, story_breaks: bool) -> String { let mut o = String::with_capacity(s.len()); for c in s.chars() { match c { @@ -122,15 +126,81 @@ fn esc(s: &str) -> String { '<' => o.push_str("<"), '>' => o.push_str(">"), '"' => o.push_str("""), + '\'' if attr => o.push_str("'"), '\u{E000}'..='\u{E1FF}' => {} - '\u{2028}' => o.push_str("
"), + '\u{2028}' if story_breaks => o.push_str("
"), '\u{AD}' => o.push_str("­"), - c => o.push(c), + c if valid_xml_char(c) => o.push(c), + _ => {} } } o } +fn xml_text(s: &str) -> String { + escape_markup(s, false, false) +} + +fn xml_attr(s: &str) -> String { + escape_markup(s, true, false) +} + +/// Story content permits the document's discretionary line-separator marker to become markup. +/// Other contexts always serialize it as ordinary text. +fn story_markup(s: &str) -> String { + escape_markup(s, false, true) +} + +/// A conservative BCP 47 subset suitable for both HTML and EPUB language attributes. +fn language_tag(s: &str) -> &str { + if s.len() > 63 { + return "und"; + } + let mut parts = s.split('-'); + let Some(first) = parts.next() else { return "und" }; + if !(2..=8).contains(&first.len()) || !first.bytes().all(|b| b.is_ascii_alphabetic()) { + return "und"; + } + if parts.any(|p| p.is_empty() || p.len() > 8 || !p.bytes().all(|b| b.is_ascii_alphanumeric())) { + return "und"; + } + s +} + +/// Return a MIME type that is safe to place in an attribute and a data URL. Parameters are omitted +/// because exported assets are always represented as their original binary bytes. +fn safe_mime(mime: &str) -> &str { + fn token(s: &str) -> bool { + !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'+' | b'-')) + } + + let mime = mime.split_once(';').map_or(mime, |(essence, _)| essence).trim(); + if mime.len() <= 127 + && let Some((kind, subtype)) = mime.split_once('/') + && !subtype.contains('/') + && token(kind) + && token(subtype) + { + mime + } else { + "image/png" + } +} + +/// Quote arbitrary document metadata as one CSS string token. Escaping `<` is important because +/// HTML parses the contents of a `style` element before CSS parsing sees it. +fn css_string(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if matches!(c, '"' | '\\' | '<' | '>' | '&') || c.is_control() { + let _ = write!(out, "\\{:x} ", c as u32); + } else { + out.push(c); + } + } + out +} + /// CSS class name for a style name. pub fn slug(name: &str) -> String { let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); @@ -177,7 +247,7 @@ fn hex(doc: &Document, swatch: &str, tint: f32) -> Option { } fn char_css(doc: &Document, c: &CharProps, base_size: f64, out: &mut String) { - let _ = write!(out, "font-family: \"{}\", serif; ", c.font_family); + let _ = write!(out, "font-family: \"{}\", serif; ", css_string(&c.font_family)); let st = c.font_style.to_lowercase(); if st.contains("bold") { out.push_str("font-weight: bold; "); @@ -330,7 +400,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| pc.get(pf.style.as_str()).cloned()).unwrap_or_else(|| slug(&pf.style)); let text_empty = st.text[r.clone()].trim().is_empty(); let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft; - let _ = write!(out, "<{tag} class=\"{class}\"{}>", if rtl { " dir=\"rtl\"" } else { "" }); + let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); if text_empty { out.push_str(" "); } @@ -353,9 +423,9 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { None => c.to_string(), }) .collect(); - esc(&sub) + story_markup(&sub) } else { - esc(raw) + story_markup(raw) }; let et = (f.style != story::NO_CHAR_STYLE).then(|| doc.styles.export_tag(&f.style, true)).flatten(); let ctag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, true)).unwrap_or("span"); @@ -365,12 +435,12 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { .filter(|c| !c.is_empty()) .or_else(|| cc.get(f.style.as_str()).cloned()) .unwrap_or_else(|| slug(&f.style)); - format!(" class=\"{class}\"") + format!(" class=\"{}\"", xml_attr(&class)) } else { String::new() }; let style = override_css(doc, &f.over); - let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{style}\"") }; + let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) }; if cls.is_empty() && style_attr.is_empty() { out.push_str(&t); } else { @@ -495,10 +565,11 @@ fn body_html<'a>( if a.data.is_empty() { continue; } + let mime = safe_mime(&a.mime); // Video and sound: an HTML5 player (with the poster, when there is one). - if let Some(kind) = designcraft_doc::media_kind(&a.mime) { - let name = format!("media/{}.{}", g.asset.0, ext(&a.mime)); - images.insert(name.clone(), (a.mime.as_str(), a.data.as_slice())); + if let Some(kind) = designcraft_doc::media_kind(mime) { + let name = format!("media/{}.{}", g.asset.0, ext(mime)); + images.insert(name.clone(), (mime, a.data.as_slice())); let m = doc.item(*iid).and_then(|i| i.media.clone()).unwrap_or_default(); let mut attrs = String::new(); for (on, attr) in @@ -509,17 +580,23 @@ fn body_html<'a>( } } if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) { - let pn = format!("images/{}.{}", pa.id.0, ext(&pa.mime)); - images.insert(pn.clone(), (pa.mime.as_str(), pa.data.as_slice())); + let poster_mime = safe_mime(&pa.mime); + let pn = format!("images/{}.{}", pa.id.0, ext(poster_mime)); + images.insert(pn.clone(), (poster_mime, pa.data.as_slice())); if kind == "video" { - attrs.push_str(&format!(" poster=\"{}\"", img_src(&pn, &pa.mime, &pa.data))); + attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data)))); } } - let _ = writeln!(body, "
<{kind} src=\"{}\"{attrs}>{}
", img_src(&name, &a.mime, &a.data), esc(&a.name)); + let _ = writeln!( + body, + "
<{kind} src=\"{}\"{attrs}>{}
", + xml_attr(&img_src(&name, mime, &a.data)), + xml_text(&a.name) + ); continue; } - let name = format!("images/{}.{}", g.asset.0, ext(&a.mime)); - images.insert(name.clone(), (a.mime.as_str(), a.data.as_slice())); + let name = format!("images/{}.{}", g.asset.0, ext(mime)); + images.insert(name.clone(), (mime, a.data.as_slice())); // Alt text from Object Export Options, else the file name. let alt = doc.item(*iid).map(|i| i.alt_text.as_str()).filter(|t| !t.is_empty()).unwrap_or(&a.name); // Object Export Options: alignment and a page break before. @@ -531,8 +608,9 @@ fn body_html<'a>( if eo.page_break_before { css.push_str("page-break-before:always;break-before:page;"); } - let style = if css.is_empty() { String::new() } else { format!(" style=\"{css}\"") }; - let _ = writeln!(body, "\"{}\"/", img_src(&name, &a.mime, &a.data), esc(alt)); + let style = if css.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&css)) }; + let _ = + writeln!(body, "\"{}\"/", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(alt)); } } } @@ -550,11 +628,12 @@ pub struct HtmlOptions { /// document's stories and graphics in reading order. pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let lang = opts.language.clone().unwrap_or_else(|| "en".into()); + let language = opts.language.as_deref().unwrap_or("en"); + let lang = language_tag(language); let (body, _, _) = body_html(doc, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))); format!( "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", - esc(&title), + xml_text(&title), stylesheet(doc) ) } @@ -578,38 +657,38 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string()); - let lang = &opts.language; + let lang = language_tag(&opts.language); let chapter = format!( "\n\n\n{}\n\n{body}\n\n", - esc(&title) + xml_text(&title) ); let mut nav_items = String::new(); for (a, label) in &toc { - let _ = write!(nav_items, "
  • {}
  • ", esc(label)); + let _ = write!(nav_items, "
  • {}
  • ", xml_text(label)); } if nav_items.is_empty() { - nav_items = format!("
  • {}
  • ", esc(&title)); + nav_items = format!("
  • {}
  • ", xml_text(&title)); } let nav = format!( "\n\n\n{}\n\n\n", - esc(&title) + xml_text(&title) ); let mut manifest = String::from( "\n\n\n", ); for (k, (name, (mime, _))) in images.iter().enumerate() { - let _ = writeln!(manifest, ""); + let _ = writeln!(manifest, "", xml_attr(name), xml_attr(mime)); } // Cover: an image page first in reading order. if opts.cover.is_some() { manifest.push_str("\n\n"); } let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; - let author = opts.author.as_deref().map(|a| format!("{}", esc(a))).unwrap_or_default(); + let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); let opf = format!( "\n\n\n{}\n{}\n{lang}{author}\n2026-01-01T00:00:00Z\n\n\n\n{manifest}\n\n{cover_spine}\n\n\n", - esc(&id), - esc(&title) + xml_text(&id), + xml_text(&title) ); let container = "\n\n"; let mut buf = std::io::Cursor::new(Vec::new()); @@ -638,8 +717,8 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr z.write_all(c).map_err(io)?; let xhtml = format!( "\n\n\n{}\n\"{}\"/\n\n", - esc(&title), - esc(&title) + xml_text(&title), + xml_attr(&title) ); z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?; z.write_all(xhtml.as_bytes()).map_err(io)?; @@ -653,9 +732,10 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr mod tests { use super::*; use designcraft_doc::build::NewDocument; - use designcraft_doc::geom::Rect; - use designcraft_doc::{ParaFormat, SpreadRef}; + use designcraft_doc::geom::{Affine, Rect, shapes}; + use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef}; use std::io::Read; + use std::sync::Arc; #[test] fn exports_valid_package_in_reading_order() { @@ -734,4 +814,73 @@ mod tests { assert_eq!(base64(b"Ma"), "TWE="); assert_eq!(base64(b"M"), "TQ=="); } + + #[test] + fn metadata_is_serialized_for_its_output_context() { + let mut d = Document::new(&NewDocument::default()); + d.title = "Field Notes & ".into(); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B \"D\"", ParaFormat::default()).unwrap(); + d.styles_mut() + .export_tags + .insert(format!("p:{}", story::BASIC_PARAGRAPH), designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into() }); + if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { + basic.chars.font_family = Some("Quoted \"Serif\" ".into()); + } + + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { + page: 0, + id: aid, + name: "Cover & Notes".into(), + mime: "image/png; profile=screen".into(), + link: None, + data: Arc::new(vec![1]), + pixels: Some((1, 1)), + }), + ); + let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0))); + item.alt_text = "Cover \"A\" & B".into(); + item.content = Content::Graphic(Graphic { + asset: aid, + size: (1.0, 1.0), + xf: Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + + let story = story_html(&d, sid); + assert!(story.contains("

    A&B <C> "D"

    ")); + let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()) }); + assert!(html.contains("")); + assert!(html.contains("Field Notes & <Edition>")); + assert!(html.contains("alt=\"Cover "A" & B\"")); + assert!(html.contains("src=\"data:image/png;base64,AQ==\"")); + assert!(!html.contains("image/png; profile=screen")); + assert!(!stylesheet(&d).contains("")); + assert!(stylesheet(&d).contains("\\3c Alt\\3e ")); + + let bytes = export_epub( + &d, + &EpubOptions { + title: None, + author: Some("A & B".into()), + language: "English (US)".into(), + identifier: Some("notes&edition".into()), + cover: None, + }, + ) + .unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + assert!(opf.contains("xml:lang=\"und\"")); + assert!(opf.contains("notes&edition")); + assert!(opf.contains("A & B")); + assert!(opf.contains("media-type=\"image/png\"")); + } } From 4aea57fea7f1c8e0aec6bcef55210d7443a49022 Mon Sep 17 00:00:00 2001 From: thaynej <36243577+thaynej@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:14:27 -0600 Subject: [PATCH 20/71] Word import: keep tracked moved text (w:moveTo) (#257) Text inside a tracked move destination was dropped on .docx import, so moved passages vanished from both places. Treat moveTo like ins (both kept as accepted); moveFrom stays skipped like del. Regression test covers ins, del, moveFrom and moveTo together. Fixes #256. Also adds my contributor credit entry. Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 3 +++ crates/textimport/src/docx.rs | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db20..c480bd68 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,6 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false + +[people.thaynej] +real_name = "Jeffrey Thayne" diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 563eb728..670f8a0f 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -276,7 +276,9 @@ impl Ctx { for e in p.els() { match e.name.as_str() { "r" => self.run(e, st, allow_notes), - "hyperlink" | "smartTag" | "ins" | "fldSimple" | "customXml" => self.inline(e, st, allow_notes), + // Tracked changes import as accepted: insertions and move destinations are kept, + // deletions (`del`) and move sources (`moveFrom`) are dropped. + "hyperlink" | "smartTag" | "ins" | "moveTo" | "fldSimple" | "customXml" => self.inline(e, st, allow_notes), _ => {} } } @@ -469,4 +471,23 @@ mod tests { assert_eq!(i.para_styles[0].para.space_before, Some(12.0)); assert_eq!(i.char_styles[0].chars.font_style.as_deref(), Some("Italic")); } + + #[test] + fn tracked_changes_import_as_accepted_including_moves() { + // Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import. + let document = format!( + r#" + Kept text. + MOVED HERE. + MOVED THEN DELETED + Plain + INSERTED + DELETED + MOVED HERE.End. + "# + ); + let i = import(&docx(&document, "", "")).unwrap(); + i.story.check().unwrap(); + assert_eq!(i.story.text, "Kept text. MOVED HERE. Plain INSERTED\nEnd."); + } } From ecadfcee3e266eb84333fd9310b9e87116acaff9 Mon Sep 17 00:00:00 2001 From: thaynej <36243577+thaynej@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:14:32 -0600 Subject: [PATCH 21/71] Autoflow: add only the pages breaks ask for (#266) A frame that a column, frame or page break ends early held only a few characters, and autoflow took that as the rate frames fill at: placing text that starts with a page break added a page per few characters (33 pages for 98 characters, 271 for a one-page Word section). The estimate now comes from full frames only; with none yet, one page is added and the story measured again. Regression test covers a leading break and a break between every paragraph. Fixes #262. Claude-Session: https://claude.ai/code/session_01GLcnoDFjqsh5x3MzVbfZeb Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/place_text.rs | 51 ++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 766811b5..97917ee3 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -105,27 +105,37 @@ fn strip(story: &mut Story) { story.format_chars(0..n, |f| *f = Default::default()); } -/// Is the story overset in its frames, and how much of it do its frames show? -fn fit(d: &Document, sid: StoryId) -> (Option, usize) { +/// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)? +/// A frame a column, frame or page break ends early is left out: it says nothing about how much +/// a frame holds. +fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - let shown = cs.frames.iter().map(|f| f.range.end).max().unwrap_or(0); - (cs.overset_at, shown) + let text = d.story(sid).map_or("", |s| s.text.as_str()); + let is_break = |c: char| matches!(c, designcraft_doc::COLUMN_BREAK | designcraft_doc::FRAME_BREAK | designcraft_doc::PAGE_BREAK); + let (mut held, mut full) = (0, 0); + for f in &cs.frames { + let Some(shown) = text.get(f.range.clone()) else { continue }; + if !shown.is_empty() && !shown.chars().any(is_break) { + held += shown.len(); + full += 1; + } + } + (cs.overset_at, held, full) } /// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added. pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result { let mut added = 0; loop { - let (overset, shown) = fit(d, sid); + let (overset, held, full) = fit(d, sid); let Some(at) = overset else { return Ok(added) }; if added >= max_pages { return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages"))); } let len = d.story(sid).map_or(0, |s| s.len()); - let frames = d.story(sid).map_or(1, |s| s.frames.len()).max(1); - // Frames still needed at the rate the existing ones are filled (at least one). - let per_frame = (shown.max(at) / frames).max(1); - let want = (len - at).div_ceil(per_frame).clamp(1, max_pages - added); + // Frames still needed at the rate the full ones are filled (at least one); with no full + // frame to go by yet, one more page. + let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).clamp(1, max_pages - added); let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?; let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1)); let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); @@ -258,6 +268,29 @@ mod tests { assert_eq!(st.format_after(6).over.font_style.as_deref(), Some("Bold")); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn autoflow_adds_only_the_pages_breaks_ask_for() { + // Regression (#262): a frame a break ends early made autoflow take it for a frame that + // holds a few characters, and add a page per few characters left (33 for this text). + let dir = std::env::temp_dir().join(format!("dc-place-breaks-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (name, rtf, pages) in [ + ("lead.rtf", r"{\rtf1 \page\par A Short Heading\par First short paragraph.\par Second short paragraph.\par}", 2), + ("each.rtf", r"{\rtf1 One\par\page\par Two\par\page\par Three\par\page\par Four\par}", 4), + ] { + let path = dir.join(name); + std::fs::write(&path, rtf).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{name}: {r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), pages, "{name}: {r}"); + assert_eq!(d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.len(), pages, "{name}"); + } + let _ = std::fs::remove_dir_all(&dir); + } } #[cfg(test)] From a9326be31898aa350baa0396f2a2f35521885dae Mon Sep 17 00:00:00 2001 From: kubuntugamer <52578831+kubuntugamer@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:14:38 -0500 Subject: [PATCH 22/71] fix(engine): object.opacity accepts its documented lowercase blend modes (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object.opacity` documents `blend?: normal|multiply|…`, but parsed the value with `serde_json::from_value`, which only accepts the enum's PascalCase variant names. Every documented lowercase form was silently dropped: the command returned `{"changed": 1}` while the stored blend stayed as it was, so a client following the parameter description could save and export the previous appearance without any error (#260). Parse with the codebase's own `BlendMode::parse`, which is already case-, space-, underscore- and hyphen-insensitive and covered by tests, so `multiply`, `MULTIPLY`, `Color Burn`, `soft_light` and the serialized `Multiply` all resolve to the same mode. An unrecognized value is now an error rather than a silent no-op, matching how sibling commands treat a bad parameter — restoring `normal` after `Multiply` really does go back to Normal. Three tests in `blend_mode_tests`: the documented forms apply, the PascalCase names the Properties panel submits still apply, and an unknown value is rejected without changing the item. Fixes #260 🤖 Generated with Codebuff Co-authored-by: Codebuff --- crates/engine/src/cmd/object.rs | 76 ++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index dba63ca7..7e219f29 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::sync::Arc; +use designcraft_color::BlendMode; use designcraft_doc::{ Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, TextSel, }; @@ -219,7 +220,16 @@ pub fn specs() -> Vec { ), cmd!("object.opacity", "Opacity", [], None, "{opacity: 0..1, blend?: normal|multiply|…, ids?}", has_selection, |s, p| { let o = f64_or(p, "opacity", 1.0).clamp(0.0, 1.0) as f32; - let blend = p.get("blend").and_then(|b| serde_json::from_value(b.clone()).ok()); + // `BlendMode::parse` takes the documented lowercase forms (`multiply`), the panel + // labels (`Color Burn`) and the serialized names (`Multiply`). An unknown value is + // an error rather than a silent no-op that still reports `changed` (#260). + let blend = match p.get("blend") { + None | Some(Value::Null) => None, + Some(v) => { + let raw = v.as_str().ok_or_else(|| bad("object.opacity", "blend must be a string"))?; + Some(BlendMode::parse(raw).ok_or_else(|| bad("object.opacity", format!("unknown blend mode `{raw}`")))?) + } + }; set_flag( s, p, @@ -2422,3 +2432,67 @@ mod named_target_tests { assert_eq!(s.execute("conveyor.collect", &json!({"id": r["id"]})).unwrap()["count"], 1); } } + +#[cfg(test)] +mod blend_mode_tests { + use designcraft_color::BlendMode; + + use super::*; + + /// `object.opacity` documents `blend?: normal|multiply|…`, but deserializing the value + /// against the enum only accepted the PascalCase variant names, so the documented + /// lowercase forms were silently dropped: the command reported `changed: 1` while the + /// stored blend stayed as it was (#260). + #[test] + fn lowercase_documented_blend_modes_apply() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let blend = |s: &Session| s.doc().unwrap().doc.item(id).map(|i| i.blend); + + for (given, want) in [ + ("multiply", BlendMode::Multiply), + ("screen", BlendMode::Screen), + ("MULTIPLY", BlendMode::Multiply), + ("Color Burn", BlendMode::ColorBurn), + ("color-burn", BlendMode::ColorBurn), + ("soft_light", BlendMode::SoftLight), + ("normal", BlendMode::Normal), + ] { + let out = s.execute("object.opacity", &json!({"opacity": 1, "blend": given})).unwrap(); + assert_eq!(out["changed"], 1, "{given}"); + assert_eq!(blend(&s), Some(want), "blend mode {given:?} must be applied"); + } + } + + /// The PascalCase names keep working, so the Properties panel's own submissions are + /// unaffected. + #[test] + fn pascal_case_blend_modes_still_apply() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + s.execute("object.opacity", &json!({"opacity": 0.5, "blend": "Multiply"})).unwrap(); + let it = s.doc().unwrap().doc.item(id).cloned().unwrap(); + assert_eq!(it.blend, BlendMode::Multiply); + assert!((it.opacity - 0.5).abs() < 1e-6, "opacity must still be set"); + } + + /// A blend mode the command doesn't know is an error, not a silent success that leaves + /// the previous appearance in place. + #[test] + fn an_unknown_blend_mode_is_an_error() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + s.execute("object.opacity", &json!({"opacity": 1, "blend": "Multiply"})).unwrap(); + let e = s.execute("object.opacity", &json!({"opacity": 1, "blend": "sparkle"})).unwrap_err().to_string(); + assert!(e.contains("sparkle") && e.contains("blend"), "{e}"); + let it = s.doc().unwrap().doc.item(id).cloned().unwrap(); + assert_eq!(it.blend, BlendMode::Multiply, "a rejected value changes nothing"); + assert_eq!(it.opacity, 1.0); + } +} From 54a4a8d4aed50df5674f8cc488f4807d01d6a2b1 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:14:43 -0400 Subject: [PATCH 23/71] Fonts: the Document Fonts folder is found whatever its case (#287) Packages name the folder `Document fonts`; on a case-sensitive file system, opening a document beside it loaded none of its fonts. The exact name still wins when both exist. --- crates/engine/src/cmd/file.rs | 37 ++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 767c6228..267ea5d8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -185,7 +185,7 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option (Option std::path::PathBuf { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + let exact = dir.join(DOCUMENT_FONTS_FOLDER); + if exact.is_dir() { + return exact; + } + std::fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && e.path().is_dir()) + .map_or(exact, |e| e.path()) +} + fn file_open_bytes(s: &mut Session, p: &Value) -> Result { let b = base64_decode(str_param(p, "base64").unwrap_or("")); // IDML packages are recognised by their stored `mimetype` first entry. @@ -795,6 +812,24 @@ mod document_fonts_tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn the_document_fonts_folder_is_found_whatever_its_case() { + const FAMILY: &str = "DocFont Case Test"; + let dir = std::env::temp_dir().join(format!("dc-open-docfonts-case-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let fonts = dir.join("Document fonts"); + std::fs::create_dir_all(&fonts).unwrap(); + std::fs::write(fonts.join("case.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); + let path = dir.join("Brochure.idml"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + let r = open(&mut s, &path); + assert_eq!(r["documentFonts"], 1, "{r}"); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_document_without_the_folder_opens_as_before() { let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id())); From 6a331e671235781482473ef714ab5e966365512b Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:14:48 -0400 Subject: [PATCH 24/71] UI: Quick Apply opens without running an entry; rows are left-aligned (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * UI: the Quick Apply shortcut doesn't also run its first entry Cmd+Return opened Quick Apply, and in the same frame the list saw the same press as Enter and ran its first entry (New Document). A key press that fires a command shortcut is now consumed, so nothing else handles it that frame. This also stops Cmd+Return from inserting a paragraph break while typing. * UI: Quick Apply rows are left-aligned Rows without a shortcut or style kind were drawn with their label centred, because add_sized lays a button out centred and only the rows with a shortcut have an element that fills the remaining width. Each row is now laid out left to right in a fixed 460×22 area. * UI: Quick Apply runs its first entry only on a fresh Return Pressing Cmd+Return again while Quick Apply is open, or holding it until it repeats, reached the palette as Enter and ran the first entry. The palette now counts only a Return press that isn't a repeat and has no Cmd. --- crates/ui-egui/src/menus.rs | 90 ++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2a04e7cd..32cc48db 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1934,6 +1934,9 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { if app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) { continue; // The native menu handles it. } + // The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's + // default button) must not see the same press as Enter or Space this frame. + ctx.input_mut(|i| i.consume_key(modifiers, key)); // Like choosing the menu item: "…" commands open their dialog. activate(app, &id, &Value::Null); continue; @@ -1969,6 +1972,13 @@ pub fn palette(app: &mut DesignApp, ctx: &egui::Context) { ); r.request_focus(); let items = quick_apply_items(&app.session, &q); + // Only a fresh Return runs the first entry: ⌘Return pressed again or held down (repeats) is + // the shortcut, not a choice. + let enter = ui.input(|i| { + i.events + .iter() + .any(|e| matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, repeat: false, modifiers, .. } if !modifiers.command)) + }); egui::ScrollArea::vertical().max_height(340.0).show(ui, |ui| { for (i, it) in items.iter().enumerate() { let mut b = egui::Button::new(it.label.as_str()).frame(false); @@ -1977,7 +1987,13 @@ pub fn palette(app: &mut DesignApp, ctx: &egui::Context) { } else if !it.kind.is_empty() { b = b.shortcut_text(it.kind); } - if ui.add_sized([460.0, 22.0], b).clicked() || (i == 0 && ui.input(|i| i.key_pressed(egui::Key::Enter))) { + // The button aligns its label by the layout it's in: left, like the rows with a shortcut. + let size = egui::vec2(460.0, 22.0); + let row = + ui.allocate_ui_with_layout(size, egui::Layout::left_to_right(egui::Align::Center).with_main_align(egui::Align::Min), |ui| { + ui.add(b.min_size(size)) + }); + if row.inner.clicked() || (i == 0 && enter) { run = Some((it.id.clone(), it.params.clone())); } } @@ -2418,6 +2434,78 @@ mod tests { assert_eq!(app.session.tool_id(), "rectangleFrame"); } + #[test] + fn the_quick_apply_shortcut_opens_it_without_running_an_entry() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let cmd_return = + |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![cmd_return(true), cmd_return(false)]); + frame(&mut app, vec![]); + assert_eq!(app.ui.palette.as_deref(), Some(""), "Quick Apply stays open"); + assert_eq!(app.session.documents().len(), 1, "the first entry (New Document) didn't run"); + // Pressing the shortcut again, or holding it until it repeats, doesn't run it either. + frame(&mut app, vec![cmd_return(true), cmd_return(false)]); + let repeat = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: true, modifiers: egui::Modifiers::COMMAND }; + frame(&mut app, vec![repeat, cmd_return(false)]); + assert_eq!(app.ui.palette.as_deref(), Some(""), "Quick Apply stays open"); + assert_eq!(app.session.documents().len(), 1, "a second ⌘Return or a repeat doesn't run the first entry"); + // A plain Return still does. + let enter = + |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(&mut app, vec![enter(true), enter(false)]); + assert_eq!(app.ui.palette, None, "Return runs the first entry and closes Quick Apply"); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quick_apply_rows_are_left_aligned() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.ui.palette = Some(String::new()); + let ctx = egui::Context::default(); + let mut shapes = Vec::new(); + for _ in 0..3 { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + shapes = out.shapes; + } + let label_x = |label: &str| { + // The palette is painted last, over anything else with the same text. + shapes.iter().rev().find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == label => Some(t.pos.x), + _ => None, + }) + }; + // Rows that show a shortcut or a style kind, and rows that show neither, among the first visible ones. + let items = quick_apply_items(&app.session, ""); + let first = items.iter().take(10); + let plain = first.clone().find(|it| it.shortcut.is_none() && it.kind.is_empty()).expect("a row without a shortcut"); + let with_sc = first.clone().find(|it| it.shortcut.is_some()).expect("a row with a shortcut"); + let (a, b) = (label_x(&plain.label).expect("plain row drawn"), label_x(&with_sc.label).expect("shortcut row drawn")); + assert!((a - b).abs() < 0.5, "{:?} at x {a}, {:?} at x {b}", plain.label, with_sc.label); + } + #[test] fn sample_scripts_run() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 4fbf8938bf922b29602ef6e6e0984a229a8b994a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:14:54 -0400 Subject: [PATCH 25/71] UI: the shortcut recorder waits for the key; Interactive for PDF and Digital Publishing open their panels (#264) * Keyboard Shortcuts: the recorder waits for the key pressed with the modifiers * Workspaces: Interactive for PDF and Digital Publishing open their own panels --- crates/ui-egui/src/dialogs.rs | 25 +++++++++++++++++++++++-- crates/ui-egui/src/menus.rs | 24 +++++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f44503f7..44edebb0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -144,6 +144,11 @@ fn user_dictionary(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }); } +fn is_modifier_key(key: egui::Key) -> bool { + use egui::Key::*; + matches!(key, ShiftLeft | ShiftRight | ControlLeft | ControlRight | AltLeft | AltRight | SuperLeft | SuperRight) +} + fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let mut q = d.s("query"); ui.add( @@ -153,11 +158,12 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ); d.fields.insert("query".into(), json!(q)); let recording = d.s("recording"); - // Capture the next key press for the command being recorded. + // Capture the next key press for the command being recorded. Pressing a modifier + // reports the modifier itself as a key; skip it and wait for the key it modifies. if !recording.is_empty() { let pressed = ui.input(|i| { i.events.iter().find_map(|e| match e { - egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)), + egui::Event::Key { key, pressed: true, modifiers, .. } if !is_modifier_key(*key) => Some((*key, *modifiers)), _ => None, }) }); @@ -2767,4 +2773,19 @@ mod tests { let _ = command_fields(c.params); } } + + #[test] + fn shortcut_recorder_waits_for_the_key_after_its_modifiers() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mut d = Dialog::new("keyboardShortcuts", json!({"query": "", "recording": "app.palette"})); + let ctx = egui::Context::default(); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + // Holding Ctrl reports Ctrl itself as a key press, then Ctrl+K arrives. + for event in [key(egui::Key::ControlLeft, egui::Modifiers::COMMAND), key(egui::Key::K, egui::Modifiers::COMMAND)] { + let input = egui::RawInput { events: vec![event], ..Default::default() }; + ctx.run_ui(input, |ui| keyboard_shortcuts(&mut app, ui, &mut d)).textures_delta.clear(); + } + assert_eq!(crate::menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+K")); + assert_eq!(d.s("recording"), "", "recording ends with the recorded shortcut"); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 32cc48db..43795f61 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1360,10 +1360,20 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Some("properties"), + "Interactive for PDF" | "Digital Publishing" => Some("pages"), + _ => None, + }; + if let Some(tab) = dock_tab { + app.ui.dock_tab = tab.into(); + app.ui.dock_expanded = true; + } app.ui.open_panel = match name { "Typography" => Some("paragraphStyles".into()), "Printing and Proofing" => Some("swatches".into()), + "Interactive for PDF" => Some("buttons".into()), + "Digital Publishing" => Some("liquid".into()), _ => None, }; app.ui.workspace = name.to_string(); @@ -2738,6 +2748,18 @@ mod tests { assert_eq!(app.ui.workspace, "Essentials"); } + #[test] + fn interactive_workspaces_show_their_panels() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.dock_expanded = false; + run_ui(&mut app, "window.workspace", &json!({"name": "Interactive for PDF"})).unwrap().unwrap(); + assert_eq!((app.ui.dock_tab.as_str(), app.ui.dock_expanded, app.ui.open_panel.as_deref()), ("pages", true, Some("buttons"))); + run_ui(&mut app, "window.workspace", &json!({"name": "Digital Publishing"})).unwrap().unwrap(); + assert_eq!((app.ui.dock_tab.as_str(), app.ui.open_panel.as_deref()), ("pages", Some("liquid"))); + run_ui(&mut app, "window.workspace", &json!({"name": "Essentials"})).unwrap().unwrap(); + assert_eq!(app.ui.open_panel, None); + } + #[test] fn hide_and_show_menu_items() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 5a7ca767db03ccd5dd0872067d10064e77900463 Mon Sep 17 00:00:00 2001 From: jahala Date: Sat, 10 Oct 2026 06:50:23 +0100 Subject: [PATCH 26/71] UI: the Control panel shows its second row (#142) In every workspace that shows the Control panel (Advanced, Typography, Printing and Proofing, Book), the second row of fields was cut off at the panel's lower edge: Y and H in object mode, leading, tracking and the indents in text mode. The panel lays its groups out in a row that centres each child vertically. The two-row grids were placed as if they were one row tall and then grew downward, so they started 13.5 pt below the panel's top instead of at it and ended 5.5 pt past the 59 pt panel. The row is now top-aligned, which is where the font menus, the separators and the reference-point proxy already sat. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/lib.rs | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 23876f3a..4d93517d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -164,7 +164,7 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { // Wider than the window: the bar scrolls sideways. crate::widgets::overflow_scrolling(ui); egui::ScrollArea::horizontal().id_salt("control_bar_scroll").auto_shrink([false, true]).show(ui, |ui| { - ui.horizontal(|ui| { + ui.horizontal_top(|ui| { let text_mode = matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some()); if text_mode { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4ec0b8b0..89ffd1a7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -912,6 +912,30 @@ pub fn drop_key_name_text(raw: &mut egui::RawInput, text_field_focused: bool) { mod tests { use super::*; + /// The Control panel's two-row groups were centred as if they were one row tall, so the second + /// row hung below the panel and was cut off. + #[test] + fn control_bar_shows_its_second_row() { + use egui_kittest::kittest::Queryable as _; + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + app.run("file.newSample", json!({})).unwrap(); + app.ui.control_bar = true; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("control_bar")).unwrap().outer_rect; + assert!(bar.height() > 40.0, "{bar:?}"); + let rows: Vec = h.query_all_by_label("Y:").map(|n| n.rect()).filter(|r| bar.contains(r.left_top())).collect(); + assert_eq!(rows.len(), 1, "the Y: caption of the Control panel"); + assert!(rows[0].bottom() <= bar.bottom(), "Y: caption {:?} hangs below the Control panel {bar:?}", rows[0]); + } + #[test] fn key_names_sent_as_text_are_dropped() { let text = |t: &str| egui::Event::Text(t.to_string()); From bbe234aaa6cad692a057362065f3adc1ca063cce Mon Sep 17 00:00:00 2001 From: jahala Date: Sat, 10 Oct 2026 06:50:29 +0100 Subject: [PATCH 27/71] PDF: a damaged PNG, GIF or WebP is skipped, not fatal (#147) A placed PNG, GIF or WebP that is cut off (an interrupted download) or has a bad checksum made the whole PDF export fail with "PDF writer error: Image(Image(ImageRepr {..}), None, "unexpected end of file")", which names no file. A damaged TIFF or BMP in the same place is skipped with the warning "image `name` could not be decoded and was skipped", and the rest of the document is exported. The exporter decides whether an image can be embedded by asking krilla to open it. krilla opens PNG, GIF and WebP lazily: it reads the header, accepts the file, and decodes the pixels only when the PDF is written, where an error ends the export. TIFF and BMP are decoded by us first, so they took the skip path. lossless() now decodes the image first for every format. A file that does not decode returns None and takes the existing skip-with-warning path. This costs one extra decode per PNG, GIF or WebP asset: the sample magazine (two PNGs, 820 KB) exports in 0.19 s instead of 0.17 s in a debug build. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/pdf/src/export.rs | 5 ++++- crates/pdf/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 095ea77c..e5ebb676 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1281,6 +1281,10 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option> { } fn lossless(data: &Arc>, fmt: Option, interpolate: bool) -> Option { + // krilla reads PNG, GIF and WebP lazily: a damaged file is accepted here and fails the whole + // export when the PDF is written. Decoding it first means it is skipped, like any other + // image that can't be decoded. + let rgba = designcraft_images::decode_rgba(data)?; let direct = match fmt { Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), interpolate).ok(), Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), interpolate).ok(), @@ -1289,7 +1293,6 @@ fn lossless(data: &Arc>, fmt: Option, interpolate: b }; direct.or_else(|| { // TIFF, BMP, PSD composites… - let rgba = designcraft_images::decode_rgba(data)?; let (w, h) = rgba.dimensions(); Some(Image::from_rgba8(rgba.into_raw(), w, h)) }) diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 83744a3e..18aa5b41 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -240,6 +240,31 @@ fn place(d: &mut Document, data: Vec, px: (u32, u32)) { d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); } +/// krilla reads PNG, GIF and WebP lazily, so a damaged one passed the exporter's "does it decode" +/// check and failed the whole export when the PDF was written ("PDF writer error: … unexpected +/// end of file"). A cut-off download must be skipped with a warning, as a damaged TIFF is. +#[test] +fn a_cut_off_image_is_skipped_not_fatal() { + let px = image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 37 + y * 11) as u8, (x * 5) as u8, (y * 91) as u8, 255])); + for format in [image::ImageFormat::Png, image::ImageFormat::Gif, image::ImageFormat::WebP] { + let mut whole = Vec::new(); + px.write_to(&mut std::io::Cursor::new(&mut whole), format).unwrap(); + let cut = whole[..whole.len() * 9 / 10].to_vec(); + let mut d = doc_with_text("still here"); + place(&mut d, cut, (64, 64)); + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap_or_else(|e| panic!("{format:?}: {e}")); + assert!(r.warnings.iter().any(|w| w.contains("could not be decoded and was skipped")), "{format:?}: {:?}", r.warnings); + assert!(image_xobjects(&r.bytes).is_empty(), "{format:?}"); + assert!(extract_text(&r.bytes).concat().contains("still"), "{format:?}: the rest of the page is exported"); + // The undamaged file still goes in. + let mut d = doc_with_text("x"); + place(&mut d, whole, (64, 64)); + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(r.warnings.is_empty(), "{format:?}: {:?}", r.warnings); + assert_eq!(image_xobjects(&r.bytes).len(), 1, "{format:?}"); + } +} + /// Every image XObject in a PDF: its /ColorSpace name (`?` when not a name) and decoded samples. fn image_xobjects(bytes: &[u8]) -> Vec<(String, Vec)> { use hayro_syntax::object::Name; From 5369687497dfc7611beee3757384a470abde482a Mon Sep 17 00:00:00 2001 From: kubuntugamer <52578831+kubuntugamer@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:50:34 -0500 Subject: [PATCH 28/71] fix(cli): --help prints usage to stdout and exits 0 (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking for help is not an error, but `designcraft-cli --help` shared the catch-all arm with unknown commands, so it wrote the usage block to stderr and exited 1 — unusable in a pipeline and wrong by convention (`--version` already exits 0). Split the arm: `--help`, `-h` and `help` now print the same usage block to stdout and succeed, while an unknown command keeps it on stderr and still fails. The usage text and links line are hoisted into a shared `USAGE` const and `usage_footer()` so the two paths can't drift. Fixes #243 🤖 Generated with Codebuff Co-authored-by: Codebuff Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/designcraft-cli/src/main.rs | 31 ++++++++++++++++++++++--------- apps/designcraft-cli/tests/cli.rs | 20 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index a564befc..11514f70 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -34,6 +34,19 @@ mod perf; use designcraft_engine::Session; use serde_json::{Value, json}; +/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). +const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"; + +/// The links line under the usage block. +fn usage_footer() -> String { + format!( + "\nCommunity: {} · {} · {}", + designcraft_engine::links::DISCORD, + designcraft_engine::links::APP_PAGE, + designcraft_engine::links::GITHUB + ) +} + /// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as /// ripgrep does; any other write error is reported. fn stdout_failed(e: std::io::Error) -> ! { @@ -76,16 +89,16 @@ fn main() -> ExitCode { outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}"); ExitCode::SUCCESS } + // Asking for help is not an error: it goes to stdout and succeeds. An unknown command + // still prints the same usage to stderr and fails. + Some("--help" | "-h" | "help") => { + outln!("{USAGE}"); + outln!("{}", usage_footer()); + ExitCode::SUCCESS + } _ => { - eprintln!( - "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version" - ); - eprintln!( - "\nCommunity: {} · {} · {}", - designcraft_engine::links::DISCORD, - designcraft_engine::links::APP_PAGE, - designcraft_engine::links::GITHUB - ); + eprintln!("{USAGE}"); + eprintln!("{}", usage_footer()); ExitCode::FAILURE } } diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 8c2b0f14..1a06d13b 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -67,6 +67,26 @@ fn run_cmd_references_and_describe() { assert!(v.as_array().unwrap().iter().all(|c| c.to_string().contains("footnote"))); } +/// `designcraft-cli --help` printed usage to stderr and exited 1; asking for help is not an +/// error (#243). It now goes to stdout and succeeds, while an unknown command keeps the same +/// usage on stderr and fails. +#[test] +fn help_goes_to_stdout_and_unknown_args_to_stderr() { + for flag in ["--help", "-h", "help"] { + let out = cli().arg(flag).output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{flag}: {:?}", out.status); + assert!(stdout.starts_with("usage: designcraft-cli"), "{flag}: {stdout:?}"); + assert!(stdout.contains("designcraft-cli --version"), "{flag}: {stdout:?}"); + assert!(out.stderr.is_empty(), "{flag} wrote to stderr: {}", String::from_utf8_lossy(&out.stderr)); + } + // An unknown option is still an error: usage on stderr, nothing on stdout, non-zero exit. + let out = cli().arg("--no-such-option").output().unwrap(); + assert!(!out.status.success(), "{:?}", out.status); + assert!(out.stdout.is_empty(), "unexpected stdout: {}", String::from_utf8_lossy(&out.stdout)); + assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage: designcraft-cli")); +} + /// `designcraft-cli commands | head -1` panicked with "failed printing to /// stdout: Broken pipe (os error 32)" and exit status 101. #[test] From 3d2bda4d6b056e2ddc3c7b24af97da2605aa680c Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Sat, 10 Oct 2026 07:50:38 +0200 Subject: [PATCH 29/71] Desktop: write log records to standard error and a rotating log file (#122) * Desktop: write log records to standard error and a rotating log file The desktop app installed no logger, so every log::warn!/log::error! in the workspace was discarded: the crash guard's panic report, a failed crash-recovery save, font folders that can't be read. A launch from a desktop menu has no terminal either, so there was nothing to attach to a bug report. apps/designcraft/src/logging.rs (no new dependency): - standard error plus /logs/designcraft.log (beside ui.json and prefs.json); each launch rotates the previous log to designcraft.1.log and .2.log, so the log of a run that crashed survives the next start; the file is capped at 16 MiB - records logged before the settings directory is known are buffered (bounded) and written once the file is attached - default levels: info for designcraft*, warn for everything else; RUST_LOG replaces them with env_logger-style directives - never panics: an unusable directory or a failed write leaves standard error as the only sink The logger is installed first in main (before the engine's panic hook) and the file is opened after argument parsing (--version leaves no file). The settings directory moves out of prefs_path into config_dir. A --control or DESIGNCRAFT_CONTROL_PORT value that is not a port is now logged instead of silently leaving the control channel off. Documented in README.md (Quick start > Logs). * Desktop: no eprintln! on the log and panic paths eprintln! panics when standard error is a broken pipe (a GUI launch on Windows), and a panic inside the panic hook aborts. The "no log file" message goes through log::warn! (standard error only by then); the panic hook's fallback writes to standard error with write_fmt and ignores the error. Same fix as storytold/vectorcraft#465. --- README.md | 17 ++ apps/designcraft/src/logging.rs | 437 ++++++++++++++++++++++++++++++++ apps/designcraft/src/main.rs | 50 +++- 3 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 apps/designcraft/src/logging.rs diff --git a/README.md b/README.md index c6c08faf..6884a15e 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,23 @@ include it. To drive a running app, send JSON lines to `127.0.0.1:7979`. The protocol is described in [`docs/control-protocol.md`](docs/control-protocol.md). +### Logs + +The desktop app writes its `log` records to standard error and to `logs/designcraft.log` in the +settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/designcraft/logs/`, or +`$XDG_CONFIG_HOME/designcraft/logs/`; macOS `~/Library/Application Support/DesignCraft/logs/`; +Windows `%APPDATA%\DesignCraft\logs\`). A start launched from a desktop menu or the Dock has no +terminal, so this file is what to attach to a bug report: the crash guard's panic report and a +failed crash-recovery save land there. Each launch moves the previous log to `designcraft.1.log` +(and that one to `designcraft.2.log`), so the log of a run that crashed survives the next start. +The file stops growing at 16 MiB. `--version` writes no file. + +| Variable | Effect | +|---|---| +| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). | + +The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead. + ### Web ```sh diff --git a/apps/designcraft/src/logging.rs b/apps/designcraft/src/logging.rs new file mode 100644 index 00000000..d3e0dbea --- /dev/null +++ b/apps/designcraft/src/logging.rs @@ -0,0 +1,437 @@ +//! The desktop app's logger: `log::` records go to standard error and to a log file. +//! +//! Without a logger every `log::warn!`/`log::error!` in the workspace (the crash guard's panic +//! report, a failed crash-recovery save, font folders that can't be read) vanished. A launch from +//! a desktop menu has no terminal, so the file is what a bug report can attach: +//! `/logs/designcraft.log` (the directory beside `ui.json` and `prefs.json`, see +//! `config_dir` in `main.rs`). Each start moves the previous log to `designcraft.1.log` (and that +//! one to `.2`), so the log of a run that crashed survives the next launch. +//! +//! Levels: `info` for DesignCraft's own crates, `warn` for everything else (wgpu and naga are +//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`, +//! `warn,designcraft_render=trace`, `wgpu_core=info`. A directive ending in `*` matches every +//! target that starts with it (`designcraft*=debug`). +//! +//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and +//! written once the file is attached. Writing never panics: a file that can't be created or +//! written leaves standard error as the only sink. + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::SystemTime; + +use log::LevelFilter; + +/// The current log file's name inside the log directory. +pub const LOG_FILE: &str = "designcraft.log"; +/// How many previous logs are kept (`designcraft.1.log` … `designcraft..log`). +pub const KEEP: usize = 2; +/// The log file stops growing past this size (a runaway warning can't fill the disk). +pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +/// Records kept in memory until the log file is attached. +pub const MAX_PENDING: usize = 512; +/// The built-in filter when `RUST_LOG` is unset or empty. +pub const DEFAULT_FILTER: &str = "warn,designcraft*=info"; + +/// Per-target level filter parsed from env_logger-style directives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Filter { + default: LevelFilter, + directives: Vec<(String, LevelFilter)>, +} + +impl Filter { + /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no + /// directive names log errors only. + pub fn parse(spec: &str) -> Filter { + let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() }; + for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('=') { + Some((name, level)) => { + let name = name.trim(); + if let (false, Some(level)) = (name.is_empty(), parse_level(level)) { + f.directives.push((name.to_owned(), level)); + } + } + None => match parse_level(part) { + Some(level) => f.default = level, + // A bare target name: everything from it (env_logger does the same). + None => f.directives.push((part.to_owned(), LevelFilter::Trace)), + }, + } + } + f + } + + /// The level that applies to `target` (the most specific matching directive wins). + pub fn level_for(&self, target: &str) -> LevelFilter { + self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level) + } + + /// The most verbose level any target can reach (for `log::set_max_level`). + pub fn max(&self) -> LevelFilter { + self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max) + } +} + +fn parse_level(s: &str) -> Option { + s.trim().parse().ok() +} + +/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`); +/// `name*` matches every target starting with `name`. +fn matches(name: &str, target: &str) -> bool { + match name.strip_suffix('*') { + Some(prefix) => target.starts_with(prefix), + None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")), + } +} + +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +pub fn timestamp(t: SystemTime) -> String { + let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let secs = since.as_secs(); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) +} + +/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's +/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days + 719_468; + let era = z / 146_097; + let doe = z % 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + u64::from(m <= 2); + (y, m, d) +} + +fn numbered(dir: &Path, n: usize) -> PathBuf { + if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("designcraft.{n}.log")) } +} + +/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) +/// and create a fresh, empty log file in `dir` (created if missing). +pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { + std::fs::create_dir_all(dir)?; + for n in (1..=KEEP).rev() { + match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + } + let path = numbered(dir, 0); + let file = File::create(&path)?; + Ok((path, file)) +} + +/// One formatted log line: ` [] : \n`. +pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { + let message = message.strip_suffix('\n').unwrap_or(message); + format!("{ts} {level:<5} [{thread}] {target}: {message}\n") +} + +/// Where formatted lines go besides standard error: kept in memory until a file is attached, +/// then appended to it until it reaches its size cap. +pub struct Sink { + file: Option, + pending: Vec, + dropped: usize, + written: u64, + max_bytes: u64, + capped: bool, +} + +impl Sink { + pub fn new(max_bytes: u64) -> Sink { + Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false } + } + + /// Append `line` to the file, or keep it until [`Sink::attach`]. + pub fn write(&mut self, line: &str) { + if self.file.is_none() { + if self.pending.len() < MAX_PENDING { + self.pending.push(line.to_owned()); + } else { + self.dropped = self.dropped.saturating_add(1); + } + return; + } + self.append(line); + } + + /// Start writing to `file`, first the lines kept so far. + pub fn attach(&mut self, file: File) { + self.file = Some(file); + for line in std::mem::take(&mut self.pending) { + self.append(&line); + } + if self.dropped > 0 { + let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped); + self.dropped = 0; + self.append(¬e); + } + } + + fn append(&mut self, line: &str) { + if self.capped { + return; + } + let Some(file) = self.file.as_mut() else { return }; + let len = u64::try_from(line.len()).unwrap_or(u64::MAX); + let text = if self.written.saturating_add(len) > self.max_bytes { + self.capped = true; + format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes) + } else { + self.written = self.written.saturating_add(len); + line.to_owned() + }; + // A full disk or a vanished file must not take the app down: drop the file sink. + if file.write_all(text.as_bytes()).is_err() { + self.file = None; + self.capped = true; + } + } +} + +/// The installed logger: a [`Filter`], standard error and a [`Sink`]. +pub struct AppLogger { + filter: Filter, + stderr: bool, + sink: Mutex, +} + +impl AppLogger { + pub fn new(filter: Filter, stderr: bool) -> AppLogger { + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) } + } + + /// Rotate the logs in `dir` and send records to the fresh file; returns its path. + pub fn attach_dir(&self, dir: &Path) -> Result { + let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file); + Ok(path) + } +} + +impl log::Log for AppLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= self.filter.level_for(metadata.target()) + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + let thread = std::thread::current(); + let line = + format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); + if self.stderr { + let _ = std::io::stderr().write_all(line.as_bytes()); + } + self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line); + } + + fn flush(&self) {} +} + +/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another +/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known. +pub fn install() -> Option<&'static AppLogger> { + static LOGGER: OnceLock = OnceLock::new(); + let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty()); + let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER)); + let max = filter.max(); + let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true)); + log::set_logger(logger).ok()?; + log::set_max_level(max); + Some(logger) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, UNIX_EPOCH}; + + fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("designcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d + } + + #[test] + fn the_default_filter_shows_designcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("designcraft"), LevelFilter::Info); + assert_eq!(f.level_for("designcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("designcraft_render::tile"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); + } + + #[test] + fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,designcraft_render=trace,designcraft_render::tile=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("designcraft_render::raster"), LevelFilter::Trace); + assert_eq!(f.level_for("designcraft_render::tile"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + } + + #[test] + fn hostile_specs_never_panic_and_fall_back_sensibly() { + for spec in ["", ",,,", "=", "=debug", "designcraft=", "nonsense=loud", "🦀=info", "*", "a=b=c", "warn,,designcraft=DEBUG"] { + let f = Filter::parse(spec); + let _ = f.level_for("designcraft"); + let _ = f.max(); + } + assert_eq!(Filter::parse("warn,,designcraft=DEBUG").level_for("designcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + } + + #[test] + fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + // 2026-10-08T07:59:17.728Z + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap days, including the century rule (2000 is a leap year, 2100 is not). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_704_067_199_999)), "2023-12-31T23:59:59.999Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(std::fs::read_to_string(&path).expect("read"), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(std::fs::read_to_string(dir.join(LOG_FILE)).expect("current"), "run 4"); + assert_eq!(std::fs::read_to_string(dir.join("designcraft.1.log")).expect("1"), "run 3"); + assert_eq!(std::fs::read_to_string(dir.join("designcraft.2.log")).expect("2"), "run 2"); + assert!(!dir.join("designcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); + } + fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") + } + + #[test] + fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Error, "main", "designcraft_engine::guard", "command `frame.create` panicked: boom"), + "2026-10-08T07:59:17.728Z ERROR [main] designcraft_engine::guard: command `frame.create` panicked: boom\n" + ); + // A multi-line message (a panic with a backtrace) keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); + } + + #[test] + fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + let record = |level, target: &'static str, msg: &'static str| { + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); + }; + record(log::Level::Info, "designcraft::prefs", "settings directory"); + record(log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(log::Level::Debug, "designcraft", "below info"); + let text = read(&path); + assert!(text.contains(" INFO [") && text.contains("designcraft::prefs: settings directory\n"), "{text}"); + assert!(text.contains("wgpu_hal::vulkan: a real warning\n"), "{text}"); + assert!(!text.contains("too chatty") && !text.contains("below info"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("designcraft_render").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + use log::Log; + let dir = temp_dir("unusable"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + assert!(logger.attach_dir(&dir).is_err()); + logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build()); + let _ = std::fs::remove_file(&dir); + } +} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index a5cb13fb..fa413e3a 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -9,6 +9,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod control_server; +mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -41,8 +42,9 @@ impl eframe::App for App { } } -fn prefs_path() -> Option { - let base = if cfg!(target_os = "macos") { +/// The per-user settings directory: `ui.json`, `prefs.json` and `logs/` live here. +fn config_dir() -> Option { + if cfg!(target_os = "macos") { std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DesignCraft")) } else if cfg!(windows) { std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DesignCraft")) @@ -51,8 +53,11 @@ fn prefs_path() -> Option { .map(std::path::PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) .map(|c| c.join("designcraft")) - }; - base.map(|b| b.join("ui.json")) + } +} + +fn prefs_path() -> Option { + config_dir().map(|b| b.join("ui.json")) } fn load_prefs(app: &mut DesignApp) { @@ -182,14 +187,28 @@ fn app_icon() -> Option { eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok() } +/// The control port from `--control ` or `DESIGNCRAFT_CONTROL_PORT` (`source`); a value that +/// is not a port is logged and ignored instead of silently starting no control channel. +fn control_port_from(source: &str, value: Option) -> Option { + let value = value?; + let port = value.trim().parse().ok(); + if port.is_none() { + log::warn!("{source}: {value:?} is not a port number; the control channel is off"); + } + port +} + fn main() -> eframe::Result { - let mut control_port: Option = std::env::var("DESIGNCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + // First, so every start-up record (and the engine's panic hook, installed with the first + // Session) is captured; see `logging`. + let logger = logging::install(); + let mut control_port = control_port_from("DESIGNCRAFT_CONTROL_PORT", std::env::var("DESIGNCRAFT_CONTROL_PORT").ok()); let mut files = Vec::new(); let mut sample = false; let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { - "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--control" => control_port = control_port_from("--control", args.next()), "--sample" => sample = true, "--version" => { println!("designcraft {}", env!("CARGO_PKG_VERSION")); @@ -198,6 +217,15 @@ fn main() -> eframe::Result { _ => files.push(a), } } + // The log file lives under the settings directory; opened after the arguments, so `--version` + // leaves no file behind. Records logged until now are written to it first. + if let (Some(logger), Some(dir)) = (logger, config_dir()) { + match logger.attach_dir(&dir.join("logs")) { + Ok(path) => log::info!("DesignCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), + // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. + Err(e) => log::warn!("no log file: {e}"), + } + } let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("DesignCraft") @@ -260,6 +288,16 @@ fn main() -> eframe::Result { #[cfg(test)] mod tests { + #[test] + fn control_port_values_that_are_not_ports_are_ignored() { + assert_eq!(super::control_port_from("--control", Some("7979".into())), Some(7979)); + assert_eq!(super::control_port_from("--control", Some(" 7979 ".into())), Some(7979)); + assert_eq!(super::control_port_from("--control", None), None); + for bad in ["", "abc", "-1", "65536", "99999999999999999999"] { + assert_eq!(super::control_port_from("--control", Some(bad.into())), None, "{bad:?}"); + } + } + #[test] fn data_merge_open_dialog_lists_table_extensions() { let filters = super::open_filters("dataMerge"); From 67d4539df775dd0f4a17e0faad62438b87bcd47c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:50:43 +1100 Subject: [PATCH 30/71] Distribute selected table rows evenly and keep the edit undoable. (#224) The rows share one fixed height that preserves their composed total. Merged rows and rows that are not laid out are refused, and the table stays unchanged. --- ROADMAP.md | 1 + crates/engine/src/cmd/table.rs | 165 +++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 21 ++++ crates/ui-egui/src/panels/table.rs | 8 ++ 4 files changed, 195 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 58824013..506408a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,6 +93,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`). +- Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text. - Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. - Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X). - Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 3bb188b4..6836d1bf 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -203,6 +203,15 @@ fn table_specs() -> Vec { cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height), cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width), cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols), + cmd!( + "table.distributeRows", + "Distribute Rows Evenly", + ["Table"], + None, + "{story?, table?, rows?: [a,b]} — equal fixed heights preserving the selected rows' composed total height; may overset cell text; unmerge row-spanning cells first", + in_table, + distribute_rows + ), cmd!( "table.options", "Table Options", @@ -743,6 +752,63 @@ fn set_col_width(s: &mut Session, p: &Value) -> Result { }) } +fn distribute_rows(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.distributeRows"; + if !p.is_object() { + return Err(bad(ID, "parameters must be an object")); + } + if let Some(rows) = p.get("rows") { + let valid = rows.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(|v| v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()))); + if !valid { + return Err(bad(ID, "rows must be two non-negative row indices")); + } + } + for key in ["row", "story", "table"] { + if let Some(v) = p.get(key) + && !v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()) + { + return Err(bad(ID, format!("{key} must be a non-negative integer"))); + } + } + let g = target(s, p, ID)?; + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + if t.cells.iter().any(|c| c.row_span > 1) { + return Err(bad(ID, "unmerge cells that span rows first")); + } + let cs = s.cache.get(&st.doc, g.story, None); + let mut total = 0.0; + for row in g.range.r0..=g.range.r1 { + let height = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .filter(|f| f.table == g.table) + .flat_map(|f| &f.cells) + .filter(|c| c.row == row) + .map(|c| c.rect.height()) + .reduce(f64::max) + .ok_or_else(|| bad(ID, "all selected rows must be laid out; enlarge or thread the text frame first"))?; + if !height.is_finite() || height <= 0.0 { + return Err(bad(ID, "invalid composed row height")); + } + total += height; + } + let count = g.range.r1 - g.range.r0 + 1; + let height = total / count as f64; + if !height.is_finite() { + return Err(bad(ID, "invalid total row height")); + } + edit_table(s, &g, ID, |t| { + let rows = t.rows.get_mut(g.range.r0..=g.range.r1).ok_or_else(|| bad(ID, "invalid row range"))?; + for row in rows { + row.height = height; + row.mode = RowHeightMode::Exactly; + } + Ok(json!({"rows": count, "height": height})) + }) +} + fn distribute_cols(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.distributeColumns")?; edit_table(s, &g, "table.distributeColumns", |t| { @@ -1326,6 +1392,105 @@ mod sort_tests { use crate::Session; + fn distribution_session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + for (row, height) in [(0, 25), (1, 40), (2, 80), (3, 30)] { + s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap(); + s.execute("table.setCell", &json!({"row": row, "col": 0, "text": format!("row {row}")})).unwrap(); + } + s + } + + fn table(s: &Session) -> designcraft_doc::Table { + (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone() + } + + #[test] + fn distribute_selected_rows_preserves_height_content_and_undo() { + let mut s = distribution_session(); + s.execute("table.select", &json!({"rows": [1, 2], "what": "row"})).unwrap(); + let before = table(&s); + let selection = s.doc().unwrap().selection.cells; + let result = s.execute("table.distributeRows", &json!({})).unwrap(); + assert_eq!(result, json!({"rows": 2, "height": 60.0})); + let after = table(&s); + assert_eq!(after.rows.iter().map(|r| r.height).collect::>(), [25.0, 60.0, 60.0, 30.0]); + assert_eq!(after.cells, before.cells); + assert_eq!(after.rows[0].kind, before.rows[0].kind); + assert_eq!(s.doc().unwrap().selection.cells, selection); + let st = s.doc().unwrap(); + let sid = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; + let cs = s.cache.get(&st.doc, sid, None); + let heights: Vec<_> = + cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect.height()).collect(); + assert_eq!(heights, [25.0, 60.0, 60.0, 30.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(table(&s), after); + } + + #[test] + fn distribution_uses_content_grown_heights_and_explicit_table() { + let mut s = distribution_session(); + s.execute("table.setRowHeight", &json!({"rows": [1, 2], "height": 3, "mode": "atLeast"})).unwrap(); + s.execute("table.setCell", &json!({"row": 2, "col": 0, "text": "one\ntwo\nthree"})).unwrap(); + let st = s.doc().unwrap(); + let story = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap(); + let (sid, tid) = (story.id, *story.tables.keys().next().unwrap()); + let cs = s.cache.get(&st.doc, sid, None); + let total: f64 = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .flat_map(|f| &f.cells) + .filter(|c| c.col == 0 && (1..=2).contains(&c.row)) + .map(|c| c.rect.height()) + .sum(); + let result = s.execute("table.distributeRows", &json!({"story": sid.0, "table": tid, "rows": [1, 2]})).unwrap(); + assert!((result["height"].as_f64().unwrap() * 2.0 - total).abs() < 1e-6); + assert!(table(&s).rows[1].height > 3.0); + assert_eq!(table(&s).rows[1].mode, designcraft_doc::RowHeightMode::Exactly); + } + + #[test] + fn distribution_keeps_column_spans_and_caret() { + let mut s = distribution_session(); + s.execute("table.merge", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap(); + let before = table(&s); + let caret = s.doc().unwrap().selection.text; + let result = s.execute("table.distributeRows", &json!({"rows": [2, 1]})).unwrap(); + assert_eq!(result["height"], 60.0); + assert_eq!(table(&s).cell(1, 0).unwrap().col_span, 2); + assert_eq!(table(&s).cells, before.cells); + assert_eq!(s.doc().unwrap().selection.text, caret); + } + + #[test] + fn distribution_rejects_merges_and_missing_rows_without_changes() { + let mut s = distribution_session(); + s.execute("table.merge", &json!({"rows": [1, 2], "cols": [0, 0]})).unwrap(); + let before = table(&s); + assert!(s.execute("table.distributeRows", &json!({"rows": [1, 2]})).is_err()); + assert_eq!(table(&s), before); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("table.setRowHeight", &json!({"rows": [0, 3], "height": 10000, "mode": "exactly"})).unwrap(); + let before = table(&s); + assert!(s.execute("table.distributeRows", &json!({"rows": [0, 3]})).is_err()); + assert_eq!(table(&s), before); + assert!(s.execute("table.distributeRows", &json!({"rows": [0, 99]})).is_err()); + for params in + [json!({"rows": "all"}), json!({"rows": [0]}), json!({"rows": [-1, 2]}), json!({"row": 1.5}), json!({"table": "bad"}), json!([])] + { + assert!(s.execute("table.distributeRows", ¶ms).is_err(), "{params}"); + assert_eq!(table(&s), before); + } + } + #[test] fn sort_body_rows_by_a_column() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 43795f61..793276b6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -576,6 +576,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.splitHorizontally", "cmd:table.splitVertically", "cmd:table.distributeColumns", + "cmd:table.distributeRows", "-", "ui:app.tablePanel", ], @@ -2705,6 +2706,25 @@ mod tests { assert!(quick_apply_items(&s, "preferences").iter().any(|i| i.id == "app.preferences")); } + #[test] + fn distribute_rows_menu_dispatches_an_undoable_edit() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + app.run("text.select", json!({"story": frame["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap(); + app.run("table.setRowHeight", json!({"row": 0, "height": 40, "mode": "exactly"})).unwrap(); + app.run("table.setRowHeight", json!({"row": 1, "height": 80, "mode": "exactly"})).unwrap(); + app.run("table.selectTable", json!({})).unwrap(); + activate(&mut app, "table.distributeRows", &Value::Null); + assert!(app.ui.dialog.is_none()); + let info = app.run("table.get", json!({})).unwrap(); + assert_eq!(info["rows"][0]["height"], 60.0); + assert_eq!(info["rows"][1]["height"], 60.0); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.run("table.get", json!({})).unwrap()["rows"][0]["height"], 40.0); + } + #[test] fn every_menu_entry_is_a_command() { let mut all = Vec::new(); @@ -2712,6 +2732,7 @@ mod tests { walk(&items, &mut all); } assert!(all.len() > 80, "{}", all.len()); + assert!(all.iter().any(|(label, id, _)| label == "Distribute Rows Evenly" && id == "table.distributeRows")); for (label, id, params) in &all { assert!(ui_label(id).is_some() || designcraft_engine::find_command(id).is_some(), "menu entry {label}: unknown command {id}"); assert!(!label.is_empty() && label != id, "menu entry {id} has no label"); diff --git a/crates/ui-egui/src/panels/table.rs b/crates/ui-egui/src/panels/table.rs index 7a1cb3aa..dd319e66 100644 --- a/crates/ui-egui/src/panels/table.rs +++ b/crates/ui-egui/src/panels/table.rs @@ -139,6 +139,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.end_row(); }); + if ui + .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Distribute Rows Evenly"))) + .on_hover_text("Equal fixed heights for the selected rows; cell text may become overset.") + .clicked() + && let Err(e) = app.run("table.distributeRows", json!({})) + { + app.status(format!("Table: {e}")); + } divider(ui); caption(ui, crate::i18n::tr(&app.ui.language, "Cell Text")); ui.horizontal(|ui| { From cfac46a8d9971b28d00faa3e48755b3581b0f8c0 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 9 Oct 2026 23:50:47 -0600 Subject: [PATCH 31/71] FID1: prevent mixed gradient color spaces from aborting PDF export (#229) --- ROADMAP.md | 2 + crates/pdf/src/export.rs | 29 ++++- crates/pdf/src/lib.rs | 2 + crates/pdf/tests/gradient_color_spaces.rs | 145 ++++++++++++++++++++++ 4 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 crates/pdf/tests/gradient_color_spaces.rs diff --git a/ROADMAP.md b/ROADMAP.md index 506408a9..c3608b9b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # DesignCraft roadmap +- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. + - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index e5ebb676..72b378a9 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -941,22 +941,43 @@ impl Exporter<'_> { } /// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown. - fn fill_paint(&self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { + fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { + let kind = g.kind; + let expanded = g.expanded_stops(); + // A PDF shading needs one colour space. Midpoint expansion can introduce RGB even + // when every authored stop is CMYK/Gray; PDF/A can also change the output space. + let components = |c: &Color| match c { + Color::Cmyk { .. } if !self.rgb_only => 4, + Color::Gray { .. } => 1, + _ => 3, + }; + let mixed = expanded.first().is_some_and(|(_, first, _)| expanded.iter().any(|(_, c, _)| components(c) != components(first))); + if mixed { + self.warn(format!("gradient '{swatch}': mixed color spaces converted to RGB (colour appearance and separations may change)")); + } let mut stops: Vec = Vec::new(); let mut last = 0.0f32; - for (o, c, a) in g.expanded_stops() { + for (o, c, a) in expanded { let o = o.clamp(last, 1.0); last = o; - stops.push(Stop { offset: norm(o), color: device(&c, self.rgb_only), opacity: norm(a) }); + let color = if mixed && !matches!(c, Color::Rgb { .. }) { + let [r, g, b] = c.to_rgb(); + rgb::Color::new(q(r), q(g), q(b)).into() + } else { + // Preserve RGB values, including already-interpolated display RGB, and + // leave every homogeneous gradient on its existing device-space path. + device(&c, self.rgb_only) + }; + stops.push(Stop { offset: norm(o), color, opacity: norm(a) }); } if stops.is_empty() { return None; } let c = bounds.center(); let v = fill.gradient_vector; - return Some(match g.kind { + return Some(match kind { GradientKind::Radial => { let (c, r) = match v { Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), Vec2::new(x1 - x0, y1 - y0).hypot()), diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b03d8dbe..080bb598 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -12,6 +12,8 @@ //! - **Colour:** CMYK as DeviceCMYK, RGB as DeviceRGB, Gray as DeviceGray, spot swatches (and their //! tints) as `/Separation` with the swatch's own values as the alternate space, `[Registration]` //! as `/Separation /All`. +//! Gradients with mixed output colour spaces are converted to RGB with an export warning; +//! homogeneous gradients keep their existing colour space (subject to PDF/A conversion). //! - **Images:** clipped to their frame; JPEG data is passed through, PNG/GIF/WebP are embedded //! losslessly (or re-encoded as JPEG with [`PdfOptions::compress_images`]). //! - **Text as real text:** the composed glyph runs are emitted with embedded, subsetted fonts and a diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/gradient_color_spaces.rs new file mode 100644 index 00000000..c523a418 --- /dev/null +++ b/crates/pdf/tests/gradient_color_spaces.rs @@ -0,0 +1,145 @@ +//! Synthetic regressions for gradients that previously aborted inside the PDF writer. +use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue}; +use designcraft_compose::Cache; +use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, build::NewDocument}; +use designcraft_geom::{Rect, shapes}; +use designcraft_pdf::{PdfOptions, Standard, export_pdf_with_report}; +use hayro_syntax::{ + Pdf, + object::{Array, Dict, Name}, +}; + +fn document(colors: &[Color], kind: GradientKind, midpoint: f32, opacity: f32) -> Document { + let mut doc = Document::new(&NewDocument { width: 120.0, height: 120.0, facing_pages: false, ..Default::default() }); + let stops = colors + .iter() + .enumerate() + .map(|(i, &color)| GradientStop { offset: i as f32 / (colors.len() - 1) as f32, color, opacity, midpoint }) + .collect(); + doc.swatches.push(Swatch { + name: "Mixed inks".into(), + value: SwatchValue::Gradient { gradient: Gradient { kind, stops } }, + locked: false, + named: true, + hidden: false, + }); + // Two uses of the same swatch must not duplicate the warning. + for x in [10.0, 65.0] { + let mut item = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 10.0, x + 40.0, 100.0))); + item.fill = Fill::swatch("Mixed inks"); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + } + doc +} + +fn conversion_warnings(warnings: &[String]) -> usize { + warnings.iter().filter(|w| w.contains("Mixed inks") && w.contains("converted to RGB")).count() +} + +/// A subprocess is essential: the original assertion panicked again in Surface::drop and aborted. +#[test] +fn mixed_gradient_export_survives_in_subprocess() { + const CHILD: &str = "DESIGNCRAFT_TEST_MIXED_GRADIENT_CHILD"; + if std::env::var_os(CHILD).is_none() { + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "mixed_gradient_export_survives_in_subprocess", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(out.status.success(), "gradient export child failed: {}\n{}", out.status, String::from_utf8_lossy(&out.stderr)); + assert!(String::from_utf8_lossy(&out.stderr).contains("gradient matrix completed"), "child must execute the regression matrix"); + return; + } + let rgb = Color::rgb(1.0, 0.0, 0.0); + let cmyk = Color::cmyk(1.0, 0.0, 0.0, 0.0); + let gray = Color::gray(0.4); + for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, cmyk], vec![gray, rgb, cmyk]] { + for kind in [GradientKind::Linear, GradientKind::Radial] { + for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { + let d = document(&colors, kind, 0.5, 0.7); + let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + assert_eq!(Pdf::new(report.bytes.clone()).unwrap().pages().len(), 1); + let archival_rgb = standard == Standard::PdfA2b && !colors.iter().any(|c| matches!(c, Color::Gray { .. })); + assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_rgb), "{standard:?}: {:?}", report.warnings); + if standard == Standard::PdfX4 { + assert!( + report.warnings.iter().any(|w| w.starts_with("PDF/X-4:") && w.contains("RGB")), + "RGB conformance warning must survive: {:?}", + report.warnings + ); + } + if standard == Standard::PdfA2b { + assert!( + report.warnings.iter().any(|w| w.contains("PDF/A") && w.contains("RGB")), + "archival policy warning must survive: {:?}", + report.warnings + ); + } + } + } + } + // Expansion inserts an RGB midpoint even when the authoring stops share CMYK or Gray. + for colors in [[cmyk, Color::cmyk(0.0, 1.0, 0.0, 0.0)], [gray, Color::gray(0.8)]] { + for kind in [GradientKind::Linear, GradientKind::Radial] { + for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { + let report = + export_pdf_with_report(&document(&colors, kind, 0.25, 1.0), &Cache::new(), &PdfOptions { standard, ..Default::default() }) + .unwrap(); + let archival_cmyk = standard == Standard::PdfA2b && matches!(colors[0], Color::Cmyk { .. }); + assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_cmyk), "{standard:?}: {:?}", report.warnings); + assert_eq!(Pdf::new(report.bytes).unwrap().pages().len(), 1); + } + } + } + eprintln!("gradient matrix completed"); +} + +fn endpoint(function: &Dict<'_>, first: bool) -> Vec { + if let Some(functions) = function.get::>(b"Functions") { + let functions: Vec<_> = functions.iter::>().collect(); + return endpoint(if first { functions.first().unwrap() } else { functions.last().unwrap() }, first); + } + function.get::>(if first { b"C0" } else { b"C1" }).unwrap().iter::().collect() +} + +#[test] +fn homogeneous_gradients_keep_device_spaces_and_components() { + for (colors, space, start, end) in [ + ([Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)], "DeviceRGB", vec![1.0, 0.0, 0.0], vec![0.0, 0.0, 1.0]), + ([Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)], "DeviceCMYK", vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]), + ([Color::gray(0.2), Color::gray(0.8)], "DeviceGray", vec![0.8], vec![0.2]), + ] { + for standard in [Standard::None, Standard::PdfX4] { + let report = export_pdf_with_report( + &document(&colors, GradientKind::Linear, 0.5, 1.0), + &Cache::new(), + &PdfOptions { standard, ..Default::default() }, + ) + .unwrap(); + assert_eq!(conversion_warnings(&report.warnings), 0); + let pdf = Pdf::new(report.bytes).unwrap(); + let patterns = &pdf.pages()[0].resources().patterns; + let mut count = 0; + for (name, _) in patterns.entries() { + let pattern = patterns.get::>(name.as_ref()).unwrap(); + let shading = pattern.get::>(b"Shading").unwrap(); + assert_eq!(shading.get::>(b"ColorSpace").unwrap().as_ref(), space.as_bytes()); + let function = shading.get::>(b"Function").unwrap(); + for (actual, expected) in [(endpoint(&function, true), &start), (endpoint(&function, false), &end)] { + assert_eq!(actual.len(), expected.len()); + assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 0.005), "{space}: {actual:?} vs {expected:?}"); + } + count += 1; + } + assert!(count > 0, "inspect actual shading resources, not unrelated blend spaces"); + } + } +} + +#[test] +fn mixed_gradient_booklet_reports_conversion() { + let d = document(&[Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::rgb(1.0, 0.0, 0.0)], GradientKind::Linear, 0.5, 1.0); + let report = designcraft_pdf::export_booklet(&d, &Cache::new(), &Default::default()).unwrap(); + assert_eq!(conversion_warnings(&report.warnings), 1); + assert!(!Pdf::new(report.bytes).unwrap().pages().is_empty()); +} From 452134c13b8de5329eb649bcda208d5d20f668ce Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 9 Oct 2026 23:50:52 -0600 Subject: [PATCH 32/71] FID2: preserve decoration color resets, geometry and paint order (#230) --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 12 +- crates/engine/tests/text_decorations.rs | 165 ++++++++++++++++++++++++ crates/idml/src/export.rs | 4 +- crates/idml/src/import.rs | 4 +- crates/idml/src/tests.rs | 2 + crates/idml/src/tests/decorations.rs | 83 ++++++++++++ crates/pdf/src/text.rs | 48 +++++-- crates/render/src/text.rs | 39 ++++-- 9 files changed, 326 insertions(+), 32 deletions(-) create mode 100644 crates/engine/tests/text_decorations.rs create mode 100644 crates/idml/src/tests/decorations.rs diff --git a/ROADMAP.md b/ROADMAP.md index c3608b9b..b678d059 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,6 +14,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope **Working today** - Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. +- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3db5070d..b67bb1ee 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -71,7 +71,7 @@ pub struct RunStyle { pub kenten_character: String, } -/// An underline or strikethrough bar: its top edge `offset` below the baseline (negative = +/// An underline or strikethrough bar: its centre `offset` below the baseline (negative = /// above), thickness and colour. #[derive(Clone, Debug, PartialEq)] pub struct Rule { @@ -81,13 +81,21 @@ pub struct Rule { pub tint: f32, } +impl Rule { + /// The stroke is centred on the offset, matching Underline / Strikethrough Options. + pub fn rect(&self, x0: f64, x1: f64, baseline: f64) -> Rect { + let center = baseline + self.offset; + Rect::new(x0, center - self.weight / 2.0, x1, center + self.weight / 2.0) + } +} + impl RunStyle { /// The bars this run draws between `x0` and `x1` on a line at `baseline`. pub fn rules(&self, x0: f64, x1: f64, baseline: f64) -> impl Iterator { [(self.underline, &self.underline_rule), (self.strikethrough, &self.strike_rule)] .into_iter() .filter(|(on, _)| *on) - .map(move |(_, r)| (r, Rect::new(x0, baseline + r.offset, x1, baseline + r.offset + r.weight))) + .map(move |(_, r)| (r, r.rect(x0, x1, baseline))) } } diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/text_decorations.rs new file mode 100644 index 00000000..cd3388f7 --- /dev/null +++ b/crates/engine/tests/text_decorations.rs @@ -0,0 +1,165 @@ +//! The same synthetic overlapping runs must paint correctly on screen and in exported PDF. +use designcraft_color::{Color, Swatch}; +use designcraft_compose::{Cache, compose_story}; +use designcraft_doc::{CharAttrs, Document, ParaFormat, SpreadRef, build::NewDocument}; +use designcraft_geom::{Affine, Rect}; +use designcraft_pdf::{PdfOptions, export_pdf}; +use designcraft_render::{Placed, RenderOptions, Rendered, Renderer}; + +fn document(decoration: u8, hide_first: bool, hide_second: bool) -> Document { + let mut d = Document::new(&NewDocument { width: 240.0, height: 240.0, facing_pages: false, ..Default::default() }); + d.swatches.push(Swatch::color("Cyan rule", Color::rgb(0.0, 1.0, 1.0))); + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(0.0, 0.0, 240.0, 180.0), + d.default_layer(), + "HI", + ParaFormat { + chars: CharAttrs { font_family: Some("Source Sans 3".into()), size: Some(72.0), fill: Some("[Black]".into()), ..Default::default() }, + ..Default::default() + }, + ) + .unwrap(); + d.story_mut(sid).unwrap().format_chars(0..1, |f| { + f.over.skew = Some(45.0); + if hide_first { + f.over.fill = Some("[None]".into()); + } + }); + d.story_mut(sid).unwrap().format_chars(1..2, |f| { + if hide_second { + f.over.fill = Some("[None]".into()); + } + f.over.underline = Some(decoration == 1 || decoration == 3); + f.over.strikethrough = Some(decoration == 2); + f.over.underline_color = Some(if decoration == 3 { "[None]" } else { "Cyan rule" }.into()); + f.over.strikethrough_color = Some("Cyan rule".into()); + f.over.underline_weight = Some(Some(18.0)); + f.over.strikethrough_weight = Some(Some(18.0)); + f.over.underline_offset = Some(Some(-24.0)); + // Strike offsets are positive above the baseline in the document model. + f.over.strikethrough_offset = Some(Some(24.0)); + }); + d +} + +fn raster(d: &Document, threads: u16) -> Rendered { + let cache = Cache::new(); + let mut renderer = Renderer::new(); + renderer.threads = threads; + let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() }; + let first = renderer.render(d, &cache, &placed, 480, 480, Affine::scale(2.0), &options); + let cached = renderer.render(d, &cache, &placed, 480, 480, Affine::scale(2.0), &options); + assert_eq!(first.pixels, cached.pixels, "decoration cache hits must retain paint order"); + first +} + +fn pdf(d: &Document) -> Rendered { + let bytes = export_pdf(d, &Cache::new(), &PdfOptions::default()).unwrap(); + let image = designcraft_render::render_pdf_page(&bytes, 0, 480).unwrap(); + assert_eq!((image.width(), image.height()), (480, 480)); + // hayro renders on transparent paper; composite onto the same white backdrop as the canvas. + let pixels = image + .data() + .iter() + .flat_map(|p| { + let white = 255 - p.a; + [p.r.saturating_add(white), p.g.saturating_add(white), p.b.saturating_add(white), 255] + }) + .collect(); + Rendered { width: 480, height: 480, pixels } +} + +fn dark(p: [u8; 4]) -> bool { + // Process black is color managed (about 52/51/52 with the bundled profile). + p[0] < 80 && p[1] < 80 && p[2] < 80 +} +fn white(p: [u8; 4]) -> bool { + p[0] > 245 && p[1] > 245 && p[2] > 245 +} +fn cyan(p: [u8; 4]) -> bool { + p[0] < 35 && p[1] > 220 && p[2] > 220 +} + +fn check_paint_order(render: impl Fn(&Document) -> Rendered) { + let plain_doc = document(0, false, false); + let plain = render(&plain_doc); + let first_only = render(&document(0, false, true)); + let second_only = render(&document(0, true, false)); + let under = render(&document(1, false, false)); + let strike = render(&document(2, false, false)); + assert_eq!(render(&document(3, false, false)).pixels, plain.pixels, "explicit None must draw no rule"); + let sid = *plain_doc.stories.keys().next().unwrap(); + let cs = compose_story(&plain_doc, sid, &Default::default()); + let line = &cs.frames[0].lines[0]; + let g = line.glyphs.iter().find(|g| g.byte == 1).unwrap(); + let rect = Rect::new(g.x, line.baseline - 24.0 - 9.0, g.x + g.adv, line.baseline - 24.0 + 9.0); + let mut cross_run = 0; + let mut exposed = 0; + for y in (rect.y0 * 2.0).ceil() as u32 + 2..(rect.y1 * 2.0).floor() as u32 - 2 { + for x in (rect.x0 * 2.0).ceil() as u32 + 2..(rect.x1 * 2.0).floor() as u32 - 2 { + // The first run's skewed H overlaps the SECOND run's rule. A per-run under/glyph + // loop would still cover these pixels, so the witness is stronger than one run. + if dark(first_only.pixel(x, y)) && white(second_only.pixel(x, y)) { + cross_run += 1; + assert!(dark(under.pixel(x, y)), "underline covered an earlier run at {x},{y}: {:?}", under.pixel(x, y)); + assert!(cyan(strike.pixel(x, y)), "strike must remain above glyphs at {x},{y}"); + } + if white(plain.pixel(x, y)) { + exposed += 1; + assert!(cyan(under.pixel(x, y)), "the visible underline must retain its fill"); + } + } + } + assert!(cross_run > 20, "fixture needs cross-run interior-ink witnesses, got {cross_run}"); + assert!(exposed > 20, "fixture needs visible rule pixels, got {exposed}"); +} + +#[test] +fn raster_underlines_are_behind_all_runs_and_strikes_remain_above() { + for threads in [0, 2] { + check_paint_order(|d| raster(d, threads)); + } +} + +#[test] +fn pdf_underlines_are_behind_all_runs_and_strikes_remain_above() { + check_paint_order(pdf); +} + +#[test] +fn decoration_offsets_are_centers_for_explicit_and_automatic_rules() { + let mut d = document(1, false, false); + let sid = *d.stories.keys().next().unwrap(); + for (automatic, size) in [(false, 72.0), (true, 28.0)] { + d.story_mut(sid).unwrap().format_chars(0..2, |f| { + f.over.size = Some(size); + f.over.underline = Some(true); + f.over.strikethrough = Some(true); + f.over.underline_color = Some(String::new()); + f.over.strikethrough_color = Some(String::new()); + f.over.fill_tint = Some(0.4); + f.over.underline_tint = Some(0.9); + f.over.strikethrough_tint = Some(0.8); + f.over.underline_weight = Some(if automatic { None } else { Some(12.0) }); + f.over.strikethrough_weight = Some(if automatic { None } else { Some(6.0) }); + f.over.underline_offset = Some(if automatic { None } else { Some(-3.0) }); + f.over.strikethrough_offset = Some(if automatic { None } else { Some(8.0) }); + }); + let cs = compose_story(&d, sid, &Default::default()); + for style in &cs.styles { + let baseline = 84.222687; + let rules: Vec<_> = style.rules(10.0, 50.0, baseline).collect(); + assert_eq!(rules.len(), 2); + let expected = if automatic { [(size * 0.12, size / 14.0), (-size * 0.3, size / 14.0)] } else { [(-3.0, 12.0), (-8.0, 6.0)] }; + for ((rule, rect), (offset, weight)) in rules.iter().zip(expected) { + assert!((rect.center().y - (baseline + offset)).abs() < 1e-9, "offset must locate the center: {rect:?}"); + assert!((rect.height() - weight).abs() < 1e-9); + assert_eq!(rule.color, style.fill); + assert_eq!(rule.tint, 0.4); + } + } + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 919991eb..62f50d0f 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1098,8 +1098,8 @@ impl<'a> Ex<'a> { if let Some(o) = o { el.set(&format!("{k}Offset"), num(o.unwrap_or(-9999.0))); } - if let Some(c) = c.as_ref().filter(|c| !c.is_empty()) { - el.set(&format!("{k}Color"), self.sw(c)); + if let Some(c) = c { + el.set(&format!("{k}Color"), if c.is_empty() { "Text Color".into() } else { self.sw(c) }); } if let Some(t) = t { el.set(&format!("{k}Tint"), num(t as f64 * 100.0)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 3e1c17e2..f6e711d5 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1095,7 +1095,9 @@ impl<'r> Importer<'r> { // Underline / strikethrough options (-9999 = automatic). for k in ["Underline", "StrikeThru"] { let num = |n: &str| e.num(&format!("{k}{n}")).filter(|v| *v > -9000.0); - let color = e.prop(&format!("{k}Color")).map(|r| self.swatch_ref(r.trim())).filter(|r| r != "Text Color" && !r.is_empty()); + // An explicit Text Color resets an inherited swatch; absence still inherits. + // Resolve the sentinel before swatch_ref, which maps unknown names to [None]. + let color = e.prop(&format!("{k}Color")).map(|r| if r.trim() == "Text Color" { String::new() } else { self.swatch_ref(r.trim()) }); let tint = e.num(&format!("{k}Tint")).filter(|v| *v >= 0.0).map(|v| (v / 100.0) as f32); if k == "Underline" { a.underline_weight = num("Weight").map(Some); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index b636a2ac..910c89a0 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -7,6 +7,8 @@ use designcraft_geom::{Rect, shapes}; use super::*; +mod decorations; + fn zip_files(files: &[(&str, &str)]) -> Vec { use zip::write::SimpleFileOptions; let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); diff --git a/crates/idml/src/tests/decorations.rs b/crates/idml/src/tests/decorations.rs new file mode 100644 index 00000000..338acffc --- /dev/null +++ b/crates/idml/src/tests/decorations.rs @@ -0,0 +1,83 @@ +use super::*; + +fn fixture(local: &str) -> Document { + let styles = r#" + + + ParagraphStyle/PBase + ParagraphStyle/PReset + + + + CharacterStyle/CBaseText ColorText Color + CharacterStyle/CReset + CharacterStyle/CBase + + "#; + let story = format!( + r#"Ink"# + ); + let map = r#""#; + let spread = r#""#; + import_idml(&zip_files(&[("designmap.xml", map), ("Resources/Styles.xml", styles), ("Stories/s.xml", &story), ("Spreads/s.xml", spread)])) + .unwrap() +} + +fn assert_style_resets(d: &Document) { + let p = d.styles.para("PReset").unwrap(); + let c = d.styles.char_style("CReset").unwrap(); + for attrs in [&p.chars, &c.chars] { + assert_eq!(attrs.underline_color.as_deref(), Some(""), "an explicit reset must remain a sparse value"); + assert_eq!(attrs.strikethrough_color.as_deref(), Some("")); + } + let (_, base) = d.styles.resolve_para_style("PBase"); + let (_, reset) = d.styles.resolve_para_style("PLeaf"); + assert_eq!(base.underline_color, "[Black]"); + assert_eq!(base.strikethrough_color, "[Black]"); + assert!(reset.underline_color.is_empty() && reset.strikethrough_color.is_empty()); + let reset = d.styles.resolve_char(&base, &designcraft_doc::CharFormat { style: "CLeaf".into(), ..Default::default() }); + assert!(reset.underline_color.is_empty() && reset.strikethrough_color.is_empty()); + let inherited = d.styles.resolve_char(&base, &designcraft_doc::CharFormat { style: "CInherit".into(), ..Default::default() }); + assert_eq!(inherited.underline_color, "[Black]"); + assert_eq!(inherited.strikethrough_color, "[Black]"); + assert!(d.styles.char_style("CInherit").unwrap().chars.underline_color.is_none()); +} + +#[test] +fn text_color_resets_paragraph_and_character_style_chains_through_roundtrip() { + let d = fixture(""); + assert_style_resets(&d); + assert_style_resets(&import_idml(&export_idml(&d)).unwrap()); +} + +#[test] +fn local_text_color_and_explicit_none_remain_distinct_through_roundtrip() { + for (local, expected) in [ + (r#"UnderlineColor="Text Color" StrikeThruColor="Text Color""#, ""), + (r#"UnderlineColor="n" StrikeThruColor="n""#, "[None]"), + (r#"UnderlineColor="" StrikeThruColor="""#, "[None]"), + ("", "[Black]"), + ] { + let imported = fixture(local); + let reopened = import_idml(&export_idml(&imported)).unwrap(); + for d in [&imported, &reopened] { + let story = d.stories.values().next().unwrap(); + let (_, para) = d.styles.resolve_para(&story.paras[0]); + let actual = d.styles.resolve_char(¶, story.char_format_at(0)); + assert_eq!(actual.underline_color, expected); + assert_eq!(actual.strikethrough_color, expected); + } + } +} + +#[test] +fn exporting_native_text_color_reset_writes_the_sentinel() { + let mut d = fixture(""); + let attrs = &mut d.styles_mut().para_mut("PReset").unwrap().chars; + attrs.underline_color = Some(String::new()); + attrs.strikethrough_color = Some(String::new()); + let attrs = &mut d.styles_mut().char_style_mut("CReset").unwrap().chars; + attrs.underline_color = Some(String::new()); + attrs.strikethrough_color = Some(String::new()); + assert_style_resets(&import_idml(&export_idml(&d)).unwrap()); +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 7fd8e365..fb2370cb 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -39,6 +39,28 @@ fn same_run(a: &PlacedGlyph, b: &PlacedGlyph) -> bool { && (a.y - b.y).abs() < 1e-6 } +/// Merge bars of one paint layer; underlines and strikes must never be merged together. +fn text_rules(cs: &ComposedStory, ft: &FrameText, underline: bool) -> Vec<(designcraft_compose::Rule, Rect)> { + let mut bars: Vec<(designcraft_compose::Rule, Rect)> = Vec::new(); + for line in &ft.lines { + for g in line.glyphs.iter().filter(|g| g.visible) { + let style = &cs.styles[g.style as usize]; + let (on, rule) = if underline { (style.underline, &style.underline_rule) } else { (style.strikethrough, &style.strike_rule) }; + if !on { + continue; + } + let rect = rule.rect(g.x, g.x + g.adv, line.baseline); + match bars.last_mut() { + Some((last_rule, last)) if last_rule == rule && (last.y0 - rect.y0).abs() < 1e-6 && (rect.x0 - last.x1).abs() < 0.5 => { + last.x1 = rect.x1; + } + _ => bars.push((rule.clone(), rect)), + } + } + } + bars +} + impl Exporter<'_> { pub(crate) fn font(&mut self, face: &FontFace) -> Option { self.fonts @@ -108,7 +130,17 @@ impl Exporter<'_> { me.tables(s, ft); } }); - let mut deco: Vec<(designcraft_compose::Rule, Rect)> = Vec::new(); + // All underlines precede all glyph runs, including ink overlapping from another run. + let underlines = text_rules(cs, ft, true); + if !underlines.is_empty() { + self.as_artifact(s, |me, s| { + for (rule, r) in underlines { + if let Some(c) = me.swatch_color(&rule.color, rule.tint) { + me.fill_rect(s, r, c); + } + } + }); + } for l in &ft.lines { let gs = &l.glyphs; let mut i = 0; @@ -135,21 +167,9 @@ impl Exporter<'_> { self.tagged_run(s, cs, &gs[i..j], l.baseline, story); i = j; } - // Underline / strikethrough, merged per style along the line. - for g in gs.iter().filter(|g| g.visible) { - let st = &cs.styles[g.style as usize]; - for (rule, r) in st.rules(g.x, g.x + g.adv, l.baseline) { - match deco.last_mut() { - Some((last_rule, last)) if *last_rule == *rule && (last.y0 - r.y0).abs() < 1e-6 && (r.x0 - last.x1).abs() < 0.5 => { - last.x1 = r.x1 - } - _ => deco.push((rule.clone(), r)), - } - } - } } self.as_artifact(s, |me, s| { - for (rule, r) in deco { + for (rule, r) in text_rules(cs, ft, false) { if let Some(c) = me.swatch_color(&rule.color, rule.tint) { me.fill_rect(s, r, c); } diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 165366eb..85468164 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -25,8 +25,9 @@ pub(crate) struct LineGlyphs { pub bounds: Option, /// Glyph outlines batched per run style. pub runs: Vec<(u32, BezPath)>, - /// Underline / strikethrough bars: colour swatch, tint, rectangle. - pub decos: Vec<(String, f32, Rect)>, + /// Bars below/above the glyph paint pass: colour swatch, tint, rectangle. + pub underlines: Vec<(String, f32, Rect)>, + pub strikes: Vec<(String, f32, Rect)>, pub glyphs: usize, } @@ -116,15 +117,18 @@ fn union(r: Option, b: Rect) -> Option { fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs { let mut runs: Vec<(u32, BezPath)> = Vec::new(); - let mut decos = Vec::new(); + let mut underlines = Vec::new(); + let mut strikes = Vec::new(); let mut glyphs = 0; for g in &l.glyphs { if !g.visible { continue; } let style = &cs.styles[g.style as usize]; - for (rule, r) in style.rules(g.x, g.x + g.adv, l.baseline) { - decos.push((rule.color.clone(), rule.tint, r)); + for (on, rule, bars) in [(style.underline, &style.underline_rule, &mut underlines), (style.strikethrough, &style.strike_rule, &mut strikes)] { + if on { + bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, l.baseline))); + } } let outline = db.outline(&g.face, g.gid); if outline.elements().is_empty() { @@ -155,10 +159,10 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line let sw = if st.stroke != designcraft_color::swatch::NONE { st.stroke_weight } else { 0.0 }; bounds = union(bounds, bp.bounding_box().inflate(sw, sw)); } - for (_, _, r) in &decos { + for (_, _, r) in underlines.iter().chain(&strikes) { bounds = union(bounds, *r); } - LineGlyphs { bounds, runs, decos, glyphs } + LineGlyphs { bounds, runs, underlines, strikes, glyphs } } fn build(cs: &ComposedStory, ft: &FrameText) -> FrameGlyphs { @@ -339,12 +343,19 @@ impl Renderer { doc.resolve_color(&st.fill, st.fill_tint).map(|c| color_of(&c, 1.0)) }) }; - for i in shown { - let lg = &fg.lines[i]; - let Some(b) = lg.bounds else { continue }; - if !rect_overlaps(b, vis) { - continue; + shown.retain(|&i| fg.lines[i].bounds.is_some_and(|b| rect_overlaps(b, vis))); + // Paint all underlines before any glyphs: skewed runs and tightly spaced lines + // can overlap a later run's rule. A per-run paint pass would still cover ink. + for &i in &shown { + for (sw, tint, r) in &fg.lines[i].underlines { + if let Some(c) = doc.resolve_color(sw, *tint) { + ctx.set_paint(color_of(&c, 1.0)); + ctx.fill_rect(r); + } } + } + for &i in &shown { + let lg = &fg.lines[i]; self.stats.glyphs += lg.glyphs; for (si, bp) in &lg.runs { if let Some(c) = fill_of(*si) { @@ -374,7 +385,9 @@ impl Renderer { ctx.stroke_path(bp); } } - for (sw, tint, r) in &lg.decos { + } + for &i in &shown { + for (sw, tint, r) in &fg.lines[i].strikes { if let Some(c) = doc.resolve_color(sw, *tint) { ctx.set_paint(color_of(&c, 1.0)); ctx.fill_rect(r); From fb68b7cc2d7f3b0257ef775108cb550d2e680103 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Fri, 9 Oct 2026 23:50:58 -0600 Subject: [PATCH 33/71] M5: rebind embedded image links when opening relocated packages (#235) (#244) Reuse packaged-link candidates for embedded IDML images and native files. Resolve missing original paths beside the opened document, retaining the embedded image bytes and any original link that still exists. Bound path lookup allocations and cover actual package relocation through commands. Make existing data-merge test path assertions portable to Windows. Co-authored-by: Codex --- crates/engine/src/cmd/datamerge/tests.rs | 5 +- crates/engine/src/cmd/file.rs | 1 + crates/engine/src/cmd/interchange.rs | 88 ++++++++++++++++++++++-- crates/engine/src/cmd/package.rs | 25 +++++++ 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 6153a1d6..64f2f5d0 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -714,7 +714,8 @@ fn relative_path_and_reopen() { std::fs::create_dir_all(&b).unwrap(); let csv = b.join("data.csv"); write(&csv, "Name\nAda\n"); - assert_eq!(relative_between(&a, &csv), "../b/data.csv"); + let relative = Path::new("..").join("b").join("data.csv").to_string_lossy().into_owned(); + assert_eq!(relative_between(&a, &csv), relative); assert_eq!(relative_between(&b, &csv), "data.csv"); let mut doc = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); @@ -743,7 +744,7 @@ fn relative_path_and_reopen() { let mut s2 = Session::new(); exec(&mut s2, "file.open", json!({"path": saved})); let src = &s2.documents()[s2.active_index().unwrap()].doc.data_merge.sources[0]; - assert_eq!(src.relative_path.as_deref(), Some("../b/data.csv")); + assert_eq!(src.relative_path.as_deref(), Some(relative.as_str())); assert_eq!(src.rows[0][0], "Ada"); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 267ea5d8..d475cafc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -164,6 +164,7 @@ fn file_open(s: &mut Session, p: &Value) -> Result { } let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let mut d = from_bytes(&bytes)?; + super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent()); super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); let (fonts, faces, warnings) = load_document_fonts(&mut d, path); let mut st = DocState::new(d, Some(path.to_string())); diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 030cf1b6..8a65ec91 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -59,13 +59,22 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result { std::sync::Arc::make_mut(asset).link = Some(path); } } + resolve_packaged_links(&mut document, dir); document }; Ok(document) } #[cfg(not(target_arch = "wasm32"))] -fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec)> { +const MAX_LINK_PATH_BYTES: usize = 128 * 1024; + +#[cfg(not(target_arch = "wasm32"))] +fn packaged_link_candidates(link: &str, dir: Option<&std::path::Path>) -> Vec { + // Enough for a maximum-length Windows path even with four-byte UTF-8 characters. + // Bound splitting/allocation for link strings supplied by imported documents. + if link.len() > MAX_LINK_PATH_BYTES { + return Vec::new(); + } let mut candidates = vec![std::path::PathBuf::from(link)]; if let Some(dir) = dir { // IDML may retain Windows paths even when the package is opened on another platform. @@ -87,7 +96,29 @@ fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(Stri candidates.push(dir.join("Links").join(name)); } } - for path in candidates { + candidates +} + +/// Embedded images do not call the IDML resource loader. Rebind their missing links +/// beside the opened document too, without replacing their embedded image bytes. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std::path::Path>) { + let Some(dir) = dir else { return }; + for asset in document.assets.values_mut() { + let Some(link) = asset.link.as_deref() else { continue }; + if link.len() > MAX_LINK_PATH_BYTES || std::path::Path::new(link).is_file() { + continue; + } + let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| p.is_file()) else { continue }; + // Preserve ordinary Windows paths rather than canonicalizing to a verbatim URI. + let path = std::path::absolute(&path).unwrap_or(path); + std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned()); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec)> { + for path in packaged_link_candidates(link, dir) { if let Ok(bytes) = std::fs::read(&path) { // Keep ordinary Windows drive/UNC syntax for IDML URI export; canonicalize // would introduce a verbatim prefix that is not an IDML file URI. @@ -132,7 +163,7 @@ mod tests { use super::*; - fn linked_idml(link: &str) -> Result<(Vec, Vec)> { + fn linked_idml(link: &str, embed_images: bool) -> Result<(Vec, Vec)> { let png = designcraft_render::Rendered { width: 2, height: 2, pixels: vec![255; 16] }.to_png(); let mut session = Session::new(); session.execute("file.new", &json!({}))?; @@ -142,7 +173,7 @@ mod tests { Arc::make_mut(asset).link = Some(link.into()); Ok(Value::Null) })?; - let bytes = designcraft_idml::export_idml_with(&session.doc()?.doc, &designcraft_idml::ExportOptions { embed_images: false }); + let bytes = designcraft_idml::export_idml_with(&session.doc()?.doc, &designcraft_idml::ExportOptions { embed_images }); Ok((bytes, png)) } @@ -151,7 +182,7 @@ mod tests { let dir = std::env::temp_dir().join(format!("dc-idml-nested-links-{}", std::process::id())); let path = dir.join("Links").join("illustrations").join("logo.png"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - let (idml, png) = linked_idml("C:\\original-computer\\project\\Links\\illustrations\\logo.png").unwrap(); + let (idml, png) = linked_idml("C:\\original-computer\\project\\Links\\illustrations\\logo.png", false).unwrap(); std::fs::write(&path, &png).unwrap(); std::fs::write(dir.join("logo.png"), b"a different file with the same basename").unwrap(); @@ -169,7 +200,7 @@ mod tests { let dir = std::env::temp_dir().join(format!("dc-idml-flat-links-{}", std::process::id())); let path = dir.join("Links").join("logo.png"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - let (idml, png) = linked_idml("/original-computer/project/logo.png").unwrap(); + let (idml, png) = linked_idml("/original-computer/project/logo.png", false).unwrap(); std::fs::write(&path, &png).unwrap(); let doc = import(&idml, Some(&dir)).unwrap(); @@ -179,4 +210,49 @@ mod tests { assert_eq!(super::super::links::status(asset), "ok"); std::fs::remove_dir_all(dir).unwrap(); } + + #[test] + fn embedded_link_resolution_preserves_originals_and_image_bytes() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-embedded-links-{}-{nonce}", std::process::id())); + let original = dir.join("original").join("logo.png"); + let package = dir.join("package"); + let copy = package.join("Links").join("logo.png"); + std::fs::create_dir_all(original.parent().unwrap()).unwrap(); + std::fs::create_dir_all(copy.parent().unwrap()).unwrap(); + let (idml, png) = linked_idml(original.to_str().unwrap(), true).unwrap(); + std::fs::write(&original, b"modified original").unwrap(); + std::fs::write(©, &png).unwrap(); + let doc = import(&idml, Some(&package)).unwrap(); + let asset = doc.assets.values().next().unwrap(); + assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "an existing original stays authoritative"); + assert_eq!(*asset.data, png); + assert_eq!(super::super::links::status(asset), "modified"); + + std::fs::remove_file(&original).unwrap(); + std::fs::write(©, b"modified packaged copy").unwrap(); + let doc = import(&idml, Some(&package)).unwrap(); + let asset = doc.assets.values().next().unwrap(); + assert_eq!(asset.link.as_deref(), copy.to_str()); + assert_eq!(*asset.data, png, "rebinding must not replace embedded pixels"); + assert_eq!(super::super::links::status(asset), "modified"); + + std::fs::remove_file(©).unwrap(); + for folder in [Some(package.as_path()), None] { + let doc = import(&idml, folder).unwrap(); + let asset = doc.assets.values().next().unwrap(); + assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "keep a missing link when there is no candidate"); + assert_eq!(*asset.data, png); + assert_eq!(super::super::links::status(asset), "missing"); + } + let oversized = "a/".repeat(MAX_LINK_PATH_BYTES); + assert!(packaged_link_candidates(&oversized, Some(&package)).is_empty()); + let mut doc = import(&idml, None).unwrap(); + Arc::make_mut(doc.assets.values_mut().next().unwrap()).link = Some(oversized.clone()); + resolve_packaged_links(&mut doc, Some(&package)); + let asset = doc.assets.values().next().unwrap(); + assert_eq!(asset.link.as_deref(), Some(oversized.as_str())); + assert_eq!(*asset.data, png); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 5cc4cc86..50556d97 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -221,6 +221,31 @@ fn copy_links(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; + #[test] + fn relocated_packages_rebind_embedded_image_links_on_open() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-relocation-{}-{nonce}", std::process::id())); + let original = dir.join("original"); + let moved = dir.join("moved"); + let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "OwnedPackage", "width": 72, "height": 72})).unwrap(); + s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "owned.png", "x": 5, "y": 5, "width": 16})).unwrap(); + s.execute("file.package", &json!({"dir": original.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); + std::fs::rename(&original, &moved).unwrap(); + let expected = std::path::absolute(moved.join("Links").join("owned.png")).unwrap(); + for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] { + let mut opened = Session::new(); + opened.execute(command, &json!({"path": moved.join(format!("OwnedPackage.{extension}")).to_string_lossy()})).unwrap(); + let asset = opened.doc().unwrap().doc.assets.values().next().unwrap(); + assert_eq!(*asset.data, png, "{extension}: preserve embedded pixels"); + assert_eq!(asset.link.as_deref(), expected.to_str(), "{extension}: use the relocated Links file"); + let links = opened.execute("links.list", &json!({})).unwrap(); + assert_eq!(links[0]["status"], "ok", "{extension}"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn package_writes_document_links_and_report() { let dir = std::env::temp_dir().join(format!("dc-package-{}", std::process::id())); From 4d16a8ce3da6614fb4a97df045ffe8404ab7973a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:51:03 -0400 Subject: [PATCH 34/71] IDML: layers import and export in stacking order; font names with a format suffix (#247) * IDML: layers import and export in stacking order * Fonts: a family named with a format suffix such as (OTF) resolves to the installed family --- crates/engine/src/cmd/fonts.rs | 35 ++++++++++++++++++++++++++ crates/fonts/src/fontdb.rs | 46 +++++++++++++++++++++++++++------- crates/fonts/src/lib.rs | 12 +++++++++ crates/idml/src/export.rs | 3 ++- crates/idml/src/import.rs | 2 ++ crates/idml/src/tests.rs | 35 ++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index ef7eb931..3b173c19 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -190,4 +190,39 @@ mod tests { assert_eq!(r["changed"], 1); assert_eq!(s.doc().unwrap().doc.styles.para("Odd").unwrap().chars.font_family.as_deref(), Some(avail.as_str())); } + + /// InDesign names a family installed in several formats `Family (OTF)`; the plain family + /// stands for it, and a family that isn't available under either name is still missing. + #[test] + fn a_family_with_a_format_suffix_uses_the_plain_family() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Some text here."})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 15})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Source Serif 4 (OTF)", "fontStyle": "Bold"})).unwrap(); + let l = s.execute("font.list", &json!({})).unwrap(); + let entry = l.as_array().unwrap().iter().find(|f| f["family"] == "Source Serif 4 (OTF)").unwrap(); + assert_eq!((&entry["missing"], &entry["styleMissing"], &entry["source"]), (&json!(false), &json!(false), &json!("bundled")), "{l}"); + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, sid, None); + let faces: Vec<_> = + cs.frames.iter().flat_map(|f| &f.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0).map(|g| g.face.get()).collect(); + assert!(!faces.is_empty() && faces.iter().all(|f| f.family == "Source Serif 4" && f.style == "Bold")); + let kinds = |s: &mut Session| { + s.execute("preflight.run", &json!({})).unwrap()["issues"] + .as_array() + .unwrap() + .iter() + .map(|i| i["kind"].as_str().unwrap().to_string()) + .collect::>() + }; + assert!(!kinds(&mut s).iter().any(|k| k == "missingFont")); + + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Nope (OTF)"})).unwrap(); + let l = s.execute("font.list", &json!({})).unwrap(); + assert_eq!((&l[0]["family"], &l[0]["missing"]), (&json!("Nope (OTF)"), &json!(true)), "{l}"); + assert!(kinds(&mut s).iter().any(|k| k == "missingFont")); + } } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 4177f941..14441e14 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -712,6 +712,15 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s }) } +/// `family` without the font-format suffix layout apps append when a family is installed in +/// several formats (`Minion Pro (OTF)` → `Minion Pro`), if it has one. +fn without_format_suffix(family: &str) -> Option<&str> { + const FORMATS: [&str; 5] = ["OTF", "TT", "TTF", "TTC", "T1"]; + let (base, suffix) = family.trim_end().strip_suffix(')')?.rsplit_once('(')?; + let base = base.trim_end(); + (!base.is_empty() && FORMATS.iter().any(|f| f.eq_ignore_ascii_case(suffix.trim()))).then_some(base) +} + /// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`). pub fn base_style(style: &str) -> &str { style.split('{').next().unwrap_or(style).trim() @@ -1218,6 +1227,14 @@ impl ScopedFonts<'_> { /// Style names available for `family` (Regular first, then by weight). pub fn styles(&self, family: &str) -> Vec { + let v = self.exact_styles(family); + match without_format_suffix(family) { + Some(base) if v.is_empty() => self.exact_styles(base), + _ => v, + } + } + + fn exact_styles(&self, family: &str) -> Vec { let mut v: Vec<(bool, f32, String)> = self .own() .iter() @@ -1237,15 +1254,10 @@ impl ScopedFonts<'_> { } /// Resolve a family + style to a face, falling back to the closest style of the family, then to - /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. + /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. A + /// family with a format suffix (`Minion Pro (OTF)`) that isn't found is looked up without it. pub fn face(&self, family: &str, style: &str) -> Arc { - if let Some(f) = self.find(family, style) { - return f; - } - #[cfg(not(target_arch = "wasm32"))] - if self.db.load_cataloged(family) - && let Some(f) = self.find(family, style) - { + if let Some(f) = self.find_or_load(family, style).or_else(|| without_format_suffix(family).and_then(|base| self.find_or_load(base, style))) { return f; } self.find(FALLBACK_FAMILY, style) @@ -1254,8 +1266,24 @@ impl ScopedFonts<'_> { .unwrap_or_else(last_resort_face) } - /// Is `family` available (the document's, loaded, or installed on the system)? + fn find_or_load(&self, family: &str, style: &str) -> Option> { + if let Some(f) = self.find(family, style) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.db.load_cataloged(family) { + return self.find(family, style); + } + None + } + + /// Is `family` available (the document's, loaded, or installed on the system), as named or + /// without a format suffix (see [`ScopedFonts::face`])? pub fn has_family(&self, family: &str) -> bool { + self.has_exact_family(family) || without_format_suffix(family).is_some_and(|base| self.has_exact_family(base)) + } + + fn has_exact_family(&self, family: &str) -> bool { if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family)) || self.db.is_loaded(family) { return true; } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 8f901e96..c1d74478 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -425,6 +425,18 @@ mod tests { assert_eq!(static_face.style, "Regular", "static fonts ignore axis settings"); } + #[test] + fn an_exact_family_name_wins_over_the_name_without_its_format_suffix() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + db.add_font(testing::font_with("DC Test Suffix (OTF)", &['a']).unwrap()); + assert_eq!(db.face("DC Test Suffix (OTF)", "Regular").family, "DC Test Suffix (OTF)"); + assert_eq!(db.face(&format!("{DEFAULT_FAMILY} (TT)"), "Regular").family, DEFAULT_FAMILY); + assert!(db.has_family(&format!("{DEFAULT_FAMILY} (T1)"))); + assert!(!db.has_family(&format!("{DEFAULT_FAMILY} (Bold)"))); + assert!(!db.has_family("DC Test Suffix")); + } + #[test] fn outlines_and_metrics() { let db = FontDb::global(); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 62f50d0f..09fa22f3 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -346,7 +346,8 @@ impl<'a> Ex<'a> { let n = format!("TV {name}"); root.push(El::new("TextVariable").attr("Self", format!("dTextVariablen{n}")).attr("Name", &n).attr("VariableType", ty)); } - for l in &d.layers { + // IDML lists layers back to front. + for l in d.layers.iter().rev() { let color = match names::layer_color_out(l.color) { Some(n) => p("LayerColor", "enumeration", n), None => { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index f6e711d5..a958e659 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -356,6 +356,8 @@ impl<'r> Importer<'r> { self.layer_ids.insert(s.to_string(), id); } } + // IDML lists layers back to front; `Document::layers` is front to back. + self.layers.reverse(); if self.layers.is_empty() { let id = LayerId(self.alloc()); self.layers.push(Layer { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 910c89a0..e6308e89 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -859,3 +859,38 @@ fn arabic_story_and_table_directions_survive_idml_export() { assert_eq!(st.direction, designcraft_doc::TextDirection::RightToLeft); assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft); } + +/// InDesign's designmap lists layers back to front; `Document::layers[0]` is the front layer. +#[test] +fn layers_import_and_export_in_stacking_order() { + let designmap = r#" + + + + "#; + let spread = r#" + + + + + + + + + + "#; + let d = import_idml(&zip_files(&[("designmap.xml", designmap), ("Spreads/Spread_s.xml", spread)])).unwrap(); + let names = |d: &Document| d.layers.iter().map(|l| l.name.clone()).collect::>(); + assert_eq!(names(&d), ["Content", "Background"]); + assert_eq!(d.spreads[0].items[0].layer, d.layers[1].id, "the rectangle stays on Background"); + + let bytes = export_idml(&d); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); + let mut map = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("designmap.xml").unwrap(), &mut map).unwrap(); + let (bg, content) = (map.find(r#"Name="Background""#).unwrap(), map.find(r#"Name="Content""#).unwrap()); + assert!(bg < content, "designmap lists the back layer first"); + let back = import_idml(&bytes).unwrap(); + assert_eq!(names(&back), ["Content", "Background"]); + assert_eq!(back.spreads[0].items[0].layer, back.layers[1].id); +} From a42fa206774a704cd9420aaa6218fcd21c66f3fc Mon Sep 17 00:00:00 2001 From: Sawizzle <156952830+sawizzle@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:51:07 -0700 Subject: [PATCH 35/71] docs: register the MCP server from an installed release (#269) * docs: register the MCP server from an installed release Co-Authored-By: Claude Opus 5.5 * docs: drop the third-party link; the Windows path is the default install folder Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/mcp.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/mcp.md b/docs/mcp.md index f53c5676..772f8a91 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -24,6 +24,23 @@ Other MCP clients use the same command line, e.g. in a JSON config: {"mcpServers": {"designcraft": {"command": "/path/to/designcraft-cli", "args": ["mcp"]}}} ``` +### From an installed release + +The release packages ship `designcraft-cli` alongside the desktop app, so no build is needed: + +| Install | CLI | +|---|---| +| Windows (MSI) | `C:\Program Files\DesignCraft\designcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | +| Linux (deb, rpm) | `/usr/bin/designcraft-cli` | +| macOS | the separate `designcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | + +```sh +# Windows, default install folder +claude mcp add designcraft -- "C:\Program Files\DesignCraft\designcraft-cli.exe" mcp +# Linux, or macOS with the CLI unzipped onto PATH +claude mcp add designcraft -- designcraft-cli mcp +``` + ## Modes | | Headless (`mcp`) | Connected (`mcp --connect PORT` or `HOST:PORT`) | From c984624956ae03eb6a3bb45a6bf48277959bd919 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 10 Oct 2026 07:51:12 +0200 Subject: [PATCH 36/71] Make menu entries selectable across the full row (#249) Co-authored-by: moppeeer <339764117+moppeeer@users.noreply.github.com> --- crates/ui-egui/src/menus.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 793276b6..c372f1ef 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1804,7 +1804,7 @@ pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) { if crate::i18n::is_rtl(&lang) { ui.set_max_width(320.0); } - ui.with_layout(egui::Layout::top_down(if crate::i18n::is_rtl(&lang) { egui::Align::Max } else { egui::Align::Min }), |ui| { + ui.with_layout(egui::Layout::top_down_justified(if crate::i18n::is_rtl(&lang) { egui::Align::Max } else { egui::Align::Min }), |ui| { ui.set_min_width(240.0); let hidden = menu_items(app, ui, &entries, menu); if hidden > 0 && !app.ui.show_full_menus { From 3de58750cd0aa0d766470a4b6c46d4afa15b4a1e Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:51:17 +1100 Subject: [PATCH 37/71] Add PDF/X-4 validation command (#200) --- apps/designcraft-cli/src/main.rs | 12 ++++++++++++ crates/pdf/src/lib.rs | 8 +++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 11514f70..b1819c35 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -12,6 +12,7 @@ //! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md) //! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document //! designcraft-cli bench FILE [--runs N] # the same measurements on one document +//! designcraft-cli validate-pdf FILE.pdf # run the built-in PDF/X-4 conformance checks //! designcraft-cli links # Discord, website, app page and GitHub links //! designcraft-cli --version # print the version //! ``` @@ -84,6 +85,7 @@ fn main() -> ExitCode { Some("mcp") => report(mcp(&args[1..])), Some("perf") => report(perf::perf(&args[1..])), Some("bench") => report(perf::bench(&args[1..])), + Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))), Some("links") => { use designcraft_engine::links::*; outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}"); @@ -114,6 +116,16 @@ fn report(r: Result<(), String>) -> ExitCode { } } +fn validate_pdf(path: Option<&str>) -> Result<(), String> { + let path = path.ok_or("usage: designcraft-cli validate-pdf FILE.pdf")?; + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let issues = designcraft_pdf::check_pdfx4(&bytes); + let valid = issues.is_empty(); + let report = json!({"path": path, "standard": "PDF/X-4", "valid": valid, "issues": issues}); + outln!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?); + if valid { Ok(()) } else { Err(format!("{path} failed PDF/X-4 validation")) } +} + /// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app /// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr. fn mcp(args: &[String]) -> Result<(), String> { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 080bb598..d2fbba7b 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -20,9 +20,8 @@ //! Unicode mapping taken from the story text (ligatures, page numbers and inserted hyphens get the //! right characters), so text is selectable and searchable. //! - **Marks:** crop marks, bleed marks and a page-information line, drawn in `[Registration]`. -//! - **Standards:** PDF/A-2b through krilla's validator. PDF/X-4 writes PDF 1.6 with trim/bleed -//! boxes, but the PDF/X output intent and identification are not written yet (krilla 0.8 has no -//! PDF/X support) — the export report says so. +//! - **Standards:** PDF/A-2b through krilla's validator and PDF/X-4 output intent/identification +//! with built-in conformance checks. `designcraft-cli validate-pdf` exposes those checks for CI. //! //! Not yet: tagged PDF, bookmarks, hyperlinks, overprint, layers as optional content. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -46,8 +45,7 @@ pub enum Standard { /// Plain PDF 1.7. #[default] None, - /// PDF/X-4 (PDF 1.6, trim/bleed boxes). TODO: OutputIntent + GTS_PDFXVersion once krilla - /// supports PDF/X. + /// PDF/X-4 (PDF 1.6, trim/bleed boxes, output intent and identification). PdfX4, /// PDF/A-2b (archival; validated by krilla). PdfA2b, From b744953d70f795c2ae48de497d17cf0d23ed9460 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:51:21 -0400 Subject: [PATCH 38/71] Span columns: text after a spanning paragraph flows below it (#276) A spanning paragraph closes the band of columns above it. Those columns are balanced first and the paragraph sits below the deepest of them; the columns after it start below the paragraph, so text that moves to the next column no longer restarts at the frame top over the heading. A spanning paragraph that doesn't fit moves to the next frame. Span counts below the column count set the paragraph across the first columns only. --- crates/compose/src/lib.rs | 318 ++++++++++++++++++++++++++++++++---- crates/compose/src/table.rs | 2 +- crates/compose/src/tests.rs | 156 ++++++++++++++++++ 3 files changed, 439 insertions(+), 37 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index b67bb1ee..5283487a 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -24,6 +24,7 @@ pub mod xref; use std::collections::HashMap; use std::ops::Range; +use std::rc::Rc; use std::sync::{Arc, Mutex}; use designcraft_doc::{ @@ -470,7 +471,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .zip(&cols) .map(|(f, columns)| FrameText { frame: f.id, vertical: f.vertical, columns: columns.clone(), ..Default::default() }) .collect(); - let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, last_reference: 0.0, pending: 0.0 }; + let mut cur = Cursor { + fi: 0, + col: 0, + last_baseline: None, + last_descent: 0.0, + last_reference: 0.0, + pending: 0.0, + pi: 0, + band: Band::default(), + limits: Rc::default(), + }; let para_ranges = story.para_ranges(); let np = para_ranges.len(); let hyph_exceptions = doc.hyphenation_exception_map(); @@ -504,14 +515,33 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut notes = Notes::new(doc, story); let mut note_snaps: Vec<(u32, Option, usize)> = Vec::with_capacity(np); let mut var_cache: std::collections::HashMap, std::sync::Arc>> = Default::default(); + // Column balancing above spanning paragraphs: settled and trial bottoms by band, the search + // in progress, and a budget for the re-lays. + let mut limits: Rc> = Rc::default(); + let mut trial: Option = None; + let mut balance_runs = 0usize; + let balance_budget = 20 * story.paras.iter().filter(|p| matches!(doc.styles.resolve_para(p).0.span_columns, SpanColumns::Span(_))).count(); + // Paragraphs set across columns (vertical justification moves their frames' lines together). + let mut span_paras = vec![false; np]; 'paras: while pi < np { let prange = para_ranges[pi].clone(); + cur.pi = pi; + cur.limits = Rc::clone(&limits); let snap = Snapshot::take(&out, &cur, list_counter); snaps.truncate(pi); snaps.push(snap); note_snaps.truncate(pi); note_snaps.push((notes.num, notes.key, notes.placed.len())); info.truncate(pi); + // A balancing trial whose band ran past its frame: re-lay the band with more room. + if trial.as_ref().is_some_and(|t| cur.fi > t.key.0) + && let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) + { + balance_runs += 1; + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = j; + continue 'paras; + } let pf = &story.paras[pi]; let (pp, base_chars) = doc.styles.resolve_para(pf); if let Some(t) = story.para_table(pi) { @@ -528,6 +558,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let start_at = (cur.fi, cur.col); let anchor = prange.start + story.text[prange.clone()].find(story::TABLE_ANCHOR).unwrap_or(0); if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) { + if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { + balance_runs += 1; + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = j; + continue 'paras; + } out.overset_at = Some(prange.start); break 'paras; } @@ -611,7 +647,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // Nested line styles: find where the first lines end in this column, restyle them, and // look again (the style changes the widths) until the lines settle. let cols_here = &cols[cur.fi]; - let col = cols_here[cur.col.min(cols_here.len() - 1)]; + let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns); let spacing = spacing_for(&pp, base_chars.size); let mut lines: Vec<(std::ops::Range, String)> = Vec::new(); for _ in 0..3 { @@ -702,17 +738,63 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C designcraft_doc::Leading::Points(v) => v, }; let spacing = spacing_for(&pp, base_size); + // A paragraph set across the columns moves on to the next frame where others move on to + // the next column. + let spanning = matches!(pp.span_columns, SpanColumns::Span(n) if n != 1); + span_paras[pi] = spanning; + let next_column = |cur: &mut Cursor| if spanning { cur.next_frame() } else { cur.next_column(&cols) }; // Paragraph start options. if force_col[pi] { - cur.next_column(&cols); + next_column(&mut cur); } if cur.last_baseline.is_some() { match pp.start_paragraph { - StartParagraph::NextColumn => cur.next_column(&cols), + StartParagraph::NextColumn => next_column(&mut cur), StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => cur.next_frame(), StartParagraph::Anywhere => {} } } + if spanning && cur.fi < frames.len() { + // Close the band: the paragraph goes below the deepest of its columns, which are + // balanced first. + let key = cur.band_key(); + let deepest = out.frames[cur.fi] + .lines + .get(cur.band.line0..) + .unwrap_or_default() + .iter() + .map(|l| (l.baseline, l.descent)) + .max_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1))); + if let Some((baseline, descent)) = deepest { + let bottom = baseline + descent; + let multi = cols[cur.fi].len() > 1; + let active = trial.as_ref().is_some_and(|t| t.key == key && t.span == pi); + if !active && let Some(t) = trial.take() { + set_limit(&mut limits, t.key, None); + } + if multi && (active || limit_of(&limits, key).is_none() && balance_runs < balance_budget) { + let top = cur.clip(cols[cur.fi][0]).y0; + let mut t = trial.take().unwrap_or(Trial { key, span: pi, lo: top, hi: bottom, tries: 0, done: false }); + // This layout fits: its bottom is the best so far. + t.hi = t.hi.min(bottom); + let next = if t.done { t.hi } else { t.next() }; + set_limit(&mut limits, key, Some(next)); + if !t.done { + trial = Some(t); + balance_runs += 1; + rewind(key.1, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = key.1; + continue 'paras; + } + } + cur.col = 0; + cur.last_baseline = Some(baseline); + cur.last_descent = descent; + } else if cur.col != 0 { + cur.col = 0; + cur.resume(); + } + } if cur.last_baseline.is_some() { cur.pending += pp.space_before; } @@ -727,21 +809,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span loop { if cur.fi >= frames.len() { + if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { + balance_runs += 1; + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = j; + continue 'paras; + } out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start)); // The part of the paragraph that fits keeps its shading and border. para_box_decos(&mut out, pi, &pp); break 'paras; } let f = &frames[cur.fi]; - let col = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; - let col = match pp.span_columns { - SpanColumns::Span(n) if cur.col == 0 => { - let n = if n == 0 { cols[cur.fi].len() } else { (n as usize).min(cols[cur.fi].len()) }; - let other = cols[cur.fi][n - 1]; - Rect::new(col.x0.min(other.x0), col.y0, col.x1.max(other.x1), col.y1) - } - _ => col, - }; + let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; + let col = cur.clip(base); + // A spanning paragraph isn't held to the balanced bottom of the columns above it. + let col = if spanning { span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns) } else { col }; // Estimate slots for the breaker with the paragraph's base leading. let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp); let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no); @@ -817,7 +900,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C remaining: breaks.len() - k, here: (cur.fi, cur.col), }; - let action = if restores < 4 * np + 64 { keep_violation(&ctx, &pp, &info, &force_col, &line_cap) } else { None }; + let action = if restores < (4 * np + 64) * (balance_runs + 1) { + keep_violation(&ctx, &pp, &info, &force_col, &line_cap) + } else { + None + }; if let Some(action) = action { restores += 1; let j = match action { @@ -830,20 +917,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C j } }; - for k in j + 1..np { - force_col[k] = false; - line_cap[k] = None; - } - snaps[j].restore(&mut out, &mut cur, &mut list_counter); - (notes.num, notes.key) = (note_snaps[j].0, note_snaps[j].1); - notes.placed.truncate(note_snaps[j].2); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); pi = j; continue 'paras; } } // Next column / frame; re-break the rest of the paragraph there. g0 = s; - cur.next_column(&cols); + next_column(&mut cur); col_first_line = line_no; moved = true; break; @@ -905,7 +986,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let brk = glyphs.get(g0 + b.next.saturating_sub(1)).map(|g| g.ch); let jumped = match brk { Some(story::COLUMN_BREAK) => { - cur.next_column(&cols); + next_column(&mut cur); true } Some(story::FRAME_BREAK) | Some(story::PAGE_BREAK) => { @@ -941,6 +1022,20 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C keep_first: pp.keep_first as usize, keep_last: pp.keep_last as usize, }); + // The columns below a spanning paragraph start under it. + if spanning + && let Some(ft) = out.frames.get(cur.fi) + && let Some(l) = ft.lines.last().filter(|l| l.para == pi) + { + let above = Above { + top: l.baseline + l.descent, + baseline: l.baseline, + descent: l.descent, + reference: cur.last_reference, + pending: pp.space_after, + }; + cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above) }; + } // Rules and shading for the paragraph. if let Some((fi, bl, asc, _)) = first_line_rect { let ft = &mut out.frames[fi]; @@ -980,7 +1075,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C ft.range = p..p; } let before: Vec = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect(); - vertical_justify(ft, f); + let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)); + vertical_justify(ft, f, spanned); for (t, b) in ft.tables.iter_mut().zip(before) { let now = ft.lines.get(t.line).map_or(b, |l| l.baseline); t.shift(now - b); @@ -1049,6 +1145,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C out } +/// The columns a spanning paragraph is set across, from `col`'s top to its bottom. +fn span_rect(cols: &[Rect], col: Rect, span: SpanColumns) -> Rect { + let SpanColumns::Span(n) = span else { return col }; + let n = if n == 0 { cols.len() } else { (n as usize).min(cols.len()) }; + let (Some(a), Some(b)) = (cols.first(), n.checked_sub(1).and_then(|i| cols.get(i))) else { return col }; + Rect::new(a.x0.min(b.x0), col.y0, a.x1.max(b.x1), col.y1) +} + /// Flag the lines of paragraphs whose Keep Options the layout couldn't honour: lines kept /// together that split, too few lines at the bottom (orphans) or top (widows) of a column, or a /// keep-with-next paragraph separated from the next one. @@ -1244,6 +1348,31 @@ impl Snapshot { } } +/// Re-lay from paragraph `j`: back to its snapshot and footnote numbering, with fresh keep +/// decisions for the paragraphs after it. +#[allow(clippy::too_many_arguments)] +fn rewind( + j: usize, + snaps: &[Snapshot], + note_snaps: &[(u32, Option, usize)], + notes: &mut Notes, + out: &mut ComposedStory, + cur: &mut Cursor, + list_counter: &mut u32, + force_col: &mut [bool], + line_cap: &mut [Option], +) { + force_col.iter_mut().skip(j + 1).for_each(|f| *f = false); + line_cap.iter_mut().skip(j + 1).for_each(|c| *c = None); + if let Some(s) = snaps.get(j) { + s.restore(out, cur, list_counter); + } + if let Some(&(num, key, placed)) = note_snaps.get(j) { + (notes.num, notes.key) = (num, key); + notes.placed.truncate(placed); + } +} + /// Where a placed paragraph sits (for keep resolution). #[derive(Clone, Debug)] struct ParaInfo { @@ -1364,23 +1493,136 @@ struct Cursor { last_reference: f64, /// Space before/after waiting to be added to the next line. pending: f64, + /// The paragraph being laid. + pi: usize, + /// The band of columns the cursor is in. + band: Band, + /// Column bottoms that balance bands, by band key (kept by the compose loop). + limits: Rc>, +} + +/// A band's frame and first paragraph. +type BandKey = (usize, usize); + +/// A run of the frame's columns: the whole frame, or the part between spanning paragraphs. +/// Text fills its columns one after the other; a spanning paragraph closes it. +#[derive(Clone, Copy, Debug, Default)] +struct Band { + /// First paragraph laid (at least in part) in the band: balancing re-lays from there. + para: usize, + /// Lines of the frame above the band. + line0: usize, + /// Below a spanning paragraph, its last line: every column of the band continues from it. + above: Option, +} + +#[derive(Clone, Copy, Debug)] +struct Above { + /// Bottom of the spanning paragraph's last line (the band's top). + top: f64, + baseline: f64, + descent: f64, + reference: f64, + /// The spanning paragraph's space after. + pending: f64, +} + +/// Balancing the columns above a spanning paragraph: a search for the lowest column bottom at +/// which the band's text still fits its frame, re-laying the band at each trial bottom. +#[derive(Debug)] +struct Trial { + key: BandKey, + /// The spanning paragraph that closes the band. + span: usize, + /// Highest bottom known not to fit / lowest known to fit. + lo: f64, + hi: f64, + tries: u32, + /// The band is laid at `hi` for good. + done: bool, +} + +impl Trial { + /// The next bottom to try: half way, or `hi` once settled. + fn next(&mut self) -> f64 { + self.tries += 1; + if self.hi - self.lo > 0.5 && self.tries < 16 { + (self.lo + self.hi) / 2.0 + } else { + self.done = true; + self.hi + } + } +} + +/// A balancing trial whose band didn't fit its frame: records the next bottom to try in +/// `limits` (or, when even the settled bottom fails or the budget is spent, leaves the band +/// unbalanced) and returns the paragraph to re-lay from. +fn trial_failed(trial: &mut Option, limits: &mut Rc>, budget_left: bool) -> Option { + let t = trial.as_mut()?; + let key = t.key; + let bottom = if t.done || !budget_left { + *trial = None; + f64::INFINITY + } else { + t.lo = limit_of(limits, key).unwrap_or(t.lo); + t.next() + }; + set_limit(limits, key, Some(bottom)); + Some(key.1) +} + +fn set_limit(limits: &mut Rc>, key: BandKey, bottom: Option) { + let v = Rc::make_mut(limits); + v.retain(|(k, _)| *k != key); + if let Some(b) = bottom { + v.push((key, b)); + } +} + +fn limit_of(limits: &[(BandKey, f64)], key: BandKey) -> Option { + limits.iter().find(|(k, _)| *k == key).map(|&(_, b)| b) } impl Cursor { fn next_column(&mut self, cols: &[Vec]) { self.col += 1; if self.fi < cols.len() && self.col >= cols[self.fi].len() { - self.col = 0; - self.fi += 1; + self.next_frame(); + } else { + self.resume(); } - self.last_baseline = None; - self.pending = 0.0; } fn next_frame(&mut self) { self.fi += 1; self.col = 0; - self.last_baseline = None; - self.pending = 0.0; + self.band = Band { para: self.pi, ..Band::default() }; + self.resume(); + } + /// Start the current column at the top of the band. + fn resume(&mut self) { + match self.band.above { + Some(a) => { + self.last_baseline = Some(a.baseline); + self.last_descent = a.descent; + self.last_reference = a.reference; + self.pending = a.pending; + } + None => { + self.last_baseline = None; + self.pending = 0.0; + } + } + } + fn band_key(&self) -> BandKey { + (self.fi, self.band.para) + } + /// Column `col` of the current frame limited to the band: below the spanning paragraph above + /// it and, while balancing, above the trial bottom. + fn clip(&self, col: Rect) -> Rect { + let y0 = self.band.above.map_or(col.y0, |a| a.top.max(col.y0).min(col.y1.max(col.y0))); + let y1 = limit_of(&self.limits, self.band_key()).map_or(col.y1, |b| b.min(col.y1).max(y0)); + Rect::new(col.x0, y0, col.x1, y1) } /// Baseline for the next line with leading `lead` and ascent `asc`. fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 { @@ -2122,16 +2364,20 @@ fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions: out } -fn vertical_justify(ft: &mut FrameText, f: &FrameSpec) { +/// `spanned`: a paragraph spans the frame's columns, so the lines move together. +fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { return; } - // Per column. - let ncols = ft.columns.len().max(1) as u32; + if spanned && f.opts.vertical_justification == VerticalJustification::Justify { + return; + } + // Per column (or all lines together). + let ncols = if spanned { 1 } else { ft.columns.len().max(1) as u32 }; for c in 0..ncols { - let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| l.column == c).map(|(i, _)| i).collect(); - let (Some(&a), Some(&z)) = (idx.first(), idx.last()) else { continue }; - let bottom = ft.lines[z].baseline + ft.lines[z].descent; + let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect(); + let (Some(&a), Some(_)) = (idx.first(), idx.last()) else { continue }; + let bottom = idx.iter().map(|&i| ft.lines[i].baseline + ft.lines[i].descent).fold(f64::NEG_INFINITY, f64::max); let space = f.area.y1 - bottom; if space <= 0.0 { continue; diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 529c9b77..f1632992 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -124,7 +124,7 @@ pub(crate) fn place_table( if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 { return false; } - let col_rect = |cur: &Cursor| cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; + let col_rect = |cur: &Cursor| cur.clip(cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]); let mut col = col_rect(cur); // Column widths: scaled to fit the measure. let avail = (col.width() - pp.left_indent - pp.right_indent).max(1.0); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 256d9a13..19a446f7 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1889,3 +1889,159 @@ fn vertical_lines_fit_the_em_box() { assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0); } } + +// ---------- span columns ---------- + +/// A story of `before` body paragraphs, a heading spanning all columns, and `after` body +/// paragraphs, in a three-column frame. Returns the heading's paragraph index. +fn span_doc(before: usize, after: usize, rect: Rect) -> (Document, StoryId, ItemId, usize) { + let mut paras: Vec<&str> = vec![LOREM; before]; + paras.push("A heading across the columns"); + paras.extend(vec![LOREM; after]); + let (mut d, sid, fid) = doc_with(¶s.join("\n"), rect, ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 3; + d.story_mut(sid).unwrap().paras[before].para.span_columns = Some(SpanColumns::Span(0)); + (d, sid, fid, before) +} + +/// The line's leading slot (glyph ascent and descent may reach into the neighbouring lines). +fn line_box(l: &Line) -> (f64, f64) { + (l.baseline - 0.75 * l.leading, l.baseline + 0.25 * l.leading) +} + +/// No two lines of a frame share any area. +fn assert_no_overlap(ft: &FrameText) { + for (i, a) in ft.lines.iter().enumerate() { + for b in &ft.lines[i + 1..] { + let ((at, ab), (bt, bb)) = (line_box(a), line_box(b)); + let x_overlap = a.x0 < b.x1 - 0.5 && b.x0 < a.x1 - 0.5; + let y_overlap = at < bb - 0.5 && bt < ab - 0.5; + assert!( + !(x_overlap && y_overlap), + "lines overlap: para {} col {} y {:.1}..{:.1} and para {} col {} y {:.1}..{:.1}", + a.para, + a.column, + at, + ab, + b.para, + b.column, + bt, + bb + ); + } + } +} + +/// The heading spans the frame; text before it sits above it and text after it below it. +fn assert_span_layout(ft: &FrameText, h: usize) { + let heading: Vec<&Line> = ft.lines.iter().filter(|l| l.para == h).collect(); + assert!(!heading.is_empty()); + let (c0, cn) = (ft.columns[0], ft.columns[ft.columns.len() - 1]); + for l in &heading { + assert!((l.x0 - c0.x0.min(cn.x0)).abs() < 1e-6 && (l.x1 - c0.x1.max(cn.x1)).abs() < 1e-6, "heading spans {}..{}", l.x0, l.x1); + } + let top = heading.iter().map(|l| line_box(l).0).fold(f64::INFINITY, f64::min); + let bottom = heading.iter().map(|l| line_box(l).1).fold(f64::NEG_INFINITY, f64::max); + for l in ft.lines.iter().filter(|l| l.para < h) { + assert!(line_box(l).1 <= top + 0.5, "para {} col {} ends at {} below the heading top {top}", l.para, l.column, line_box(l).1); + } + for l in ft.lines.iter().filter(|l| l.para > h) { + assert!(line_box(l).0 >= bottom - 0.5, "para {} col {} starts at {} above the heading bottom {bottom}", l.para, l.column, line_box(l).0); + } + assert_no_overlap(ft); +} + +#[test] +fn text_after_a_spanning_paragraph_flows_below_it() { + let (d, sid, _, h) = span_doc(2, 3, Rect::new(0.0, 0.0, 540.0, 300.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let ft = &cs.frames[0]; + assert_span_layout(ft, h); + // The text before is balanced across the three columns above the heading. + let before: Vec<&Line> = ft.lines.iter().filter(|l| l.para < h).collect(); + let per_col = |c: u32| before.iter().filter(|l| l.column == c).count(); + let counts = [per_col(0), per_col(1), per_col(2)]; + assert!(counts.iter().all(|&n| n > 0), "{counts:?}"); + assert!(counts.iter().max().unwrap() - counts.iter().min().unwrap() <= 1, "{counts:?}"); + // The text after starts right below the heading in every column, on the same baseline. + let bottom = ft.lines.iter().filter(|l| l.para == h).map(|l| l.baseline).fold(f64::NEG_INFINITY, f64::max); + let firsts: Vec = (0..3).map(|c| ft.lines.iter().find(|l| l.para > h && l.column == c).map(|l| l.baseline).unwrap()).collect(); + for b in &firsts { + assert!(*b > bottom && *b < bottom + 40.0, "{firsts:?} after {bottom}"); + } + assert!((firsts[1] - firsts[2]).abs() < 1e-6, "{firsts:?}"); +} + +#[test] +fn spanning_heading_kept_with_next_and_spanning_two_of_three_columns() { + let (mut d, sid, _, h) = span_doc(2, 3, Rect::new(0.0, 0.0, 540.0, 300.0)); + d.story_mut(sid).unwrap().paras[h].para.keep_with_next = Some(2); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert_span_layout(&cs.frames[0], h); + // Spanning two of three columns: the third column beside the heading stays empty. + d.story_mut(sid).unwrap().paras[h].para.span_columns = Some(SpanColumns::Span(2)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ft = &cs.frames[0]; + let heading = ft.lines.iter().find(|l| l.para == h).unwrap(); + assert!((heading.x0 - ft.columns[0].x0).abs() < 1e-6 && (heading.x1 - ft.columns[1].x1).abs() < 1e-6); + assert_no_overlap(ft); + let bottom = heading.baseline + 0.25 * heading.leading; + assert!(ft.lines.iter().filter(|l| l.para > h).all(|l| line_box(l).0 >= bottom - 0.5)); + assert!((0..3).all(|c| ft.lines.iter().any(|l| l.para > h && l.column == c))); +} + +#[test] +fn spanning_paragraph_at_the_top_and_end_of_a_frame() { + let (d, sid, _, h) = span_doc(0, 4, Rect::new(0.0, 0.0, 540.0, 300.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let ft = &cs.frames[0]; + assert_span_layout(ft, h); + assert!(ft.lines[0].para == h && ft.lines[0].baseline < 20.0); + assert!((1..3).all(|c| ft.lines.iter().any(|l| l.column == c))); + + let (d, sid, _, h) = span_doc(3, 0, Rect::new(0.0, 0.0, 540.0, 300.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert_span_layout(&cs.frames[0], h); +} + +#[test] +fn text_after_a_spanning_paragraph_threads_into_the_next_frame() { + let (mut d, sid, f1, h) = span_doc(2, 8, Rect::new(0.0, 0.0, 540.0, 200.0)); + let lid = d.default_layer(); + let (f2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 300.0, 540.0, 700.0), lid, "", ParaFormat::default()).unwrap(); + d.item_mut(f2).unwrap().text_frame_mut().unwrap().options.columns = 3; + d.thread(f1, f2).unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset(), "overset at {:?}", cs.overset_at); + assert_span_layout(&cs.frames[0], h); + let (a, b) = (&cs.frames[0], &cs.frames[1]); + assert!((0..3).all(|c| a.lines.iter().any(|l| l.para > h && l.column == c))); + assert!(!b.lines.is_empty() && b.lines[0].para > h && b.lines[0].baseline < 320.0); + assert_no_overlap(b); + // Every line of the story is set once, in order. + let lines = all_lines(&cs); + for w in lines.windows(2) { + assert!(w[1].range.start >= w[0].range.end, "{:?} then {:?}", w[0].range, w[1].range); + } + assert_eq!(lines.last().unwrap().range.end, d.story(sid).unwrap().text.len()); +} + +#[test] +fn spanning_paragraph_in_rtl_and_vertical_frames() { + let (mut d, sid, _, h) = span_doc(2, 4, Rect::new(0.0, 0.0, 540.0, 500.0)); + d.story_mut(sid).unwrap().direction = designcraft_doc::TextDirection::RightToLeft; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert_span_layout(&cs.frames[0], h); + + let (mut d, sid, _, h) = span_doc(2, 4, Rect::new(0.0, 0.0, 540.0, 540.0)); + d.story_mut(sid).unwrap().vertical = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.frames[0].lines.is_empty()); + assert_no_overlap(&cs.frames[0]); + assert!(cs.frames[0].lines.iter().any(|l| l.para == h)); +} From e6b5a52a8553fc46b321351581995f7bb6a01a62 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:51:26 -0400 Subject: [PATCH 39/71] Text: the line before a page break is a paragraph's last line (#283) A line ended by a column, frame or page break takes the paragraph's last-line alignment, so a Left Justify paragraph sets it flush left. A line ended by a forced line break stays justified. The break that moves the following text to the next column or frame is the line's last glyph, so a space after the break no longer keeps the text in the same column. --- crates/compose/src/lib.rs | 13 +++++++++++-- crates/compose/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5283487a..8f34d117 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -933,8 +933,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let lx0 = x0 + ind_l; let lx1 = x1 - pp.right_indent; let last = k + 1 == breaks.len(); + // The break character that ends this line, if any (it is the line's last glyph). + let brk = if b.forced && e > s { + e.checked_sub(1).and_then(|i| glyphs.get(i)).map(|g| g.ch).filter(|&c| breaker::is_forced(c)) + } else { + None + }; + // A column, frame or page break ends the line as a paragraph's last line (last-line + // alignment); a justified line ended by a forced line break stays justified. + let ends_para = last || matches!(brk, Some(story::COLUMN_BREAK | story::FRAME_BREAK | story::PAGE_BREAK)); + let forced_mid = brk == Some(story::FORCED_LINE_BREAK) && !last; let (mut placed, end_x, ratio) = - layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page, &bidi_info); + layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, ends_para, forced_mid, f.left_page, &bidi_info); ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback); let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) }; let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end); @@ -983,7 +993,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C line_no += 1; // Column / frame / page break characters. if b.forced && e < glyphs.len() + 1 { - let brk = glyphs.get(g0 + b.next.saturating_sub(1)).map(|g| g.ch); let jumped = match brk { Some(story::COLUMN_BREAK) => { next_column(&mut cur); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 19a446f7..849d3a4e 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -573,6 +573,32 @@ fn column_break_moves_following_text() { assert!(lines[1].baseline < 20.0, "second column starts at the top"); } +#[test] +fn line_before_a_break_character_is_a_last_line() { + // As in InDesign: column, frame and page breaks end the paragraph's last line (set with the + // last-line alignment), while a forced line break inside a justified paragraph is justified. + use designcraft_doc::story::{COLUMN_BREAK, FORCED_LINE_BREAK, FRAME_BREAK, PAGE_BREAK}; + for (brk, align, justified) in [ + (COLUMN_BREAK, Align::LeftJustified, false), + (FRAME_BREAK, Align::LeftJustified, false), + (PAGE_BREAK, Align::LeftJustified, false), + (PAGE_BREAK, Align::FullyJustified, true), + (FORCED_LINE_BREAK, Align::LeftJustified, true), + ] { + // A space after the break is skipped and does not keep the break from moving the text. + let text = format!("one two three{brk} four five six"); + let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 400.0, 300.0), ParaAttrs { align: Some(align), ..Default::default() }); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let full = (l.end_x - l.x1).abs() < 0.6; + assert_eq!(full, justified, "{brk:?} {align:?}: line ends at {} of {}", l.end_x, l.x1); + if brk == COLUMN_BREAK { + assert_eq!(all_lines(&cs)[1].column, 1, "the text after a column break starts the next column"); + } + } +} + #[test] fn tabs_without_stops_use_default_half_inch() { let (d, sid, _) = doc_with("A\tB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); From c30340f60f210d713189cbae1378a0aa7474d717 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 10 Oct 2026 01:51:30 -0400 Subject: [PATCH 40/71] chore: bump github actions to fix Node 20 deprecation warnings (#268) --- .github/workflows/freebsd.yml | 7 ++-- .github/workflows/packaging-lint.yml | 2 +- .github/workflows/release.yml | 63 +++++++++++++--------------- .github/workflows/windows-arm64.yml | 10 ++--- 4 files changed, 38 insertions(+), 44 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 450e5734..77c03038 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -27,16 +27,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 300 steps: - - uses: actions/checkout@v4 - # The Japanese font tests run against craft-fonts (an optional build input; they skip without it). + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - name: Build and test in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: release: "14.3" usesh: true diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 8c1a4131..59c0a1ce 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,7 +23,7 @@ jobs: env: ACTIONLINT_VERSION: 1.7.12 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install tools run: | sudo apt-get update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index becf15f0..634552c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: date: ${{ steps.v.outputs.date }} prerelease: ${{ steps.v.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: v name: Resolve version run: | @@ -72,18 +72,17 @@ jobs: DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-macos-universal - name: Apply version override @@ -103,7 +102,7 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-macos path: dist/release/* @@ -131,18 +130,17 @@ jobs: DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-windows-${{ matrix.arch }} - name: Apply version override @@ -166,7 +164,7 @@ jobs: AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-windows-${{ matrix.arch }} path: dist/release/* @@ -190,10 +188,9 @@ jobs: DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} NFPM_VERSION: 2.47.0 steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 @@ -207,8 +204,8 @@ jobs: curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-linux-${{ matrix.arch }} - name: Apply version override @@ -232,7 +229,7 @@ jobs: "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' test -s "$appimage.zsync" done - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-linux-${{ matrix.arch }} path: dist/release/* @@ -255,8 +252,8 @@ jobs: DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-linux-${{ matrix.arch }} path: dist/linux @@ -270,7 +267,7 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Build the Flatpak bundle run: packaging/linux/flatpak-bundle.sh "dist/linux/designcraft-$DESIGNCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-flatpak-${{ matrix.arch }} path: dist/release/*.flatpak @@ -288,17 +285,16 @@ jobs: DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - name: Build and package in a FreeBSD VM (tar.gz) # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: release: "14.3" usesh: true @@ -323,7 +319,7 @@ jobs: bash packaging/freebsd/package.sh tar -tzf dist/release/designcraft-*-freebsd-x86_64.tar.gz \ | grep -E '/bin/designcraft$|/bin/designcraft-cli$|/share/applications/ai\.storyteller\.designcraft\.desktop$|/share/doc/designcraft/LICENSE-MIT$' - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-freebsd-x86_64 path: dist/release/* @@ -339,18 +335,17 @@ jobs: DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} TRUNK_VERSION: 0.21.14 steps: - - uses: actions/checkout@v4 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check out craft-fonts - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: abb83316d96aa59c1cf64784289e378fe9fa5695 path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-web - name: Install trunk @@ -366,7 +361,7 @@ jobs: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-web path: dist/release/* @@ -385,7 +380,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: release-* path: artifacts diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index f2c54375..b568d4e7 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -36,11 +36,11 @@ jobs: runs-on: windows-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: windows-arm64-package - name: Install WiX @@ -51,7 +51,7 @@ jobs: env: DIST: ${{ github.workspace }}\dist\arm64 run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: designcraft-windows-arm64 path: dist/arm64/* @@ -64,7 +64,7 @@ jobs: runs-on: windows-11-arm timeout-minutes: 30 steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: designcraft-windows-arm64 path: dist From e904bd6057a49dad49f3559e2bc758afff06eb76 Mon Sep 17 00:00:00 2001 From: Dmytro <140102908+dmatviichuk@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:51:35 +0200 Subject: [PATCH 41/71] Add Ukrainian interface translation (#236) * UK1: add Ukrainian interface translation Translate existing interface catalogs and wire Ukrainian menu selection, contextual publishing labels and grammatical counters. Validate persisted selection, untouched document data, placeholder/key coverage and bundled Cyrillic glyphs. Co-Authored-By: Codex * i18n uk: translate the open and place alert titles Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Codex Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ASSETS.md | 4 + ROADMAP.md | 2 +- crates/ui-egui/src/chrome.rs | 9 +- crates/ui-egui/src/dialogs.rs | 38 +- crates/ui-egui/src/i18n.rs | 80 +- crates/ui-egui/src/i18n/uk.rs | 1461 ++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 29 +- crates/ui-egui/src/panels/interactive.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 20 +- crates/ui-egui/src/panels/table.rs | 2 +- crates/ui-egui/src/theme.rs | 28 + 11 files changed, 1649 insertions(+), 26 deletions(-) create mode 100644 crates/ui-egui/src/i18n/uk.rs diff --git a/ASSETS.md b/ASSETS.md index f243fdb3..66479bd7 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -8,6 +8,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| `crates/ui-egui/src/i18n/uk.rs` (332 common phrases) | VectorCraft contributors | https://github.com/storytold/vectorcraft/blob/01f329aa99fa0c9517311c7b6cf7233ffc207742/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted for DesignCraft context; remaining Ukrainian phrases are original or credited below | +| `crates/ui-egui/src/i18n/uk.rs` (68 common phrases) | PhotoCraft contributors | https://github.com/storytold/photocraft/blob/ed057bdc6fd89a858c5e0ea4eab158a2a7052dde/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary | +| `crates/ui-egui/src/i18n/uk.rs` (37 common phrases) | WordCraft contributors | https://github.com/storytold/wordcraft/blob/7fb7b013591e2593fbd038f90bbc535be318e5a3/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary | +| `crates/ui-egui/src/i18n/uk.rs` (15 common phrases) | DeckCraft contributors | https://github.com/storytold/deckcraft/blob/3a45c614a409349fc13d449f35bd98f3321f0df5/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary; validation dictionaries are external and are not bundled | | `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `https://www.gutenberg.org/files/3204/files/mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`) | Public Domain ("Public Domain material by grant from the author, January, 2001"; Project Gutenberg: "Public domain in the USA") | ~165k words with break points, front-coded + deflate; regenerate with `cargo run --release -p designcraft-compose --example hyphgen -- mhyph.txt assets/hyphenation` | | `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from `en-us.dic`'s source list by `crates/compose/src/hyphen/patgen.rs` (`examples/hyphgen.rs`) | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX/hyph-utf8 patterns used) | | `assets/fonts/Inter-Medium.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Open-source typeface | diff --git a/ROADMAP.md b/ROADMAP.md index b678d059..bb517b5a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -107,7 +107,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). - Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `