Merge upstream main into cs-pr (German, PowerPoint export)

Keeps both module declarations (cs and the new de) and adds the Czech
label for the new "Export PowerPoint…" menu item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Losminek
2026-10-15 07:06:30 +02:00
co-authored by Claude Opus 5.5
62 changed files with 7155 additions and 203 deletions
Generated
+18
View File
@@ -986,6 +986,7 @@ dependencies = [
"designcraft-idml",
"designcraft-images",
"designcraft-pdf",
"designcraft-pptx",
"designcraft-render",
"designcraft-textimport",
"designcraft-tools",
@@ -1109,6 +1110,23 @@ dependencies = [
"tiff",
]
[[package]]
name = "designcraft-pptx"
version = "0.8.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
"designcraft-doc",
"designcraft-fonts",
"designcraft-geom",
"designcraft-images",
"image",
"quick-xml",
"skrifa 0.47.0",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "designcraft-render"
version = "0.8.0"
+1
View File
@@ -25,6 +25,7 @@ designcraft-render = { path = "crates/render" }
designcraft-pdf = { path = "crates/pdf" }
designcraft-idml = { path = "crates/idml" }
designcraft-epub = { path = "crates/epub" }
designcraft-pptx = { path = "crates/pptx" }
designcraft-textimport = { path = "crates/textimport" }
designcraft-format = { path = "crates/format" }
designcraft-tools = { path = "crates/tools" }
+2 -1
View File
@@ -87,8 +87,9 @@ 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, and PNG, PDF (PDF/X-4, PDF/A-2b)
- **Open.** A documented native format, IDML import and export, editable PowerPoint 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
+213
View File
@@ -1,6 +1,218 @@
# DesignCraft roadmap
**Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
- 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.
- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken.
DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
## Status (2026-10-01)
**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.
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions.
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands.
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`).
- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options.
- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands.
- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named.
- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands.
- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.
- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus.
- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands.
- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands.
- Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands.
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
- Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample).
- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF.
- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed).
- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI.
- Tagged PDF (`file.exportPdf {tagged}`, on by default from the UI): structure tree with stories as paragraphs in reading order, figures carrying their alt text, parent-page items and printer's marks as artifacts.
- Global Light (`object.globalLight`, `globalLight` on drop/inner shadows; Use Global Light in the Effects panel) and Generate Static Caption (`object.caption`: template over name, path, alt text, label, effective ppi, dimensions, format; any side, offset, style).
- Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics.
- Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come.
- Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically.
- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML.
- Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`.
- Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign.
- Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts.
- File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB).
- File ▸ Export PowerPoint (`file.exportPptx`, CLI `--export x.pptx`): every page becomes a slide of editable objects: shapes (preset or custom geometry, solid and gradient fills, dashed/compound strokes with arrowheads, shadows, glow, feather, opacity), pictures cropped like their frames (SVG kept as SVG), native tables (merged cells, fills, per-edge borders) and text boxes. By default the text keeps DesignCraft's line breaks, justified spacing and leading; threaded stories split at their frames and lines pushed aside by a wrap get boxes of their own. `lineBreaks: reflow` writes one box per frame in native columns instead. TrueType fonts are embedded where their licences allow, and objects without a PowerPoint equivalent are rasterised. Checked against PowerPoint for Mac and LibreOffice.
- Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup).
- Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place).
- Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions.
- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only.
- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported.
- File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages.
- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK).
- High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection).
- Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans).
- Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images.
- Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName.
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack).
- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete.
- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle.
- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates.
- Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption.
- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset.
- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order.
- Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json).
- View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label).
- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name.
- Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting.
- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position.
- Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF.
- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box.
- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle).
- Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows.
- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet.
- Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable).
- Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports.
- Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame.
- Gridify: arrow keys while dragging a frame or shape tool add columns/rows (`frame.grid`), one undo step.
- Gap tool (U, `gap.move`): drag the space between objects (or an object and the page edge) and both sides resize.
- XML basics (`xml.newTag/deleteTag/tag/mapStyle/structure`, `file.exportXml`, `file.importXml`; Tags panel with structure; View ▸ Show Tagged Frames): tagged frames export in reading order, mapped paragraph styles become elements, imported elements flow into frames with matching tags. Inline text tagging (`xml.tagText`, an `xmlTag` character attribute, exported as nested elements) and tag markers (View ▸ Structure ▸ Show Tag Markers) too. DTDs (`xml.loadDtd`, `xml.validate`, `xml.deleteDtd`; Tags panel): loading one adds its elements as tags and names the root; validation checks content models (sequences, choices, `?*+`, mixed content, EMPTY/ANY, parameter entities) and required attributes, and lists each problem with its element path.
- Adjust Layout / Layout Adjustment (`adjustLayout` on Document Setup and Margins and Columns): objects follow each page's margin box to its new size and position.
- Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it.
- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update.
- 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, 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`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn'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.
- 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.
- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF.
- Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds.
- Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`).
- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web).
- 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 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback.
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Italian, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. German and Japanese cover the whole interface: every key of every catalog. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage.
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
- EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted.
- Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections.
- Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas.
- Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout.
- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture.
- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread.
- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet.
- Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited.
- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers.
- Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted).
- Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width.
- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups).
- Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step).
- Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML.
- Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters.
- Tagged PDF: one structure element per paragraph (H1–H6 and BlockQuote from Export Tagging, else P) in reading order, frame fills, strokes, rules and tables as artifacts, figures with alt text.
- EPUB: cover image from the first page (`file.exportEpub {cover}`), and Fixed Layout export (`file.exportFixedEpub`, File ▸ Export EPUB (Fixed Layout)…): pre-paginated pages as 144 ppi images with their text kept for search and read-aloud.
- Interactive PDF options (`file.exportPdf {fullScreen, bookmarksPanel, pageLayout, view, advanceSeconds}`, File ▸ Export PDF (Interactive)…): page mode, layout, opening view and page timing in the catalog and pages.
- Object Export Options (`object.exportOptions`): tagged-PDF artifact, EPUB/HTML rasterize (the object as an image), alignment and page break before, alt text.
- Scripts panel (Window ▸ Utilities ▸ Scripts, `script.run`): saved command scripts (with `$N.path` references to earlier results) run as one undo step; edit, add, load and delete them in the panel. Scripts use DesignCraft's command language, not ExtendScript.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen.
- Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come.
- Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come.
- Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads.
- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page.
- File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document).
- Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML.
- Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`.
- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles).
- Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point.
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
- Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`).
- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`.
- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type).
- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used.
- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded.
- Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML).
- Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name).
- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and any fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`).
- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read.
- Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`.
- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`.
- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`.
- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character).
- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`.
- Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing.
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
- 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; 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), `<PDF>` 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.
- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather.
- ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
- MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input.
- Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads.
**Next (in order):** books · New Window / split view · Ink Manager and Separations Preview · XML structure, tags and export · buttons and forms · PDF/X-4 output intent (needs krilla support) · EPS place.
## How far from full parity (estimate, 2026-10-04)
**Breadth: ~99% weighted** (P0 core 99%, P1 100%, P2 97%) over the 275 features of the InDesign catalogue, scored
row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still
lack some of InDesign's options or dialog details, so **overall parity including depth is about 83%**.
**Remaining work: about 170 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly 60–85 hours with
four agents in parallel on separate crates:
| Work | Estimate |
|---|---|
| Open P0 (2: PDF/X-4 validation against a certified checker, PostScript interpretation for EPS) | 8 h |
| Open P1 (none partial) | 0 h |
| Open P2 (3 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback) | 14 h |
| Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h |
| Performance (incremental composition, GPU raster) and interchange hardening (IDML/PDF corpus) | 55 h |
Basis: since the first estimate this effort landed about 45 arcs (from parent overrides, Preferences, Stroke panel,
gradients and Pathfinder to table/cell styles, nested/GREP styles and Print) in roughly 14 hours on a heavily loaded
machine — about 1–2 hours per catalogue row.
## Agents: CLI and MCP
Every command is reachable from `designcraft-cli` (`run`, `script` with `$N.path` result references, `app` for the
running window, `describe`, `commands`), from MCP (`designcraft-cli mcp`, tools including `execute` and `batch` with the
same references), and from the app's JSON control channel. See [docs/agents.md](docs/agents.md).
## Milestones
| # | Milestone | State |
**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes; parent canvas refresh; bounded control input; text stroke behind fill; Links source paths; table anchors; Czech catalog synchronized) · **Target:** Adobe InDesign 2026
@@ -227,6 +439,7 @@ Newest first.
- **2026-10-12** · On screen, blend modes blend with the objects below and not with the paper, as in InDesign and in PDF export: a Color Dodge or Screen object over blank paper shows as it is. No readiness percentage change.
- **2026-10-12** · A gradient feather on a group fades the group as one, on screen and in PDF. Before, the screen ignored it, and PDF masked the group with a box of zero size because a group has no path of its own. No readiness percentage change.
- **2026-10-12** · In a CMYK transparency blend space (print documents), blend modes blend in CMYK on screen as in InDesign. Color Dodge with 100% K text turns coloured areas under it white and leaves the paper alone, where RGB blending changed almost nothing. PDF export already did this. No readiness percentage change.
- **2026-10-11** · Moving a sheared and rotated text frame no longer leaves trails of text behind: canvas repaints cover the frame's whole text box, not just its outline. No readiness percentage change.
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
- **2026-10-12** · Load Swatches refuses an `.ase` file with more than 100,000 colors, and `swatch.load` merges a file of 100,000 colors in under 0.1 s (it took 9.6 s of CPU in a test build). No readiness percentage change.
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
+11
View File
@@ -1,6 +1,7 @@
//! 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|.pptx] [--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
@@ -326,6 +327,14 @@ fn run(args: &[String]) -> Result<(), String> {
}
fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> {
if out.to_ascii_lowercase().ends_with(".pptx") {
let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]);
for w in r["warnings"].as_array().into_iter().flatten() {
eprintln!("warning: {}", w.as_str().unwrap_or_default());
}
return Ok(());
}
if out.to_ascii_lowercase().ends_with(".idml") {
let r = s.execute("file.exportIdml", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
@@ -453,6 +462,8 @@ fn script(args: &[String]) -> Result<(), String> {
exec(b, "file.exportIdml", p)?;
} else if lower.ends_with(".epub") {
exec(b, "file.exportEpub", p)?;
} else if lower.ends_with(".pptx") {
exec(b, "file.exportPptx", p)?;
} else {
// PNG / JPEG of the first page.
b.call("app.export", p)?;
+1 -1
View File
@@ -30,4 +30,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
web-sys = { version = "0.3", features = ["Event", "EventTarget", "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
+159
View File
@@ -0,0 +1,159 @@
//! Stop editor work after graphics loss while retaining documents for recovery downloads.
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use designcraft_engine::{Session, doc::Document};
#[derive(Clone, Default)]
pub(crate) struct GraphicsState(Arc<AtomicBool>);
impl GraphicsState {
/// Only the first loss should replace the error panel or move keyboard focus.
pub(crate) fn fail(&self) -> bool {
!self.0.swap(true, Ordering::Relaxed)
}
pub(crate) fn has_failed(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
pub(crate) fn run_if_healthy<R>(&self, work: impl FnOnce() -> R) -> Option<R> {
if self.has_failed() { None } else { Some(work()) }
}
}
/// An immutable copy that outlives the session and its renderer.
#[derive(Clone)]
pub(crate) struct RecoveryDocument {
title: String,
doc: Arc<Document>,
}
impl RecoveryDocument {
pub(crate) fn title(&self) -> &str {
&self.title
}
pub(crate) fn save_copy(&self) -> Result<(String, Vec<u8>), String> {
let bytes = designcraft_format::save(&self.doc).map_err(|error| format!("Couldn't save a copy of {}: {error}", self.title))?;
Ok((format!("{}.designcraft", self.doc.title), bytes))
}
}
/// Retain all documents without activating one, changing dirty state, or committing a preview.
pub(crate) fn recovery_documents(session: &Session) -> Vec<RecoveryDocument> {
session
.documents()
.iter()
.map(|state| RecoveryDocument { title: state.title(), doc: state.preview_stash.as_ref().unwrap_or(&state.doc).clone() })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn recovery_document(session: &Session, index: usize) -> Result<(String, Vec<u8>), String> {
recovery_documents(session).get(index).ok_or_else(|| "This document is no longer open.".to_string())?.save_copy()
}
// Synthetic 1x1 red RGBA PNG.
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
#[test]
fn graphics_loss_blocks_editor_work_and_cannot_be_cleared_by_another_callback() {
let graphics = GraphicsState::default();
let device_callback = graphics.clone();
let mut session = Session::new();
session.execute("file.new", &json!({"pages": 1})).unwrap();
graphics.run_if_healthy(|| session.execute("file.place", &json!({"name": "red.png", "base64": PNG}))).unwrap().unwrap();
graphics.run_if_healthy(|| session.execute("edit.undo", &json!({}))).unwrap().unwrap();
let (_, undone) = recovery_document(&session, 0).unwrap();
assert!(designcraft_engine::cmd::from_bytes(&undone).unwrap().all_items().is_empty());
graphics.run_if_healthy(|| session.execute("edit.redo", &json!({}))).unwrap().unwrap();
let (_, before) = recovery_document(&session, 0).unwrap();
assert_eq!(designcraft_engine::cmd::from_bytes(&before).unwrap().all_items().len(), 1);
assert!(device_callback.fail());
assert!(!graphics.fail(), "duplicate context/device notifications must not replace recovery controls");
assert!(graphics.has_failed());
assert!(graphics.run_if_healthy(|| session.execute("edit.undo", &json!({}))).is_none());
assert_eq!(recovery_document(&session, 0).unwrap().1, before, "a hidden editor must not apply more edits");
}
#[test]
fn recovery_copies_all_open_documents_without_changing_session_state() {
let mut session = Session::new();
session.execute("file.new", &json!({"title": "First", "pages": 1})).unwrap();
session.execute("file.place", &json!({"name": "red.png", "base64": PNG})).unwrap();
let first = session.doc().unwrap().doc.clone();
session.execute("file.new", &json!({"title": "Second", "pages": 1})).unwrap();
session.execute("frame.create", &json!({"rect": [10, 10, 110, 110]})).unwrap();
let second = session.doc().unwrap().doc.clone();
let active = session.active_index();
let journal_len = session.journal.len();
for (index, expected) in [first.clone(), second.clone()].iter().enumerate() {
let (name, bytes) = recovery_document(&session, index).unwrap();
assert_eq!(name, format!("{}.designcraft", expected.title));
let reopened = designcraft_engine::cmd::from_bytes(&bytes).unwrap();
assert_eq!(reopened, **expected, "the copy must retain document content and embedded PNG bytes");
}
assert_eq!(session.active_index(), active);
assert_eq!(session.journal.len(), journal_len);
assert!(Arc::ptr_eq(&session.documents()[0].doc, &first));
assert!(Arc::ptr_eq(&session.documents()[1].doc, &second));
assert!(session.documents().iter().all(|state| state.is_dirty()));
assert!(recovery_document(&session, 2).is_err());
}
#[test]
fn recovery_saves_the_template_during_data_merge_preview() {
let mut session = Session::new();
session.execute("file.new", &json!({"title": "Template", "pages": 1})).unwrap();
let template = session.doc().unwrap().doc.clone();
session.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap();
session.doc_mut().unwrap().preview_stash = Some(template.clone());
let (_, bytes) = recovery_document(&session, 0).unwrap();
let reopened = designcraft_engine::cmd::from_bytes(&bytes).unwrap();
assert_eq!(reopened, *template);
assert!(Arc::ptr_eq(session.doc().unwrap().preview_stash.as_ref().unwrap(), &template));
}
#[test]
fn recovery_snapshots_keep_all_documents_and_assets_after_session_drop() {
let mut session = Session::new();
session.execute("file.new", &json!({"title": "First", "pages": 1})).unwrap();
session.execute("file.place", &json!({"name": "red.png", "base64": PNG})).unwrap();
let first = session.doc().unwrap().doc.clone();
session.execute("file.new", &json!({"title": "Template", "pages": 1})).unwrap();
session.execute("file.place", &json!({"name": "template.png", "base64": PNG})).unwrap();
let template = session.doc().unwrap().doc.clone();
session.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap();
session.doc_mut().unwrap().preview_stash = Some(template.clone());
let documents = recovery_documents(&session);
assert_eq!(documents.len(), 2);
assert_eq!(documents[0].title(), "First");
assert_eq!(documents[1].title(), "Template");
assert!(Arc::ptr_eq(&documents[0].doc, &first));
assert!(Arc::ptr_eq(&documents[1].doc, &template));
session.stop_data_preview();
session.execute("frame.create", &json!({"rect": [10, 10, 110, 110]})).unwrap();
drop(session);
for (snapshot, expected) in documents.iter().zip([first, template]) {
let (name, bytes) = snapshot.save_copy().unwrap();
assert_eq!(name, format!("{}.designcraft", expected.title));
assert!(bytes.starts_with(b"PK"));
let reopened = designcraft_engine::cmd::from_bytes(&bytes).unwrap();
assert_eq!(reopened, *expected, "snapshots must retain every document and its embedded asset bytes");
assert!(!reopened.assets.is_empty());
assert!(reopened.assets.values().all(|asset| !asset.data.is_empty()));
}
}
}
+3
View File
@@ -22,6 +22,9 @@ mod browser_policy;
#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(any(target_arch = "wasm32", test))]
mod graphics;
#[cfg(target_arch = "wasm32")]
fn main() {
web::start();
+299 -27
View File
@@ -1,6 +1,7 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use super::browser_policy::{has_query_flag, mime_for};
use crate::graphics::{GraphicsState, RecoveryDocument, recovery_documents};
use designcraft_engine::Session;
use designcraft_ui_egui::{DesignApp, ImportRequest, ImportedFile, Inbox, Services};
use wasm_bindgen::JsCast as _;
@@ -37,8 +38,16 @@ pub fn start() {
log::error!("no document");
return;
};
if let Err(e) = create_error_panel(&document) {
log::error!("couldn't create graphics error panel: {e:?}");
if let Some(el) = document.get_element_by_id(LOADING_ID) {
el.set_text_content(Some("DesignCraft couldn't start. Reload this page to try again."));
}
return;
}
let graphics = GraphicsState::default();
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
graphics_failed(&graphics, "DesignCraft failed to start", "Reload this page to try again.", &format!("Missing canvas {CANVAS_ID}."));
return;
};
let mut options = eframe::WebOptions::default();
@@ -47,32 +56,285 @@ pub fn start() {
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let result = eframe::WebRunner::new()
let runner = eframe::WebRunner::new();
let creator_graphics = graphics.clone();
let result = runner
.start(
canvas,
canvas.clone(),
options,
Box::new(move |cc| {
if let Some(rs) = &cc.wgpu_render_state {
log::info!("designcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
let device_graphics = creator_graphics.clone();
// This also observes an already-lost WebGPU device through its lost promise.
rs.device.set_device_lost_callback(move |reason, message| {
graphics_failed(&device_graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, &format!("Device loss ({reason:?}): {message}"));
});
}
let inbox: Inbox = Inbox::default();
let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
if has_query_flag(&query(), "sample") {
let _ = app.run("file.newSample", serde_json::json!({}));
}
Ok(Box::new(WebShell { app, inbox }))
Ok(Box::new(WebShell { app, inbox, graphics: creator_graphics }))
}),
)
.await;
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
Err(e) => el.set_inner_html(&format!("<p>DesignCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>")),
match result {
Ok(()) => {
if let Err(e) = install_recovery_controls(&runner, &canvas, &graphics) {
graphics_failed(&graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, &format!("Couldn't install graphics recovery controls: {e:?}"));
if let Some(shell) = runner.app_mut::<WebShell>()
&& let Err(e) = prepare_document_recovery(&runner, &shell.app.session)
{
recovery_controls_failed(&e);
}
}
// A loss callback can run during startup. Never hide its failure panel.
if !graphics.has_failed()
&& let Some(el) = document.get_element_by_id(LOADING_ID)
{
el.remove();
}
}
Err(e) => {
graphics_failed(
&graphics,
"DesignCraft failed to start",
"Reload this page to try again. A browser with WebGPU or WebGL2 is required.",
&format!("{e:?}"),
);
if let Some(shell) = runner.app_mut::<WebShell>()
&& let Err(e) = prepare_document_recovery(&runner, &shell.app.session)
{
recovery_controls_failed(&e);
}
}
}
});
}
const ERROR_ID: &str = "designcraft_graphics_error";
const ERROR_TITLE_ID: &str = "designcraft_graphics_title";
const ERROR_MESSAGE_ID: &str = "designcraft_graphics_message";
const ERROR_DETAIL_ID: &str = "designcraft_graphics_detail";
const ERROR_ACTIONS_ID: &str = "designcraft_graphics_actions";
const ERROR_STATUS_ID: &str = "designcraft_graphics_status";
const ERROR_DOCUMENTS_ID: &str = "designcraft_graphics_documents";
const GRAPHICS_LOST_EVENT: &str = "designcraftgraphicslost";
const GRAPHICS_TITLE: &str = "Graphics connection lost";
const GRAPHICS_MESSAGE: &str = "Editing has stopped. Save a copy of each open document, then reload this page and reopen the copies. Reloading discards changes you have not downloaded.";
fn create_error_panel(document: &web_sys::Document) -> Result<(), wasm_bindgen::JsValue> {
let panel: web_sys::HtmlElement = document.create_element("div")?.dyn_into()?;
panel.set_id(ERROR_ID);
panel.set_tab_index(-1);
panel.set_attribute("role", "alertdialog")?;
panel.set_attribute("aria-modal", "true")?;
panel.set_attribute("aria-labelledby", ERROR_TITLE_ID)?;
panel.set_attribute("aria-describedby", ERROR_MESSAGE_ID)?;
// DOM content remains visible when the GPU can no longer paint the egui canvas.
let colors = designcraft_ui_egui::theme::Tokens::for_brightness(designcraft_ui_egui::theme::Brightness::Dark);
panel.style().set_css_text(&format!(
"position:fixed;inset:0;z-index:1;display:none;flex-direction:column;align-items:center;justify-content:safe center;box-sizing:border-box;padding:24px;overflow:auto;background:{};color:{};font:14px system-ui,sans-serif;gap:12px;",
css_color(colors.app_bar), css_color(colors.text),
));
for (tag, id) in [("h1", ERROR_TITLE_ID), ("p", ERROR_MESSAGE_ID), ("div", ERROR_ACTIONS_ID), ("p", ERROR_STATUS_ID), ("pre", ERROR_DETAIL_ID)] {
let child: web_sys::HtmlElement = document.create_element(tag)?.dyn_into()?;
child.set_id(id);
child.style().set_css_text("margin:0;max-width:640px;white-space:pre-wrap;overflow-wrap:anywhere;");
if id == ERROR_ACTIONS_ID {
child.style().set_css_text("display:flex;flex-direction:column;gap:8px;max-width:100%;");
add_reload_control(document, &child)?;
} else if id == ERROR_STATUS_ID {
child.set_attribute("aria-live", "polite")?;
}
panel.append_child(&child)?;
}
document.body().ok_or_else(|| wasm_bindgen::JsValue::from_str("no body"))?.append_child(&panel)?;
Ok(())
}
fn graphics_failed(graphics: &GraphicsState, title: &str, message: &str, detail: &str) {
if !graphics.fail() {
return;
}
log::error!("designcraft-web: {detail}");
if let Err(e) = show_error(title, message, detail) {
log::error!("couldn't show graphics error: {e:?}");
}
// A device callback may run while AppRunner is borrowed. Defer its notification;
// the WebGL listener already holding AppRunner captures documents directly.
if let Some(window) = web_sys::window()
&& let Some(canvas) = window.document().and_then(|doc| doc.get_element_by_id(CANVAS_ID))
{
let notify = wasm_bindgen::closure::Closure::once_into_js(move || {
if let Err(e) = web_sys::Event::new(GRAPHICS_LOST_EVENT).and_then(|event| canvas.dispatch_event(&event)) {
recovery_controls_failed(&e);
}
});
window.queue_microtask(notify.unchecked_ref());
}
}
fn show_error(title: &str, message: &str, detail: &str) -> Result<(), wasm_bindgen::JsValue> {
let document = web_sys::window().and_then(|w| w.document()).ok_or_else(|| wasm_bindgen::JsValue::from_str("no document"))?;
let panel: web_sys::HtmlElement =
document.get_element_by_id(ERROR_ID).ok_or_else(|| wasm_bindgen::JsValue::from_str("no graphics error panel"))?.dyn_into()?;
for (id, text) in [(ERROR_TITLE_ID, title), (ERROR_MESSAGE_ID, message), (ERROR_DETAIL_ID, detail)] {
if let Some(el) = document.get_element_by_id(id) {
el.set_text_content(Some(text));
}
}
panel.style().set_property("display", "flex")?;
if let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok()) {
canvas.style().set_property("visibility", "hidden")?;
}
if let Some(loading) = document.get_element_by_id(LOADING_ID).and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok()) {
loading.style().set_property("display", "none")?;
}
// Include eframe's hidden text input: neither pointer nor keyboard focus may reach the editor.
let mut sibling = document.body().and_then(|body| body.first_element_child());
while let Some(el) = sibling {
sibling = el.next_element_sibling();
if el.id() != ERROR_ID {
if let Some(html) = el.dyn_ref::<web_sys::HtmlElement>() {
html.set_inert(true);
}
el.set_attribute("aria-hidden", "true")?;
}
}
panel.focus()?;
Ok(())
}
/// Recovery listeners are owned by their DOM targets, independently of WebRunner.
fn add_dom_event_listener(
target: &web_sys::EventTarget,
event_name: &str,
callback: impl FnMut(web_sys::Event) + 'static,
) -> Result<(), wasm_bindgen::JsValue> {
let callback = wasm_bindgen::closure::Closure::wrap(Box::new(callback) as Box<dyn FnMut(web_sys::Event)>);
target.add_event_listener_with_callback(event_name, callback.as_ref().unchecked_ref())?;
// EventTarget retains the JS function; weak references reclaim its captures with the target.
let _ = callback.into_js_value();
Ok(())
}
fn css_color(color: egui::Color32) -> String {
format!("#{:02x}{:02x}{:02x}", color.r(), color.g(), color.b())
}
fn recovery_button(document: &web_sys::Document) -> Result<web_sys::HtmlElement, wasm_bindgen::JsValue> {
let button: web_sys::HtmlElement = document.create_element("button")?.dyn_into()?;
button.set_attribute("type", "button")?;
let colors = designcraft_ui_egui::theme::Tokens::for_brightness(designcraft_ui_egui::theme::Brightness::Dark);
button.style().set_css_text(&format!(
"box-sizing:border-box;max-width:100%;padding:6px 12px;border:1px solid {};border-radius:{}px;background:{};color:{};font:inherit;white-space:normal;overflow-wrap:anywhere;cursor:pointer;",
css_color(colors.field_border), colors.radius, css_color(colors.button), css_color(colors.text),
));
Ok(button)
}
fn add_reload_control(document: &web_sys::Document, actions: &web_sys::HtmlElement) -> Result<(), wasm_bindgen::JsValue> {
let reload = recovery_button(document)?;
reload.set_text_content(Some("Reload"));
add_dom_event_listener(&reload, "click", move |event| {
event.stop_propagation();
if let Some(window) = web_sys::window()
&& let Err(e) = window.location().reload()
{
log::error!("couldn't reload: {e:?}");
}
})?;
actions.append_child(&reload)?;
Ok(())
}
fn install_recovery_controls(
runner: &eframe::WebRunner,
canvas: &web_sys::HtmlCanvasElement,
graphics: &GraphicsState,
) -> Result<(), wasm_bindgen::JsValue> {
let loss_runner = runner.clone();
runner.add_event_listener(canvas, GRAPHICS_LOST_EVENT, move |_: web_sys::Event, app_runner| {
let shell = app_runner.app_mut::<WebShell>();
if shell.graphics.has_failed()
&& let Err(e) = prepare_document_recovery(&loss_runner, &shell.app.session)
{
recovery_controls_failed(&e);
}
})?;
let context_graphics = graphics.clone();
let context_runner = runner.clone();
// wgpu's GLES backend does not observe browser WebGL context-loss events.
runner.add_event_listener(canvas, "webglcontextlost", move |_: web_sys::Event, app_runner| {
graphics_failed(&context_graphics, GRAPHICS_TITLE, GRAPHICS_MESSAGE, "The browser lost the WebGL graphics context.");
if let Err(e) = prepare_document_recovery(&context_runner, &app_runner.app_mut::<WebShell>().app.session) {
recovery_controls_failed(&e);
}
})?;
// A WebGPU device can be lost while start() is still awaiting initialization.
if graphics.has_failed()
&& let Some(shell) = runner.app_mut::<WebShell>()
{
prepare_document_recovery(runner, &shell.app.session)?;
}
Ok(())
}
fn prepare_document_recovery(runner: &eframe::WebRunner, session: &Session) -> Result<(), wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or_else(|| wasm_bindgen::JsValue::from_str("no window"))?;
let documents = recovery_documents(session);
if add_document_recovery_controls(documents)? {
// Controls now own the snapshots. Release AppRunner's borrow before stopping painting
// and removing its listeners/ResizeObserver; never discard documents on setup failure.
let runner = runner.clone();
let stop = wasm_bindgen::closure::Closure::once_into_js(move || runner.destroy());
window.queue_microtask(stop.unchecked_ref());
}
Ok(())
}
fn recovery_controls_failed(error: &wasm_bindgen::JsValue) {
log::error!("couldn't prepare recovery downloads: {error:?}");
if let Some(status) = web_sys::window().and_then(|w| w.document()).and_then(|doc| doc.get_element_by_id(ERROR_STATUS_ID)) {
status.set_text_content(Some("Couldn't prepare document downloads. Keep this page open to preserve unsaved documents."));
}
}
fn add_document_recovery_controls(snapshots: Vec<RecoveryDocument>) -> Result<bool, wasm_bindgen::JsValue> {
let document = web_sys::window().and_then(|w| w.document()).ok_or_else(|| wasm_bindgen::JsValue::from_str("no document"))?;
let actions = document.get_element_by_id(ERROR_ACTIONS_ID).ok_or_else(|| wasm_bindgen::JsValue::from_str("no recovery actions"))?;
if document.get_element_by_id(ERROR_DOCUMENTS_ID).is_some() {
return Ok(false);
}
let documents: web_sys::HtmlElement = document.create_element("div")?.dyn_into()?;
documents.set_id(ERROR_DOCUMENTS_ID);
documents.style().set_css_text("display:flex;flex-direction:column;gap:8px;max-width:100%;");
for snapshot in snapshots {
let button = recovery_button(&document)?;
button.set_text_content(Some(&format!("Save a Copy: {}", snapshot.title())));
add_dom_event_listener(&button, "click", move |event| {
event.stop_propagation();
let result = snapshot.save_copy().and_then(|(name, bytes)| {
download(&name, &bytes)?;
Ok(format!("Requested download of {name}. Check your browser downloads before reloading."))
});
let text = result.unwrap_or_else(|e| e);
if let Some(status) = web_sys::window().and_then(|w| w.document()).and_then(|doc| doc.get_element_by_id(ERROR_STATUS_ID)) {
status.set_text_content(Some(&text));
}
})?;
documents.append_child(&button)?;
}
// Build off-DOM so a setup error leaves the session intact and permits a clean retry.
// The inserted element's id also prevents duplicate buttons on repeated loss notifications.
actions.insert_before(&documents, actions.first_child().as_ref())?;
Ok(true)
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
@@ -82,36 +344,46 @@ fn query() -> String {
struct WebShell {
app: DesignApp,
inbox: Inbox,
graphics: GraphicsState,
}
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let request = self.app.import_request("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes });
ctx.request_repaint();
let graphics = self.graphics.clone();
let _ = graphics.run_if_healthy(|| {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let request = self.app.import_request("drop");
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(ImportedFile { request, name, bytes });
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
self.app.logic(ctx);
});
}
self.app.logic(ctx);
});
}
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);
let _ = self.graphics.run_if_healthy(|| {
designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused());
self.app.raw_input_hook(raw);
});
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
let _ = self.graphics.run_if_healthy(|| self.app.ui(ui));
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
+2
View File
@@ -26,6 +26,8 @@ real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.PhilippKrueger]
real_name = "Philipp Krüger"
[people.emanuelebonaventura]
display_name = "insanityxoff"
+56 -1
View File
@@ -69,7 +69,7 @@ pub fn read(bytes: &[u8]) -> Result<Vec<Swatch>, AseError> {
r.take(4)?; // version
let blocks = r.u32()?;
let (mut out, mut colors) = (Vec::new(), 0usize);
for _ in 0..blocks {
'blocks: for _ in 0..blocks {
let kind = r.u16()?;
let len = r.u32()? as usize;
let body = r.take(len)?;
@@ -84,6 +84,19 @@ pub fn read(bytes: &[u8]) -> Result<Vec<Swatch>, AseError> {
let mut b = R { b: body, i: 0 };
let name = b.name()?;
let model = b.take(4)?.to_vec();
let n = match &model[..] {
b"CMYK" => 4,
b"RGB " | b"LAB " => 3,
b"Gray" => 1,
_ => continue,
};
// A NaN or infinite component cannot be stored in a document: leave that swatch out.
let mut peek = R { b: body, i: b.i };
for _ in 0..n {
if !peek.f32()?.is_finite() {
continue 'blocks;
}
}
let color = match &model[..] {
b"CMYK" => Color::Cmyk { c: b.f32()?, m: b.f32()?, y: b.f32()?, k: b.f32()? },
b"RGB " => Color::Rgb { r: b.f32()?, g: b.f32()?, b: b.f32()? },
@@ -91,6 +104,9 @@ pub fn read(bytes: &[u8]) -> Result<Vec<Swatch>, AseError> {
b"LAB " => {
let (l, a, bb) = (b.f32()?, b.f32()?, b.f32()?);
let [r, g, bl] = cms::lab::lab_to_srgb(cms::Lab { l: l * 100.0, a, b: bb });
if ![r, g, bl].iter().all(|v| v.is_finite()) {
continue;
}
Color::Rgb { r: r.clamp(0.0, 1.0), g: g.clamp(0.0, 1.0), b: bl.clamp(0.0, 1.0) }
}
_ => continue,
@@ -214,4 +230,43 @@ mod tests {
assert_eq!(read(&write(&sw)).err(), Some(AseError::TooManyColors));
assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit");
}
#[test]
fn non_finite_components_are_skipped() {
let sw = |name: &str, color: Color| Swatch {
name: name.into(),
value: SwatchValue::Color { color, color_type: ColorType::Process },
locked: false,
named: true,
hidden: false,
};
let mut list = vec![sw("Ok", Color::Rgb { r: 0.1, g: 0.2, b: 0.3 })];
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
list.push(sw("Rgb", Color::Rgb { r: bad, g: 0.0, b: 0.0 }));
list.push(sw("Cmyk", Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: bad }));
list.push(sw("Gray", Color::Gray { k: bad }));
}
let mut bytes = write(&list);
// Append Lab blocks with a non-finite component.
let mut extra = 0u32;
for bad in [f32::NAN, f32::INFINITY] {
let mut c = Vec::new();
c.extend(2u16.to_be_bytes());
c.extend([0, b'L', 0, 0]);
c.extend(b"LAB ");
for v in [bad, 0.0, 0.0] {
c.extend(v.to_bits().to_be_bytes());
}
c.extend(2u16.to_be_bytes());
bytes.extend(COLOR.to_be_bytes());
bytes.extend((c.len() as u32).to_be_bytes());
bytes.extend(&c);
extra += 1;
}
let count = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) + extra;
bytes[8..12].copy_from_slice(&count.to_be_bytes());
let back = read(&bytes).unwrap();
let names: Vec<&str> = back.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["Ok"], "non-finite swatches must be left out");
}
}
+8 -2
View File
@@ -81,15 +81,21 @@ impl Spacing {
if !self.justify || g.locked_advance {
return ([0.0; 3], [0.0; 3]);
}
// A space that is no break opportunity (No Break, U+00A0) is a box with word-space elasticity.
if g.is_justify_space() {
let (st, sh) = self.space_elastic(g);
return ([st, 0.0, 0.0], [sh, 0.0, 0.0]);
}
let natural = g.adv / self.glyph_desired.max(0.01);
(
[0.0, g.space * (self.letter_max - self.letter_desired).max(0.0), natural * (self.glyph_max - self.glyph_desired).max(0.0)],
[0.0, g.space * (self.letter_desired - self.letter_min).max(0.0), natural * (self.glyph_desired - self.glyph_min).max(0.0)],
)
}
/// Word-space stretch and shrink of space glyph `g` (only U+0020 is elastic).
/// Word-space stretch and shrink of space glyph `g` (see [`Glyph::is_word_space`]); none
/// inside a jidori run, whose width is fitted.
fn space_elastic(&self, g: &Glyph) -> (f64, f64) {
if g.ch != ' ' && !(g.ch == '\u{3000}' && g.ideographic_space_elastic) {
if !g.is_word_space() || g.locked_advance {
return (0.0, 0.0);
}
(g.space * (self.word_max - self.word_desired).max(0.0), g.space * (self.word_desired - self.word_min).max(0.0))
+8 -7
View File
@@ -1672,7 +1672,7 @@ fn apply_desired_spacing(glyphs: &mut [Glyph], pp: &ParaProps) {
if g.locked_advance {
continue;
}
if g.ch == ' ' || (g.ch == '\u{3000}' && g.ideographic_space_elastic) {
if g.is_word_space() {
g.adv += g.space * (ws - 1.0);
} else if !g.is_space() && g.adv > 0.0 {
g.adv = g.adv * gs + ls * g.space;
@@ -2566,7 +2566,8 @@ fn layout_line(
// a line whose last tab is a right, centre, character or right-indent tab is not justified.
let seg = last_tab.map_or(0, |(i, _)| i + 1);
let seg_justifies = last_tab.is_none_or(|(_, left)| left);
let spaces: Vec<usize> = line.iter().enumerate().skip(seg).filter(|(_, g)| g.is_space() && !g.no_break).map(|(i, _)| i).collect();
// Spaces inside a jidori run keep the run's fitted width, as in the breaker.
let spaces: Vec<usize> = line.iter().enumerate().skip(seg).filter(|(_, g)| g.is_justify_space() && !g.locked_advance).map(|(i, _)| i).collect();
let align = match pp.align {
Align::TowardsSpine => {
if left_page {
@@ -2667,7 +2668,7 @@ fn layout_line(
let mut ratio_n = 0usize;
for &i in &spaces {
let g = &line[i];
if g.ch == ' ' && g.space > 0.0 {
if matches!(g.ch, ' ' | shape::NBSP) && g.space > 0.0 {
ratio_sum += (g.adv + add[i]) / g.space;
ratio_n += 1;
}
@@ -2853,7 +2854,7 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
.iter()
.map(|&i| {
let g = &line[i];
if g.ch != ' ' && !(g.ch == '\u{3000}' && g.ideographic_space_elastic) {
if !g.is_word_space() {
0.0
} else if stretch {
g.space * (sp.word_max - sp.word_desired).max(0.0)
@@ -2863,8 +2864,8 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
})
.collect();
// Letter gaps: between visible glyphs (not after the line's last glyph).
let last_box = line.iter().rposition(|g| !g.is_space() && g.adv > 0.0).unwrap_or(0);
let is_box = |i: usize, g: &Glyph| i < last_box && !g.is_space() && g.adv > 0.0 && (!g.locked_advance || g.break_after != Some(false));
let last_box = line.iter().rposition(|g| !g.is_justify_space() && g.adv > 0.0).unwrap_or(0);
let is_box = |i: usize, g: &Glyph| i < last_box && !g.is_justify_space() && g.adv > 0.0 && (!g.locked_advance || g.break_after != Some(false));
let letter: Vec<f64> = line
.iter()
.enumerate()
@@ -2881,7 +2882,7 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
let glyph: Vec<f64> = line
.iter()
.map(|g| {
if g.is_space() || g.adv <= 0.0 || g.locked_advance {
if g.is_justify_space() || g.adv <= 0.0 || g.locked_advance {
0.0
} else {
let natural = g.adv / sp.glyph_desired.max(0.01);
+45
View File
@@ -78,12 +78,24 @@ impl Glyph {
pub fn is_space(&self) -> bool {
matches!(self.ch, ' ' | '\u{2002}'..='\u{200A}') || (self.ch == '\u{3000}' && self.ideographic_space_elastic)
}
/// A space whose width justification and word spacing vary: U+0020, the nonbreaking space
/// U+00A0 and an elastic ideographic space. Fixed-width spaces (U+2002–U+200A, U+202F) get no
/// word-space stretch or shrink.
pub fn is_word_space(&self) -> bool {
matches!(self.ch, ' ' | NBSP) || (self.ch == '\u{3000}' && self.ideographic_space_elastic)
}
/// A space that justification treats as a word space rather than a letter: [`Self::is_space`]
/// plus U+00A0, which is never a break opportunity.
pub fn is_justify_space(&self) -> bool {
self.is_space() || self.ch == NBSP
}
pub fn is_letter(&self) -> bool {
self.ch.is_alphabetic() || matches!(self.ch, '\'' | '’')
}
}
pub const SOFT_HYPHEN: char = '\u{AD}';
pub const NBSP: char = '\u{A0}';
/// Shaped paragraph.
pub struct ShapedPara {
@@ -293,6 +305,7 @@ pub(crate) fn shape_para(
resolved.push((a, b, props, fmt, para_chars.clone()));
}
}
let resolved = marks_join_their_base(&story.text, resolved);
for (a, b, props, fmt, resolved_base) in resolved {
let style = table.intern(db, &props);
// A missing font's substitute stands in for the whole font: fallback fonts help it.
@@ -355,6 +368,38 @@ pub(crate) fn shape_para(
ShapedPara { glyphs, range }
}
/// Combining marks that start a run are shaped with the letter before them when the two runs
/// differ only in OpenType features (ligatures, kerning, `otf_features`): the shaper attaches each
/// mark to its base, in its base's face. A run in another font, size or colour keeps its marks.
fn marks_join_their_base<'f>(
text: &str,
resolved: Vec<(usize, usize, CharProps, &'f designcraft_doc::CharFormat, CharProps)>,
) -> Vec<(usize, usize, CharProps, &'f designcraft_doc::CharFormat, CharProps)> {
let same_but_features = |a: &CharProps, b: &CharProps| {
*b == CharProps { ligatures: b.ligatures, kerning: b.kerning, otf_features: b.otf_features.clone(), ..a.clone() }
};
let is_base = |c: char| !c.is_whitespace() && !c.is_control() && !matches!(c, designcraft_doc::FOOTNOTE_REF | designcraft_doc::ENDNOTE_REF);
let mut out: Vec<(usize, usize, CharProps, &designcraft_doc::CharFormat, CharProps)> = Vec::with_capacity(resolved.len());
for (a, b, props, fmt, base) in resolved {
let run = text.get(a..b).unwrap_or("");
let marks = run.char_indices().find(|(_, c)| !is_mark(*c)).map_or(run.len(), |(i, _)| i);
if let Some(prev) = out.last_mut()
&& marks > 0
&& prev.1 == a
&& text.get(prev.0..prev.1).and_then(|s| s.chars().next_back()).is_some_and(is_base)
&& same_but_features(&prev.2, &props)
{
prev.1 = a + marks;
if a + marks < b {
out.push((a + marks, b, props, fmt, base));
}
continue;
}
out.push((a, b, props, fmt, base));
}
out
}
/// Tate-chu-yoko: in vertical text each run of marked glyphs takes one em along the line, its
/// glyphs set side by side across it; elsewhere the mark is dropped.
pub(crate) fn collapse_tcy(glyphs: &mut [Glyph], vertical: bool) {
+159
View File
@@ -622,6 +622,110 @@ fn hyphenation_skips_a_no_break_word_at_the_start() {
assert!(!first.hyphenated && first.range.end >= 15, "{:?}", &text[first.range.clone()]);
}
/// Natural advance of each glyph of `text` by byte, and a frame width that puts the first three
/// words on a justified line with one word space of extra to share.
fn three_word_measure(text: &str) -> (std::collections::HashMap<usize, f64>, f64) {
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 1000.0, 100.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
let line = all_lines(&cs)[0];
let adv = line.glyphs.iter().map(|g| (g.byte, g.adv)).collect::<std::collections::HashMap<_, _>>();
let third_end = text.find(" dddd").unwrap();
let gap = line.glyphs.iter().find(|g| g.byte == third_end).unwrap();
(adv, gap.x - line.x0 + gap.adv + 2.0 * line.x0)
}
fn justified_first_line(text: &str, width: f64, no_break: Option<std::ops::Range<usize>>) -> Line {
let para = ParaAttrs { align: Some(Align::LeftJustified), word_space_max: Some(4.0), ..Default::default() };
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width, 1000.0), para);
if let Some(run) = no_break {
d.story_mut(sid).unwrap().format_chars(run, |f| f.over.no_break = Some(true));
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
let line = all_lines(&cs)[0].clone();
assert!((line.end_x - line.x1).abs() < 0.1, "line ends at {} not {}", line.end_x, line.x1);
line
}
#[test]
fn no_break_spaces_stretch_on_justified_lines() {
let text = "aaaa bbbb cccc dddd eeee ffff gggg hhhh";
let (natural, width) = three_word_measure(text);
// The run holds one of the line's two spaces, then both.
for run in [0..9, 0..14] {
let line = justified_first_line(text, width, Some(run.clone()));
assert_eq!(text[line.range.clone()].trim_end(), "aaaa bbbb cccc");
let words = line.glyphs.iter().filter(|g| g.byte < 14);
let (spaces, letters): (Vec<_>, Vec<_>) = words.partition(|g| text.as_bytes()[g.byte] == b' ');
assert_eq!(spaces.len(), 2);
for s in &spaces {
assert!(s.adv > natural[&s.byte] + 1.0 && (s.adv - spaces[0].adv).abs() < 1e-6, "run {run:?}: space at {} is {}", s.byte, s.adv);
}
for g in letters {
assert!((g.adv - natural[&g.byte]).abs() < 1e-6, "run {run:?}: letter at {} is {}", g.byte, g.adv);
}
}
}
#[test]
fn nonbreaking_space_stretches_and_fixed_width_one_does_not() {
for (nb, stretches) in [('\u{A0}', true), ('\u{202F}', false)] {
let text = format!("aaaa{nb}bbbb cccc dddd eeee ffff gggg hhhh");
let (natural, width) = three_word_measure(&text);
let line = justified_first_line(&text, width, None);
let nb_glyph = line.glyphs.iter().find(|g| g.byte == 4).unwrap();
let space = line.glyphs.iter().find(|g| text.as_bytes()[g.byte] == b' ').unwrap();
assert!(space.adv > natural[&space.byte] + 1.0);
if stretches {
assert!((nb_glyph.adv - space.adv).abs() < 1e-6, "{nb:?}: {} vs {}", nb_glyph.adv, space.adv);
} else {
assert!((nb_glyph.adv - natural[&4]).abs() < 1e-6, "{nb:?}: {} vs {}", nb_glyph.adv, natural[&4]);
}
}
}
#[test]
fn jidori_run_keeps_its_width_on_a_justified_line() {
fn first_line(text: &str, width: f64, para: ParaAttrs, run: std::ops::Range<usize>) -> Line {
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width, 1000.0), para);
d.story_mut(sid).unwrap().format_chars(run, |f| f.over.jidori = Some(6));
let cs = compose_story(&d, sid, &ComposeOptions::default());
all_lines(&cs)[0].clone()
}
for sp in ['\u{A0}', ' '] {
let text = format!("aa{sp}aa bbbb cccc dddd eeee ffff gggg hhhh");
let run = 0..4 + sp.len_utf8();
let run_width = |l: &Line| l.glyphs.iter().filter(|g| run.contains(&g.byte)).map(|g| g.adv).sum::<f64>();
let ragged = first_line(&text, 1000.0, ParaAttrs::default(), run.clone());
let gap = ragged.glyphs.iter().find(|g| g.byte == text.find(" dddd").unwrap()).unwrap();
let width = gap.x - ragged.x0 + gap.adv + 2.0 * ragged.x0;
let para = ParaAttrs { align: Some(Align::LeftJustified), word_space_max: Some(4.0), ..Default::default() };
let line = first_line(&text, width, para, run.clone());
assert_eq!(text[line.range.clone()].trim_end(), format!("aa{sp}aa bbbb cccc"));
assert!((line.end_x - line.x1).abs() < 0.1, "{sp:?}: line ends at {} not {}", line.end_x, line.x1);
assert!((run_width(&line) - run_width(&ragged)).abs() < 1e-6, "{sp:?}: run is {} not {}", run_width(&line), run_width(&ragged));
}
}
#[test]
fn nonbreaking_space_alone_fills_a_justified_line() {
let text = "10\u{A0}km 1234";
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 1000.0, 100.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
let ragged = all_lines(&cs)[0];
let natural = ragged.glyphs.iter().map(|g| (g.byte, g.adv)).collect::<std::collections::HashMap<_, _>>();
let m = ragged.glyphs.iter().find(|g| g.byte == 5).unwrap();
let width = m.x - ragged.x0 + m.adv + 15.0 + 2.0 * ragged.x0;
let line = justified_first_line(text, width, None);
assert_eq!(text[line.range.clone()].trim_end(), "10\u{A0}km");
for g in line.glyphs.iter().filter(|g| g.byte < 6) {
if g.byte == 2 {
assert!((g.adv - natural[&2] - 15.0).abs() < 0.1, "U+00A0 is {}", g.adv);
} else {
assert!((g.adv - natural[&g.byte]).abs() < 1e-6, "glyph at {} is {}", g.byte, g.adv);
}
}
}
/// 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<usize>) {
let mut parts: Vec<String> = (0..fillers).map(|k| format!("Filler {k}")).collect();
@@ -1622,6 +1726,27 @@ fn warichu_stacks_the_run_inside_the_line_and_closes_up() {
assert!((l.end_x - (z.x + z.adv)).abs() < 1e-3, "the line-end caret follows the close-up");
}
#[test]
fn warichu_line_spacing_from_a_document_stays_finite() {
for spacing in [1e308, -1e308, f64::INFINITY, f64::NAN] {
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, "ABCDEFGHZ", ParaFormat::default()).unwrap();
d.story_mut(sid).unwrap().format_chars(0..8, |f| {
f.over.warichu = Some(true);
f.over.warichu_lines = Some(16);
f.over.warichu_line_spacing = Some(spacing);
});
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = &cs.frames[0].lines[0];
assert!(
l.glyphs.iter().all(|g| g.x.is_finite() && g.y.is_finite() && g.y.abs() < 1e4),
"{spacing}: {:?}",
l.glyphs.iter().map(|g| g.y).collect::<Vec<_>>()
);
}
}
#[test]
fn warichu_break_minimum_keeps_a_short_run_on_one_line() {
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
@@ -3263,6 +3388,40 @@ fn missing_glyphs_are_the_fonts_box_unless_fallback_is_on() {
assert_ne!(gid, 0, "a fallback font draws it");
}
#[test]
fn a_mark_starting_a_run_is_shaped_with_its_base() {
use designcraft_fonts::testing::font_with;
const HEBREW: &str = "DC Test Hebrew Points";
designcraft_fonts::FontDb::global().add_font(font_with(HEBREW, &['ש', '\u{5C2}', 'ל']).unwrap());
// Shin, then sin dot and lamed in a run that only turns ligatures off.
let text = "ש\u{5C2}ל";
let mark = "ש".len();
let glyphs = |family: &str, mark_family: Option<&str>| {
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 300.0, 100.0), ParaAttrs::default());
let story = d.story_mut(sid).unwrap();
story.format_chars(0..text.len(), |f| f.over.font_family = Some(family.into()));
story.format_chars(mark..text.len(), |f| {
f.over.ligatures = Some(false);
if let Some(m) = mark_family {
f.over.font_family = Some(m.into());
}
});
let cs = compose_story(&d, sid, &ComposeOptions::default());
all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).map(|g| (g.face, g.gid, g.byte)).collect::<Vec<_>>()
};
// A missing font: its substitute lacks both, so the letter comes from a fallback font, and the
// mark from the same font, in its letter's cluster.
let g = glyphs("DC Test No Such Family", None);
let (base, dot) = (g[0], g[1]);
assert_ne!(base.1, 0, "{g:?}");
assert_eq!((dot.0.family.as_str(), dot.2), (base.0.family.as_str(), 0), "the sin dot goes with its shin: {g:?}");
assert_ne!(dot.1, 0, "{g:?}");
// The run's own font lacks the mark: its box, whatever font the letter before it has.
let g = glyphs(HEBREW, Some(designcraft_fonts::DEFAULT_FAMILY));
let dot = g.iter().find(|x| x.2 == mark).unwrap();
assert_eq!((dot.0.family.as_str(), dot.1), (designcraft_fonts::DEFAULT_FAMILY, 0), "{g:?}");
}
#[test]
fn kenten_missing_from_the_font_are_its_box_unless_fallback_is_on() {
use designcraft_fonts::testing::font_with;
+17 -2
View File
@@ -84,7 +84,8 @@ fn line_count(len: usize, want: usize, before: usize, after: usize) -> usize {
let before = before.max(1);
let after = after.max(1);
while n >= 2 {
if before + after + n - 2 <= len {
// The minimums come from documents and commands: on 32-bit targets their sum can overflow.
if before.saturating_add(after).saturating_add(n - 2) <= len {
return n;
}
n -= 1;
@@ -220,7 +221,9 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
let small = st.size * scale;
// 0 is the default (one small em). Any other value, including a negative one, is added to
// that em and then clamped so the rows cannot swap order.
let gap = if st.warichu_line_spacing == 0.0 { small } else { (small + st.warichu_line_spacing).max(0.0) };
// Bounded, so a hostile value can't move a row to an infinite or huge coordinate.
let spacing = if st.warichu_line_spacing.is_finite() { st.warichu_line_spacing.clamp(-16.0 * st.size, 16.0 * st.size) } else { 0.0 };
let gap = if spacing == 0.0 { small } else { (small + spacing).max(0.0) };
let mut off = 0_usize;
for (li, &c) in counts.iter().enumerate() {
let w = widths[li];
@@ -264,3 +267,15 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
}
delta
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn huge_break_minimums_keep_one_line() {
assert_eq!(line_count(10, 2, usize::MAX, 1), 1);
assert_eq!(line_count(10, 3, 1, usize::MAX), 1);
assert_eq!(line_count(10, 2, 3, 3), 2);
}
}
+16 -1
View File
@@ -560,7 +560,9 @@ impl Document {
let pi = self.parent_index(page.parent?)?;
let ps = &self.parents[pi];
// A parent spread can come without pages from a file.
let idx = if ps.pages.len() >= 2 && page.side == PageSide::Left { 0 } else { ps.pages.len().checked_sub(1)? };
// A single-sided page takes the parent's first page, like a left-hand page.
let first = ps.pages.len() >= 2 && matches!(page.side, PageSide::Left | PageSide::Single);
let idx = if first { 0 } else { ps.pages.len().checked_sub(1)? };
Some((pi, idx))
}
@@ -616,6 +618,19 @@ mod tests {
assert_eq!(d.parent_page_for(1), None);
}
/// A single-sided page applying a two-page parent shows the parent's first page.
#[test]
fn single_sided_page_shows_first_parent_page() {
let mut d = Document::new(&NewDocument { pages: 2, facing_pages: false, ..Default::default() });
let extra = d.parents[0].pages[0].clone();
Arc::make_mut(&mut d.parents[0]).pages.push(extra);
assert_eq!(d.parents[0].pages.len(), 2);
assert_eq!(d.parent_page_for(0), Some((0, 0)));
assert_eq!(d.parent_page_for(1), Some((0, 0)));
Arc::make_mut(&mut d.parents[0]).pages.clear();
assert_eq!(d.parent_page_for(0), None);
}
#[test]
fn non_facing_one_page_per_spread() {
let d = Document::new(&NewDocument { pages: 3, facing_pages: false, ..Default::default() });
+146 -2
View File
@@ -1,8 +1,10 @@
//! Formatted text slices: copy a range of a story (with paragraph and character formats,
//! footnotes, cross-references, index markers and tables) and insert it into another story.
//! footnotes, endnotes, editorial notes, cross-references, index markers and tables) and insert
//! it into another story.
use std::sync::Arc;
use crate::endnotes::{ENDNOTE_REF, NOTE_MARK};
use crate::ids::StoryId;
use crate::index::INDEX_MARK;
use crate::notes::FOOTNOTE_REF;
@@ -33,6 +35,12 @@ impl Story {
let count = |m: char, upto: usize| self.text[..upto].matches(m).count();
let span = |m: char| count(m, a)..count(m, b);
out.notes = self.notes[span(FOOTNOTE_REF)].to_vec();
// Malformed input can have fewer payloads than markers. Keep the available entries;
// insertion creates empty entries for any missing payloads.
let endnotes = span(ENDNOTE_REF);
out.endnotes = self.endnotes.iter().skip(endnotes.start).take(endnotes.len()).cloned().collect();
let editorial = span(NOTE_MARK);
out.editorial = self.editorial.iter().skip(editorial.start).take(editorial.len()).cloned().collect();
out.anchors = self.anchors[span(ANCHOR_MARK)].to_vec();
out.xrefs = self.xrefs[span(XREF_MARK)].to_vec();
out.index_refs = self.index_refs[span(INDEX_MARK)].to_vec();
@@ -81,7 +89,7 @@ impl Story {
*p = crate::story::ParaFormat { table: None, ..pf.clone() };
}
}
// Payloads of footnotes, cross-references and index markers, in order.
// Marker payloads in text order, keeping newly allocated destination note ids.
let first = |m: char| self.text[..pos].matches(m).count();
let (nf, nx, ni, na) = (first(FOOTNOTE_REF), first(XREF_MARK), first(INDEX_MARK), first(ANCHOR_MARK));
let no = first(crate::anchored::OBJECT_MARK);
@@ -96,6 +104,22 @@ impl Story {
*slot = Arc::new(crate::notes::Footnote { id, text: n.text.clone() });
}
}
// Limit payload copies to inserted markers so excess source entries cannot overwrite
// notes already following the insertion point.
let ne = first(ENDNOTE_REF);
for (k, n) in src.endnotes.iter().take(src.text.matches(ENDNOTE_REF).count()).enumerate() {
if let Some(slot) = self.endnotes.get_mut(ne + k) {
let id = slot.id;
*slot = Arc::new(crate::notes::Footnote { id, text: n.text.clone() });
}
}
let nn = first(NOTE_MARK);
for (k, n) in src.editorial.iter().take(src.text.matches(NOTE_MARK).count()).enumerate() {
if let Some(slot) = self.editorial.get_mut(nn + k) {
let id = slot.id;
*slot = Arc::new(crate::endnotes::EditorialNote { id, ..(**n).clone() });
}
}
for (k, x) in src.xrefs.iter().enumerate() {
if let Some(slot) = self.xrefs.get_mut(nx + k) {
*slot = x.clone();
@@ -253,6 +277,93 @@ mod tests {
assert!(a.extract(2..8).notes.is_empty());
}
fn append_note_pair(st: &mut Story, text: &str) {
st.insert_endnote(st.len(), text, ParaFormat::default());
st.insert(st.len(), &NOTE_MARK.to_string());
let note = Arc::make_mut(st.editorial.last_mut().unwrap());
note.text = text.into();
note.author = format!("{text} author");
}
#[test]
fn clipped_endnotes_and_editorial_keep_payloads_with_fresh_destination_ids() {
let mut source = Story::new(StoryId(1));
append_note_pair(&mut source, "outside before");
let start = source.len();
append_note_pair(&mut source, "Citation é\nsecond");
let end = source.len();
let citation = &mut Arc::make_mut(&mut source.endnotes[1]).text;
citation.format_chars(0..8, |f| f.over.size = Some(19.0));
citation.paras[1].style = "Citation".into();
append_note_pair(&mut source, "outside after");
source.check().unwrap();
let original = source.clone();
let slice = source.extract(start..end);
slice.check().unwrap();
assert_eq!(slice.text, format!("{ENDNOTE_REF}{NOTE_MARK}"));
assert_eq!(slice.endnotes, vec![source.endnotes[1].clone()]);
assert_eq!(slice.editorial, vec![source.editorial[1].clone()]);
let mut dest = Story::new(StoryId(2));
append_note_pair(&mut dest, "destination before");
let at = dest.len();
append_note_pair(&mut dest, "destination after");
let before = dest.clone();
assert_eq!(dest.insert_story(at, &slice), at + slice.len());
dest.check().unwrap();
assert_eq!(dest.text, format!("{ENDNOTE_REF}{NOTE_MARK}").repeat(3));
assert_eq!(dest.endnotes[1].text, slice.endnotes[0].text);
assert_eq!(dest.editorial[1].text, "Citation é\nsecond");
assert_eq!(dest.editorial[1].author, "Citation é\nsecond author");
assert_eq!((dest.endnotes[1].id, dest.editorial[1].id), (3, 3));
assert_eq!(dest.endnotes[0], before.endnotes[0]);
assert_eq!(dest.endnotes[2], before.endnotes[1]);
assert_eq!(dest.editorial[0], before.editorial[0]);
assert_eq!(dest.editorial[2], before.editorial[1]);
Arc::make_mut(&mut dest.endnotes[1]).text.insert(0, "changed");
Arc::make_mut(&mut dest.editorial[1]).author = "changed".into();
assert_eq!(source, original);
assert_eq!(slice.endnotes[0], original.endnotes[1]);
assert_eq!(slice.editorial[0], original.editorial[1]);
}
#[test]
fn extract_tolerates_missing_endnote_and_editorial_payloads() {
let mut source = Story::new(StoryId(1));
for text in ["before", "selected", "missing"] {
append_note_pair(&mut source, text);
}
let pair_len = ENDNOTE_REF.len_utf8() + NOTE_MARK.len_utf8();
for available in [0, 1, 2] {
let mut damaged = source.clone();
damaged.endnotes.truncate(available);
damaged.editorial.truncate(available);
let original = damaged.clone();
let slice = damaged.extract(pair_len..damaged.len());
assert_eq!(slice.text, format!("{ENDNOTE_REF}{NOTE_MARK}").repeat(2));
let expected = if available == 2 { 1 } else { 0 };
assert_eq!((slice.endnotes.len(), slice.editorial.len()), (expected, expected));
if expected == 1 {
assert_eq!(slice.endnotes[0], source.endnotes[1]);
assert_eq!(slice.editorial[0], source.editorial[1]);
}
let mut dest = Story::new(StoryId(2));
append_note_pair(&mut dest, "untouched");
let tail = dest.clone();
dest.insert_story(0, &slice);
dest.check().unwrap();
assert_eq!(dest.text, format!("{ENDNOTE_REF}{NOTE_MARK}").repeat(3));
assert_eq!((dest.endnotes.len(), dest.editorial.len()), (3, 3));
assert_eq!(dest.endnotes[2], tail.endnotes[0]);
assert_eq!(dest.editorial[2], tail.editorial[0]);
assert_eq!(dest.endnotes[0].text.text, if expected == 1 { "selected" } else { "" });
assert_eq!(dest.editorial[0].text, if expected == 1 { "selected" } else { "" });
assert!(dest.endnotes[1].text.is_empty());
assert!(dest.editorial[1].text.is_empty());
assert_eq!(damaged, original);
}
}
#[test]
fn append_tables_preserves_sparse_and_wrapping_ids() {
for existing in [vec![1, 3, u64::MAX], vec![1, 3, u64::MAX - 1], vec![1, 3, 8]] {
@@ -283,6 +394,39 @@ mod tests {
}
}
#[test]
fn excess_note_payloads_do_not_overwrite_destination_neighbors() {
let mut payload = Story::new(StoryId(1));
append_note_pair(&mut payload, "copied");
for has_markers in [false, true] {
let mut src = if has_markers { payload.clone() } else { Story::with_text(StoryId(1), "plain", ParaFormat::default()) };
src.endnotes.push(payload.endnotes[0].clone());
src.editorial.push(payload.editorial[0].clone());
let original = src.clone();
let mut dest = Story::new(StoryId(2));
append_note_pair(&mut dest, "before");
let at = dest.len();
append_note_pair(&mut dest, "after");
let before = dest.clone();
assert_eq!(dest.insert_story(at, &src), at + src.len());
dest.check().unwrap();
let pair = format!("{ENDNOTE_REF}{NOTE_MARK}");
assert_eq!(dest.text, format!("{pair}{}{pair}", src.text));
let tail = if has_markers { 2 } else { 1 };
assert_eq!((dest.endnotes.len(), dest.editorial.len()), (tail + 1, tail + 1));
assert_eq!(dest.endnotes[0], before.endnotes[0]);
assert_eq!(dest.endnotes[tail], before.endnotes[1]);
assert_eq!(dest.editorial[0], before.editorial[0]);
assert_eq!(dest.editorial[tail], before.editorial[1]);
if has_markers {
assert_eq!(dest.endnotes[1].text, payload.endnotes[0].text);
assert_eq!((dest.editorial[1].text.as_str(), dest.editorial[1].author.as_str()), ("copied", "copied author"));
assert_eq!((dest.endnotes[1].id, dest.editorial[1].id), (3, 3));
}
assert_eq!(src, original);
}
}
#[test]
fn append_story_with_maximal_ids_does_not_overflow() {
let mut a = Story::with_text(StoryId(1), "Alpha", ParaFormat::default());
+1
View File
@@ -19,6 +19,7 @@ designcraft-compose = { workspace = true }
designcraft-render = { workspace = true }
designcraft-format = { workspace = true }
designcraft-epub = { workspace = true }
designcraft-pptx = { workspace = true }
designcraft-textimport = { workspace = true }
designcraft-pdf = { workspace = true }
designcraft-tools = { workspace = true }
+108 -9
View File
@@ -25,6 +25,9 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None,
"{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes} (no path: {text, bytes})",
has_doc, export_html),
cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None,
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})",
has_doc, export_pptx),
cmd!(noundo "file.exportText", "Export Text…", ["File"], None,
"{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})",
has_story_target, export_text),
@@ -69,6 +72,43 @@ fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
}
}
fn export_pptx(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "file.exportPptx";
let st = s.doc()?;
let line_breaks = match str_param(p, "lineBreaks") {
None | Some("keep") => designcraft_pptx::LineBreaks::Keep,
Some("reflow") => designcraft_pptx::LineBreaks::Reflow,
Some(x) => return Err(bad(ID, format!("unknown lineBreaks `{x}` (keep, reflow)"))),
};
let opts = designcraft_pptx::PptxOptions {
pages: pages_param(ID, p, st.doc.page_count())?,
line_breaks,
title: str_param(p, "title").map(str::to_string),
author: str_param(p, "author").map(str::to_string),
created: None,
embed_fonts: p.get("embedFonts").and_then(Value::as_bool).unwrap_or(true),
};
// Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame
// stays text: a picture of it would drop the rest of its story).
let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true);
let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, |it| {
!threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it)))
});
let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc);
let mut r = designcraft_pptx::export_pptx(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
if rasterized > 0 {
r.warnings.push(format!("{rasterized} object(s) went in as images"));
}
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings}))
}
None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})),
}
}
fn has_story_target(s: &Session) -> std::result::Result<(), String> {
super::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into())
}
@@ -240,26 +280,31 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
}
}
pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
const ID: &str = "file.exportPdf";
let pages = match p.get("pages") {
/// The `pages` parameter: a range string ("1-3,5"), a list or a number of 1-based positions.
fn pages_param(id: &str, p: &Value, page_count: usize) -> Result<Option<Vec<usize>>> {
Ok(match p.get("pages") {
None | Some(Value::Null) => None,
Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(ID, e.to_string()))?),
Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(id, e.to_string()))?),
Some(Value::Array(a)) => {
let mut v = Vec::new();
for x in a {
let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {x}")))?;
let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(id, format!("bad page {x}")))?;
v.push(n as usize - 1);
}
Some(v)
}
Some(Value::Number(n)) => {
let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {n}")))?;
let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(id, format!("bad page {n}")))?;
Some(vec![n as usize - 1])
}
Some(v) => return Err(bad(ID, format!("bad `pages`: {v}"))),
};
Some(v) => return Err(bad(id, format!("bad `pages`: {v}"))),
})
}
pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
const ID: &str = "file.exportPdf";
let pages = pages_param(ID, p, page_count)?;
let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false);
let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false);
let marks = match p.get("marks") {
@@ -1094,3 +1139,57 @@ mod print_group_tests {
assert!(t.contains("/CS/DeviceRGB") && designcraft_pdf::has_rgb_groups(t.as_bytes()));
}
}
#[cfg(test)]
mod pptx_tests {
use serde_json::json;
use crate::Session;
fn unzip(b64: &str) -> zip::ZipArchive<std::io::Cursor<Vec<u8>>> {
zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap()
}
fn read(z: &mut zip::ZipArchive<std::io::Cursor<Vec<u8>>>, name: &str) -> String {
let mut s = String::new();
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap();
s
}
#[test]
fn sample_exports_editable_slides() {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let r = s.execute("file.exportPptx", &json!({})).unwrap();
assert_eq!(r["slides"], 4);
let mut z = unzip(r["base64"].as_str().unwrap());
// The swatch table is a PowerPoint table; the cover picture is embedded; text is text.
assert!(read(&mut z, "ppt/slides/slide4.xml").contains("<a:tbl>"));
assert!(read(&mut z, "ppt/slides/slide1.xml").contains("<p:pic>"));
let text: String = read(&mut z, "ppt/slides/slide2.xml").split("<a:t>").skip(1).filter_map(|t| t.split("</a:t>").next()).collect();
assert!(text.contains("Notes on the Grid"), "{text}");
assert!(read(&mut z, "ppt/presentation.xml").contains("<p:embeddedFontLst>"));
let r = s.execute("file.exportPptx", &json!({"pages": "2-3", "embedFonts": false, "lineBreaks": "reflow"})).unwrap();
assert_eq!(r["slides"], 2);
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fonts are not embedded")));
assert!(s.execute("file.exportPptx", &json!({"lineBreaks": "wrap"})).is_err());
assert!(s.execute("file.exportPptx", &json!({"pages": [9]})).is_err());
}
#[test]
fn rasterize_option_places_a_picture() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap();
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap();
let shapes = |s: &mut Session| {
let r = s.execute("file.exportPptx", &json!({})).unwrap();
read(&mut unzip(r["base64"].as_str().unwrap()), "ppt/slides/slide1.xml")
};
assert!(shapes(&mut s).contains("prst=\"ellipse\""));
s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true})).unwrap();
let x = shapes(&mut s);
assert!(x.contains("<p:pic>") && !x.contains("prst=\"ellipse\""), "{x}");
}
}
+31 -1
View File
@@ -124,7 +124,13 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
|s, p| {
let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize;
let angle = p.get("angle").and_then(Value::as_i64).unwrap_or(90);
let angle = match p.get("angle") {
None => 90,
Some(v) => v.as_i64().ok_or_else(|| bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees"))?,
};
if angle % 90 != 0 {
return Err(bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees"));
}
s.edit(|d, _| {
let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.rotateSpreadView", "no such spread"))?;
let pg = std::sync::Arc::make_mut(sp).pages.first_mut().ok_or_else(|| bad("layout.rotateSpreadView", "empty spread"))?;
@@ -1234,3 +1240,27 @@ mod section_side_tests {
assert!(sp.iter().all(|p| p.len() == 1 && p[0].1 == 'S'), "{sp:?}");
}
}
#[cfg(test)]
mod spread_rotation_input_tests {
use serde_json::json;
#[test]
fn quarter_turns_work_and_other_angles_are_rejected_without_mutation() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({})).unwrap();
let rotation = |s: &crate::Session| s.doc().unwrap().doc.spreads[0].pages[0].view_rotation;
for bad in [json!(45), json!(-45), json!(91), json!("90"), json!(0.5)] {
assert!(s.execute("layout.rotateSpreadView", &json!({"angle": bad})).is_err(), "{bad}");
assert_eq!(rotation(&s), 0, "invalid input must not change the view");
}
s.execute("layout.rotateSpreadView", &json!({"angle": 90})).unwrap();
assert_eq!(rotation(&s), 1);
s.execute("layout.rotateSpreadView", &json!({"angle": -90})).unwrap();
assert_eq!(rotation(&s), 0);
s.execute("layout.rotateSpreadView", &json!({"angle": 180})).unwrap();
assert_eq!(rotation(&s), 2);
s.execute("layout.rotateSpreadView", &json!({"angle": 0})).unwrap();
assert_eq!(rotation(&s), 0, "zero resets the spread view");
}
}
+1 -1
View File
@@ -615,7 +615,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fitting",
["Object", "Fitting"],
None,
"{mode: fillProportionally|fitProportionally|fitContentToFrame|centerContent|fitFrameToContent, ids?}",
"{mode: fillProportionally (preserves aspect ratio, covers frame, clips overflow)|fitProportionally (preserves aspect ratio, shows whole image, may leave empty space)|fitContentToFrame (stretches content to frame)|centerContent (keeps current size, positions at fitting alignment)|fitFrameToContent (resizes frame to content), ids?}",
has_selection,
fit
),
+68 -21
View File
@@ -118,6 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
edit_char
),
cmd!("style.character.delete", "Delete Character Style", [], None, "{name, replaceWith?}", has_doc, delete_char),
cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| {
let st = s.doc()?;
Ok(json!({
@@ -517,16 +518,37 @@ fn edit_para(s: &mut Session, p: &Value) -> Result<Value> {
/// Rename a paragraph or character style everywhere it's used.
fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) {
let st = d.styles_mut();
if para {
for s in &mut st.paragraph {
for s in &mut d.styles_mut().paragraph {
if s.name == from {
s.name = to.to_string();
}
for r in [&mut s.based_on, &mut s.next_style] {
if r.as_deref() == Some(from) {
*r = Some(to.to_string());
}
}
} else {
for s in &mut d.styles_mut().character {
if s.name == from {
s.name = to.to_string();
}
}
}
redirect_style_refs(d, para, from, to);
}
/// Point every reference to style `from` at `to`, except the style's own registry entry (its
/// `name` field) — shared by renaming (which also updates the entry's name) and deletion (which
/// instead removes the entry, after its former uses point at the replacement).
fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) {
let st = d.styles_mut();
// A style is never based on itself: when `to` was based on `from`, it takes `from`'s parent.
let parent_of = |based_on: Option<&String>| based_on.filter(|b| b.as_str() != to).cloned();
if para {
let from_parent = parent_of(st.paragraph.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref()));
for s in &mut st.paragraph {
if s.based_on.as_deref() == Some(from) {
s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) };
}
if s.next_style.as_deref() == Some(from) {
s.next_style = Some(to.to_string());
}
}
for o in &mut st.object {
@@ -535,12 +557,10 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
}
}
} else {
let from_parent = parent_of(st.character.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref()));
for s in &mut st.character {
if s.name == from {
s.name = to.to_string();
}
if s.based_on.as_deref() == Some(from) {
s.based_on = Some(to.to_string());
s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) };
}
}
for s in &mut st.paragraph {
@@ -570,7 +590,8 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
}
}
/// Point the nested, nested line and GREP styles of paragraph attributes at a renamed character style.
/// Point the nested, nested line and GREP styles, and the drop cap style, of paragraph
/// attributes at a renamed (or deleted, pointed at its replacement) character style.
fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) {
let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style);
let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style));
@@ -579,6 +600,9 @@ fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) {
*n = to.to_string();
}
}
if p.drop_cap_style.as_deref() == Some(from) {
p.drop_cap_style = Some(to.to_string());
}
}
fn group_styles(s: &mut Session, p: &Value) -> Result<Value> {
@@ -616,22 +640,23 @@ fn group_styles(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete_para(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("").to_string();
let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad("style.paragraph.delete", "missing name"))?.to_string();
let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::BASIC_PARAGRAPH).to_string();
if name.starts_with('[') {
return Err(bad("style.paragraph.delete", "built-in styles can't be deleted"));
}
if repl == name {
return Err(bad("style.paragraph.delete", "`replaceWith` can't be the deleted style"));
}
s.edit(|d, _| {
d.styles_mut().paragraph.retain(|x| x.name != name);
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
if let Some(story) = d.story_mut(sid) {
for f in &mut story.paras {
if f.style == name {
f.style = repl.clone();
}
}
}
if d.styles.para(&name).is_none() {
return Err(bad("style.paragraph.delete", format!("no paragraph style `{name}`")));
}
if d.styles.para(&repl).is_none() {
return Err(bad("style.paragraph.delete", format!("no paragraph style `{repl}`")));
}
redirect_style_refs(d, true, &name, &repl);
d.styles_mut().paragraph.retain(|x| x.name != name);
ok()
})
}
@@ -723,6 +748,28 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn delete_char(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad("style.character.delete", "missing name"))?.to_string();
let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string();
if name.starts_with('[') {
return Err(bad("style.character.delete", "built-in styles can't be deleted"));
}
if repl == name {
return Err(bad("style.character.delete", "`replaceWith` can't be the deleted style"));
}
s.edit(|d, _| {
if d.styles.char_style(&name).is_none() {
return Err(bad("style.character.delete", format!("no character style `{name}`")));
}
if d.styles.char_style(&repl).is_none() {
return Err(bad("style.character.delete", format!("no character style `{repl}`")));
}
redirect_style_refs(d, false, &name, &repl);
d.styles_mut().character.retain(|x| x.name != name);
ok()
})
}
fn create_swatch(s: &mut Session, p: &Value) -> Result<Value> {
let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color"))?;
let color = parse_color(c).ok_or_else(|| bad("swatch.create", "bad color"))?;
+1 -1
View File
@@ -160,7 +160,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paragraph Formatting",
[],
None,
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
has_text_or_frames,
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
),
+178
View File
@@ -570,6 +570,184 @@ fn commands_take_non_object_params_without_panicking() {
}
}
#[test]
fn paragraph_style_edit_persists_attrs_and_rename() {
let mut s = session();
let name =
s.execute("style.paragraph.create", &json!({"name": "Body Copy", "para": {"align": "left"}})).unwrap()["name"].as_str().unwrap().to_string();
s.execute("style.paragraph.edit", &json!({"name": name, "para": {"align": "center"}})).unwrap();
{
let st = s.doc().unwrap();
let style = st.doc.styles.para(&name).expect("style still exists");
assert_eq!(style.para.align, Some(designcraft_doc::Align::Center), "edited attribute persisted");
}
// Renaming updates the style definition itself, and every place it's referenced.
s.execute("style.paragraph.edit", &json!({"name": name, "rename": "Body Text"})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.para("Body Text").is_some(), "renamed style exists under the new name");
assert!(st.doc.styles.para(&name).is_none(), "old name is gone");
}
#[test]
fn paragraph_style_edit_rejects_based_on_cycle() {
let mut s = session();
let a = s.execute("style.paragraph.create", &json!({"name": "A"})).unwrap()["name"].as_str().unwrap().to_string();
let b = s.execute("style.paragraph.create", &json!({"name": "B", "basedOn": a})).unwrap()["name"].as_str().unwrap().to_string();
let r = s.execute("style.paragraph.edit", &json!({"name": a, "basedOn": b}));
assert!(r.is_err(), "A based on B based on A must be rejected");
}
#[test]
fn paragraph_style_delete_reassigns_affected_text() {
let mut s = session();
let name = s.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string();
let sid = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap();
s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": name})).unwrap();
{
let st = s.doc().unwrap();
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists");
assert_eq!(story.paras[0].style, name, "style applied before delete");
}
s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": designcraft_doc::BASIC_PARAGRAPH})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.para(&name).is_none(), "deleted style is gone");
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists");
assert_eq!(story.paras[0].style, designcraft_doc::BASIC_PARAGRAPH, "affected paragraph reassigned to the replacement style");
}
#[test]
fn paragraph_style_delete_rejects_builtin_styles() {
let mut s = session();
let r = s.execute("style.paragraph.delete", &json!({"name": designcraft_doc::BASIC_PARAGRAPH}));
assert!(r.is_err(), "built-in styles can't be deleted");
}
#[test]
fn character_style_edit_persists_attrs_and_rename() {
let mut s = session();
let name = s.execute("style.character.create", &json!({"name": "Key Term", "chars": {"fontStyle": "Bold"}})).unwrap()["name"]
.as_str()
.unwrap()
.to_string();
s.execute("style.character.edit", &json!({"name": name, "chars": {"tracking": 20.0}})).unwrap();
{
let st = s.doc().unwrap();
let style = st.doc.styles.char_style(&name).expect("style still exists");
assert_eq!(style.chars.tracking, Some(20.0), "edited attribute persisted");
}
// Renaming updates the style definition itself, and every place it's referenced.
s.execute("style.character.edit", &json!({"name": name, "rename": "Emphasis"})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.char_style("Emphasis").is_some(), "renamed style exists under the new name");
assert!(st.doc.styles.char_style(&name).is_none(), "old name is gone");
}
#[test]
fn character_style_edit_rejects_based_on_cycle() {
let mut s = session();
let a = s.execute("style.character.create", &json!({"name": "A"})).unwrap()["name"].as_str().unwrap().to_string();
let b = s.execute("style.character.create", &json!({"name": "B", "basedOn": a})).unwrap()["name"].as_str().unwrap().to_string();
let r = s.execute("style.character.edit", &json!({"name": a, "basedOn": b}));
assert!(r.is_err(), "A based on B based on A must be rejected");
}
#[test]
fn character_style_delete_reassigns_affected_text() {
let mut s = session();
let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
s.execute("style.character.apply", &json!({"name": name})).unwrap();
{
let st = s.doc().unwrap();
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists");
assert_eq!(story.chars[0].format.style, name, "style applied before delete");
}
s.execute("style.character.delete", &json!({"name": name, "replaceWith": designcraft_doc::NO_CHAR_STYLE})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.char_style(&name).is_none(), "deleted style is gone");
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists");
assert_eq!(story.chars[0].format.style, designcraft_doc::NO_CHAR_STYLE, "affected run reassigned to the replacement style");
}
#[test]
fn character_style_delete_rejects_builtin_styles() {
let mut s = session();
let r = s.execute("style.character.delete", &json!({"name": designcraft_doc::NO_CHAR_STYLE}));
assert!(r.is_err(), "built-in styles can't be deleted");
}
#[test]
fn character_style_delete_rejects_missing_unknown_and_self_replacement() {
let mut s = session();
let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
assert!(s.execute("style.character.delete", &json!({"name": ""})).is_err(), "empty name is rejected");
assert!(s.execute("style.character.delete", &json!({"name": "No Such Style"})).is_err(), "unknown name is rejected");
assert!(s.execute("style.character.delete", &json!({"name": name, "replaceWith": "No Such Style"})).is_err(), "unknown replaceWith is rejected");
assert!(s.execute("style.character.delete", &json!({"name": name, "replaceWith": name})).is_err(), "replaceWith can't be the deleted style");
}
#[test]
fn paragraph_style_delete_rejects_missing_unknown_and_self_replacement() {
let mut s = session();
let name = s.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string();
assert!(s.execute("style.paragraph.delete", &json!({"name": ""})).is_err(), "empty name is rejected");
assert!(s.execute("style.paragraph.delete", &json!({"name": "No Such Style"})).is_err(), "unknown name is rejected");
assert!(s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": "No Such Style"})).is_err(), "unknown replaceWith is rejected");
assert!(s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": name})).is_err(), "replaceWith can't be the deleted style");
}
#[test]
fn deleting_a_style_never_leaves_its_replacement_based_on_itself() {
let mut s = session();
let para = |s: &Session, n: &str| s.doc().unwrap().doc.styles.para(n).unwrap().based_on.clone();
s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Head"})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Minor", "basedOn": "Sub"})).unwrap();
// Minor is based on Sub; deleting Sub in favour of Minor gives Minor Sub's parent.
s.execute("style.paragraph.delete", &json!({"name": "Sub", "replaceWith": "Minor"})).unwrap();
assert_eq!(para(&s, "Minor").as_deref(), Some("Head"));
s.execute("style.character.create", &json!({"name": "Strong"})).unwrap();
s.execute("style.character.create", &json!({"name": "Stronger", "basedOn": "Strong"})).unwrap();
s.execute("style.character.delete", &json!({"name": "Strong", "replaceWith": "Stronger"})).unwrap();
let based = s.doc().unwrap().doc.styles.character.iter().find(|c| c.name == "Stronger").unwrap().based_on.clone();
assert_ne!(based.as_deref(), Some("Stronger"));
}
#[test]
fn character_style_delete_reassigns_every_kind_of_reference() {
let mut s = session();
let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
let repl = s.execute("style.character.create", &json!({"name": "Emphasis"})).unwrap()["name"].as_str().unwrap().to_string();
// Another character style's basedOn.
let based = s.execute("style.character.create", &json!({"name": "Strong", "basedOn": name})).unwrap()["name"].as_str().unwrap().to_string();
// A paragraph style's nested, nested line, GREP and drop cap styles, and the footnote reference style.
s.execute(
"style.paragraph.create",
&json!({"name": "Body", "para": {
"nestedStyles": [{"style": name, "through": true, "count": 1, "until": {"kind": "words"}}],
"grepStyles": [{"style": name, "pattern": "x"}],
"nestedLineStyles": [{"style": name, "lines": 1}],
"dropCapStyle": name}}),
)
.unwrap();
s.execute("footnote.options", &json!({"refCharStyle": name})).unwrap();
s.execute("style.character.delete", &json!({"name": name, "replaceWith": repl})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.styles.char_style(&name).is_none(), "deleted style is gone");
assert_eq!(st.doc.styles.char_style(&based).unwrap().based_on.as_deref(), Some(repl.as_str()), "basedOn redirected");
let body = st.doc.styles.para("Body").unwrap();
assert_eq!(body.para.nested_styles.as_ref().unwrap()[0].style, repl, "nested style redirected");
assert_eq!(body.para.nested_line_styles.as_ref().unwrap()[0].style, repl, "nested line style redirected");
assert_eq!(body.para.grep_styles.as_ref().unwrap()[0].style, repl, "grep style redirected");
assert_eq!(body.para.drop_cap_style.as_deref(), Some(repl.as_str()), "drop cap style redirected");
assert_eq!(st.doc.footnote_options.ref_char_style, repl, "footnote reference style redirected");
}
#[test]
fn page_tool_selects_a_page_and_its_handles_preview_or_resize_it() {
use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind};
+14 -6
View File
@@ -668,7 +668,7 @@ fn file_face_names(path: &std::path::Path) -> Vec<ScannedFace> {
let font = crate::group::sfnt(found.clone())?;
let f = skrifa::FontRef::new(&font).ok()?;
let (family, style) = face_names(&f)?;
let (group, native) = match crate::group::declared_group(&f) {
let (group, native) = match crate::group::declared_group(&f, &family) {
Some(g) => (g, crate::group::classify(&f, &family).1),
None => {
found.extend(table(b"cmap", MAX_CMAP));
@@ -719,15 +719,23 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
}
} else {
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
if let Some(h) = &home {
dirs.push(h.join(".fonts"));
dirs.push(h.join(".local/share/fonts"));
}
let xdg_data_home = std::env::var_os("XDG_DATA_HOME").map(std::path::PathBuf::from);
dirs.extend(unix_font_dirs(home.as_deref(), xdg_data_home.as_deref()));
}
dirs
}
fn unix_font_dirs(home: Option<&std::path::Path>, xdg_data_home: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
let mut dirs: Vec<std::path::PathBuf> = ["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into).into();
if let Some(home) = home {
dirs.push(home.join(".fonts"));
}
let data_home =
xdg_data_home.filter(|path| path.is_absolute()).map(std::path::Path::to_path_buf).or_else(|| home.map(|path| path.join(".local/share")));
dirs.extend(data_home.map(|path| path.join("fonts")));
dirs
}
/// One face found in a font file: index, family, style and (variable fonts) the named instance's
/// axis settings.
type Found = (u32, String, String, Vec<([u8; 4], f32)>);
+63 -10
View File
@@ -141,10 +141,10 @@ fn name_group(f: &skrifa::FontRef<'_>) -> Option<FontGroup> {
/// Name IDs of the family name: typographic family, then family.
const FAMILY_IDS: [u16; 2] = [16, 1];
/// From the OS/2 code page ranges: `Some(Some(group))` for the CJK code pages (JIS, Chinese
/// Simplified, Korean Wansung, Chinese Traditional, Korean Johab), `Some(None)` when the font
/// lists code pages but no CJK one, `None` when it lists none.
fn code_page_group(f: &skrifa::FontRef<'_>) -> Option<Option<FontGroup>> {
/// The CJK groups of the OS/2 code page ranges the font lists (JIS, Chinese Simplified, Korean
/// Wansung, Chinese Traditional, Korean Johab), in menu order: empty when it lists code pages
/// but no CJK one, `None` when it lists none at all.
fn code_page_groups(f: &skrifa::FontRef<'_>) -> Option<Vec<FontGroup>> {
let os2 = f.os2().ok()?;
let (r1, r2) = (os2.ul_code_page_range_1()?, os2.ul_code_page_range_2().unwrap_or(0));
if r1 == 0 && r2 == 0 {
@@ -157,17 +157,37 @@ fn code_page_group(f: &skrifa::FontRef<'_>) -> Option<Option<FontGroup>> {
(20, FontGroup::ChineseTraditional),
(21, FontGroup::Korean),
];
Some(first(bits.into_iter().filter(|(b, _)| r1 & (1 << b) != 0).map(|(_, g)| g)))
let mut groups: Vec<FontGroup> = bits.into_iter().filter(|(b, _)| r1 & (1 << b) != 0).map(|(_, g)| g).collect();
groups.sort();
groups.dedup();
Some(groups)
}
/// The CJK group a family name's region tag names, as the pan-CJK families carry one per
/// language ("Noto Sans CJK SC", "Source Han Serif TC", "PingFang HK", "Hiragino Sans GB"): a
/// whole word of the name, in capitals.
fn family_tag_group(family: &str) -> Option<FontGroup> {
family.split(|c: char| c.is_whitespace() || c == '-' || c == '_').find_map(|word| match word {
"JP" => Some(FontGroup::Japanese),
"SC" | "CN" | "GB" => Some(FontGroup::ChineseSimplified),
"TC" | "HK" | "TW" | "MO" | "HC" => Some(FontGroup::ChineseTraditional),
"KR" => Some(FontGroup::Korean),
_ => None,
})
}
/// The group the font declares: its `meta` languages, then its `name` table's languages, then its
/// code pages (the strongest signal first). `None` when it declares nothing at all: no CJK
/// language and no code pages.
pub(crate) fn declared_group(f: &skrifa::FontRef<'_>) -> Option<FontGroup> {
/// code pages (the strongest signal first). A font that lists the code pages of several CJK
/// languages (one of a pan-CJK family, covering them all) is the one `family`'s region tag
/// names, else the first in menu order. `None` when it declares nothing at all: no CJK language
/// and no code pages.
pub(crate) fn declared_group(f: &skrifa::FontRef<'_>, family: &str) -> Option<FontGroup> {
if let Some(g) = meta_group(f).or_else(|| name_group(f)) {
return Some(g);
}
code_page_group(f).map(|g| g.unwrap_or(FontGroup::Western))
let pages = code_page_groups(f)?;
let tagged = if pages.len() > 1 { family_tag_group(family).filter(|g| pages.contains(g)) } else { None };
Some(tagged.or_else(|| pages.first().copied()).unwrap_or(FontGroup::Western))
}
/// The group by what the font covers: kana is Japanese, Hangul Korean, ideographs alone
@@ -190,7 +210,7 @@ pub(crate) fn covered_group(f: &skrifa::FontRef<'_>) -> FontGroup {
/// The font's group and, for a CJK group, its family name in that language (typographic family,
/// else family) when it differs from `family`.
pub(crate) fn classify(f: &skrifa::FontRef<'_>, family: &str) -> (FontGroup, Option<String>) {
let group = declared_group(f).unwrap_or_else(|| covered_group(f));
let group = declared_group(f, family).unwrap_or_else(|| covered_group(f));
(group, native_family(f, group).filter(|n| n != family))
}
@@ -253,6 +273,39 @@ mod tests {
assert_eq!(group_of(&latin(1 << 20 | 1 << 21)).0, FontGroup::ChineseTraditional);
}
/// A pan-CJK family's fonts list the code pages of every language they cover and name their
/// language only in the family name ("… CJK SC"): that tag decides, not the menu order.
#[test]
fn a_region_tag_in_the_family_name_decides_between_several_code_pages() {
let pan = 1 << 17 | 1 << 18 | 1 << 19 | 1 << 20 | 1 << 21;
let group = |family: &str, range1: u32| {
let font = with_code_pages(font_with(family, &['a', 'b']).unwrap(), range1, 0).unwrap();
classify(&skrifa::FontRef::new(&font).unwrap(), family).0
};
for (family, expected) in [
("DC Sans CJK SC R", FontGroup::ChineseSimplified),
("DC Sans CJK SC", FontGroup::ChineseSimplified),
("DC Sans GB", FontGroup::ChineseSimplified),
("DC Serif CJK TC", FontGroup::ChineseTraditional),
("DC Sans HK", FontGroup::ChineseTraditional),
("DC Sans CJK KR", FontGroup::Korean),
("DC Sans CJK JP", FontGroup::Japanese),
("DC-Sans-SC", FontGroup::ChineseSimplified),
// No tag, or not a whole word in capitals: the first in menu order, as before.
("DC Sans", FontGroup::Japanese),
("DC Script", FontGroup::Japanese),
("DC Sans Sc", FontGroup::Japanese),
] {
assert_eq!(group(family, pan), expected, "{family}");
}
// The tag names a language the font doesn't list: its code pages decide.
assert_eq!(group("DC Sans SC", 1 << 17 | 1 << 19), FontGroup::Japanese);
// One CJK code page is unambiguous, whatever the name says.
assert_eq!(group("DC Sans SC", 1 << 17), FontGroup::Japanese);
// No CJK code page: the tag alone doesn't make a font CJK.
assert_eq!(group("DC Sans SC", 1), FontGroup::Western);
}
#[test]
fn name_languages_give_the_group_and_the_native_name() {
let jp = named(&latin(1), &[(3, 1, 0x0411, 1, "テスト明朝"), (3, 1, 0x0411, 16, "テスト明朝 Pro")]);
+58
View File
@@ -6,6 +6,64 @@ use super::*;
const FAMILY: &str = "Sysfont Sans3";
#[cfg(not(any(target_os = "macos", windows)))]
mod user_font_dirs {
use super::*;
#[test]
fn user_font_dirs_when_xdg_is_absolute() {
let home = Path::new("/home/font-user");
let xdg = Path::new("/custom/data");
let dirs = unix_font_dirs(Some(home), Some(xdg));
assert_eq!(dirs, ["/usr/share/fonts", "/usr/local/share/fonts", "/home/font-user/.fonts", "/custom/data/fonts"].map(PathBuf::from));
}
#[test]
fn user_font_dirs_when_xdg_is_unset() {
let home = Path::new("/home/font-user");
let dirs = unix_font_dirs(Some(home), None);
assert_eq!(
dirs,
["/usr/share/fonts", "/usr/local/share/fonts", "/home/font-user/.fonts", "/home/font-user/.local/share/fonts"].map(PathBuf::from)
);
}
#[test]
fn user_font_dirs_when_xdg_is_relative() {
let home = Path::new("/home/font-user");
let xdg = Path::new("relative/data");
let dirs = unix_font_dirs(Some(home), Some(xdg));
assert_eq!(
dirs,
["/usr/share/fonts", "/usr/local/share/fonts", "/home/font-user/.fonts", "/home/font-user/.local/share/fonts"].map(PathBuf::from)
);
}
#[test]
fn user_font_dirs_when_xdg_is_empty() {
let home = Path::new("/home/font-user");
let xdg = Path::new("");
let dirs = unix_font_dirs(Some(home), Some(xdg));
assert_eq!(
dirs,
["/usr/share/fonts", "/usr/local/share/fonts", "/home/font-user/.fonts", "/home/font-user/.local/share/fonts"].map(PathBuf::from)
);
}
#[test]
fn user_font_dirs_when_home_is_unset_and_xdg_is_absolute() {
let xdg = Path::new("/custom/data");
let dirs = unix_font_dirs(None, Some(xdg));
assert_eq!(dirs, ["/usr/share/fonts", "/usr/local/share/fonts", "/custom/data/fonts"].map(PathBuf::from));
}
#[test]
fn user_font_dirs_when_home_and_xdg_are_unset() {
let dirs = unix_font_dirs(None, None);
assert_eq!(dirs, ["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from));
}
}
/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name).
fn renamed(file: &str) -> Vec<u8> {
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
+35 -1
View File
@@ -110,6 +110,10 @@ impl Unit {
/// Ruler tick spacing (major, subdivisions) in points for a given zoom (screen px per pt).
pub fn ruler_ticks(self, zoom: f64) -> (f64, u32) {
// A non-positive zoom can keep the major-spacing loop growing forever. Clamp
// extreme positive values too, so saved/corrupt viewport state cannot overflow
// the spacing calculation before the view restores a usable zoom.
let zoom = if zoom.is_finite() && zoom > 0.0 { zoom.clamp(1e-6, 1e6) } else { 1.0 };
let (base, subs): (f64, &[u32]) = match self {
Unit::Inches | Unit::InchesDecimal => (PT_PER_INCH, &[8, 4, 2]),
Unit::Picas => (PT_PER_PICA, &[6, 3, 2]),
@@ -270,7 +274,12 @@ pub fn format_measure_prec(pt: f64, unit: Unit, decimals: usize) -> String {
let a = pt.abs();
let mut whole = (a / big).floor();
let mut rest = (a - whole * big) / small;
if (rest - 12.0).abs() < 1e-6 || rest > 12.0 - 1e-6 {
// Carry when the displayed fractional points round up to a full pica/cicero.
// Checking unrounded rest misses values such as 11.9999 at 3 decimals,
// which would otherwise render as the misleading "0p12".
let scale = 10.0_f64.powi(decimals.min(12) as i32);
rest = (rest * scale).round() / scale;
if rest >= 12.0 {
whole += 1.0;
rest = 0.0;
}
@@ -335,6 +344,17 @@ mod tests {
assert!(parse_measure("50%", Unit::Points).is_err());
}
#[test]
fn pica_and_cicero_rounding_carries_into_the_major_unit() {
assert_eq!(format_measure_prec(11.9999, Unit::Picas, 3), "1p0");
assert_eq!(format_measure_prec(-11.9999, Unit::Picas, 3), "-1p0");
assert_eq!(format_measure_prec(11.9994, Unit::Picas, 3), "0p11.999");
assert_eq!(format_measure_prec(11.6, Unit::Picas, 0), "1p0");
let almost_cicero = PT_PER_CICERO - 0.0001 * PT_PER_DIDOT;
assert_eq!(format_measure_prec(almost_cicero, Unit::Ciceros, 3), "1c0");
assert!(close(p(&format_measure_prec(11.9999, Unit::Picas, 3)), 12.0));
}
#[test]
fn formatting() {
assert_eq!(format_measure(18.0, Unit::Picas), "1p6");
@@ -357,6 +377,20 @@ mod tests {
}
}
#[test]
fn ruler_ticks_reject_invalid_zoom_without_hanging_or_overflowing() {
for unit in Unit::ALL {
let fallback = unit.ruler_ticks(1.0);
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, -0.0, 0.0] {
assert_eq!(unit.ruler_ticks(bad), fallback, "{unit:?}: {bad}");
}
for extreme in [f64::MIN_POSITIVE, 1e-300, 1e300, f64::MAX] {
let (major, subdivisions) = unit.ruler_ticks(extreme);
assert!(major.is_finite() && major > 0.0 && subdivisions > 0, "{unit:?}: {extreme}");
}
}
}
#[test]
fn ruler_ticks_are_reasonable() {
for u in Unit::ALL {
+17 -10
View File
@@ -1927,6 +1927,15 @@ impl<'r> Importer<'r> {
t
}
/// The format of the text in a `CharacterStyleRange`: its style, and its local attributes over
/// the enclosing paragraph range's.
fn range_format(&mut self, c: &El, pchars: &CharAttrs) -> CharFormat {
let style = c.get("AppliedCharacterStyle").map(|r| self.char_style_ref(r)).unwrap_or_else(|| st::NO_CHAR_STYLE.into());
let mut over = pchars.clone();
over.merge(&self.char_attrs(c));
CharFormat { style, over }
}
fn walk_story(&mut self, e: &El, b: &mut StoryBuilder, pf: &ParaFormat, pchars: &CharAttrs, cf: &CharFormat, brk: Option<&str>) {
for n in &e.children {
match n {
@@ -1941,13 +1950,13 @@ impl<'r> Importer<'r> {
{
*last = npf.clone();
}
self.walk_story(c, b, &npf, &chars, cf, None);
// Text and markers outside any character range (a destination or a
// cross-reference source at paragraph level) take the paragraph's format.
let pcf = CharFormat { style: st::NO_CHAR_STYLE.into(), over: chars.clone() };
self.walk_story(c, b, &npf, &chars, &pcf, None);
}
"CharacterStyleRange" => {
let style = c.get("AppliedCharacterStyle").map(|r| self.char_style_ref(r)).unwrap_or_else(|| st::NO_CHAR_STYLE.into());
let mut over = pchars.clone();
over.merge(&self.char_attrs(c));
let ncf = CharFormat { style, over };
let ncf = self.range_format(c, pchars);
let bt = c.get("ParagraphBreakType").filter(|t| *t != "Anywhere");
b.last = ncf.clone();
self.walk_story(c, b, pf, pchars, &ncf, bt);
@@ -2011,11 +2020,9 @@ impl<'r> Importer<'r> {
"CrossReferenceSource" => {
// The generated text is regenerated from the format.
b.xrefs.push((c.get("Self").unwrap_or("").to_string(), c.get("AppliedFormat").unwrap_or("").to_string()));
let inner = c.find("CharacterStyleRange");
let f = match inner.and_then(|r| r.get("AppliedCharacterStyle")) {
Some(r) => CharFormat { style: self.char_style_ref(r), over: pchars.clone() },
None => cf.clone(),
};
// A source at paragraph level wraps a range; inside a range it holds the
// text directly and takes that range's format.
let f = c.find("CharacterStyleRange").map_or_else(|| cf.clone(), |r| self.range_format(r, pchars));
b.push(&designcraft_doc::XREF_MARK.to_string(), &f);
}
"Footnote" => {
+38
View File
@@ -763,6 +763,44 @@ fn round_trips_cross_references() {
assert_eq!(back.xref_formats.len(), d.xref_formats.len());
}
/// A cross-reference source sits either inside a character range (holding the text directly) or at
/// paragraph level around a range. Both give the generated text the paragraph's and the range's
/// attributes, so both set the same text at the same width.
#[test]
fn cross_reference_source_nesting_keeps_formatting() {
let story = |body: &str| {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1"><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle" PointSize="11">{body}</ParagraphStyleRange></Story></idPkg:Story>"#
)
};
const CSR: &str = r#"<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" Tracking="200">"#;
let inside = story(&format!(
r#"{CSR}<Content>See section </Content><CrossReferenceSource Self="x1" AppliedFormat="n"><Content>11.</Content></CrossReferenceSource><Content>, then a few more words to wrap.</Content></CharacterStyleRange>"#
));
let around = story(&format!(
r#"{CSR}<Content>See section </Content></CharacterStyleRange><CrossReferenceSource Self="x1" AppliedFormat="n">{CSR}<Content>11.</Content></CharacterStyleRange></CrossReferenceSource>{CSR}<Content>, then a few more words to wrap.</Content></CharacterStyleRange>"#
));
let compose = |xml: &str| {
let d = import_idml(&fixture_with_story(xml)).unwrap();
d.check().unwrap();
let s = d.stories.values().find(|s| !s.xrefs.is_empty()).expect("story with a cross-reference").clone();
let cs = designcraft_compose::compose_story(&d, s.id, &Default::default());
let glyphs: Vec<_> = cs.frames.iter().flat_map(|f| &f.lines).flat_map(|l| l.glyphs.iter().map(move |g| (g.byte, g.x, l.baseline))).collect();
(s, glyphs)
};
let (a, ga) = compose(&inside);
let (b, gb) = compose(&around);
assert!(ga.len() > a.text.chars().count() / 2, "the story composes");
assert_eq!(a.text, b.text);
assert_eq!(a.chars, b.chars);
assert_eq!(a.chars.len(), 1, "the generated text shares its neighbours' format");
assert_eq!(a.chars[0].format.over.tracking, Some(200.0));
assert_eq!(a.chars[0].format.over.size, Some(11.0));
assert_eq!(ga, gb);
}
#[test]
fn round_trips_index_references() {
use designcraft_doc::index::IndexRange;
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "designcraft-pptx"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DesignCraft PowerPoint (PPTX) export: pages as slides of editable shapes, pictures and text"
[lints]
workspace = true
[dependencies]
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
designcraft-doc = { workspace = true }
designcraft-fonts = { workspace = true }
designcraft-compose = { workspace = true }
designcraft-images = { workspace = true }
skrifa = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
zip = { workspace = true }
[dev-dependencies]
designcraft-fonts = { workspace = true, features = ["testing"] }
quick-xml = { workspace = true }
+221
View File
@@ -0,0 +1,221 @@
//! Font names as PowerPoint looks them up, and nominal advances to measure its line widths.
use std::collections::HashMap;
use designcraft_fonts::FontFace;
use skrifa::MetadataProvider;
use skrifa::string::StringId;
/// How a run names its face: the legacy (style-linked) family plus bold and italic flags, which
/// is how Office finds a face (`Source Serif 4 Semibold` is its own family; `Bold` is a flag).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunFont {
pub family: String,
pub bold: bool,
pub italic: bool,
}
#[derive(Default)]
pub struct Fonts {
names: HashMap<u32, RunFont>,
cmaps: HashMap<u32, HashMap<char, u32>>,
/// Glyph → the lowest character mapping to it.
reverse: HashMap<u32, HashMap<u32, char>>,
/// Faces the slides use, by (family, bold, italic), in first-use order.
pub used: Vec<(RunFont, designcraft_fonts::FaceRef)>,
}
fn english(face: &FontFace, id: StringId) -> Option<String> {
let f = face.skrifa()?;
let mut first = None;
for s in f.localized_strings(id) {
let text: String = s.chars().collect();
if text.trim().is_empty() {
continue;
}
if s.language().is_none_or(|l| l.starts_with("en")) {
return Some(text);
}
first.get_or_insert(text);
}
first
}
impl Fonts {
/// The run font for a face a slide uses (recorded for embedding).
pub fn use_face(&mut self, face: designcraft_fonts::FaceRef) -> RunFont {
let rf = self.run_font(face.get());
if !self.used.iter().any(|(u, _)| *u == rf) {
self.used.push((rf.clone(), face));
}
rf
}
/// Embedded OpenType for every family used, and the faces that couldn't be embedded.
pub fn embedded(&self) -> (Vec<EmbeddedFont>, Vec<String>) {
let mut out: Vec<EmbeddedFont> = Vec::new();
let mut skipped = Vec::new();
for (rf, face) in &self.used {
let face = face.get();
match eot(face) {
Ok(bytes) => {
let slot = usize::from(rf.bold) + 2 * usize::from(rf.italic);
let at = match out.iter().position(|e| e.family == rf.family) {
Some(i) => i,
None => {
out.push(EmbeddedFont { family: rf.family.clone(), ..Default::default() });
out.len() - 1
}
};
if let Some(e) = out.get_mut(at)
&& let Some(s) = e.faces.get_mut(slot)
{
*s = Some(bytes);
}
}
Err(why) => skipped.push(format!(
"{} {} ({})",
face.family,
face.style,
if why == NoEmbed::Restricted { "its licence forbids embedding" } else { "not a TrueType font" }
)),
}
}
(out, skipped)
}
pub fn run_font(&mut self, face: &FontFace) -> RunFont {
self.names
.entry(face.id())
.or_insert_with(|| {
let family = english(face, StringId::FAMILY_NAME).unwrap_or_else(|| face.family.clone());
let sub = english(face, StringId::SUBFAMILY_NAME).unwrap_or_else(|| face.style.clone()).to_ascii_lowercase();
// A named instance of a variable font has no legacy names of its own.
if face.is_variable() {
let style = face.style.to_ascii_lowercase();
let bold = face.weight >= 650.0;
let regular = matches!(style.as_str(), "regular" | "italic" | "bold" | "bold italic");
let family = if regular { face.family.clone() } else { format!("{} {}", face.family, face.style.replace(" Italic", "")) };
return RunFont { family, bold: bold && regular, italic: face.italic };
}
RunFont { family, bold: sub.contains("bold"), italic: sub.contains("italic") || sub.contains("oblique") }
})
.clone()
}
/// Nominal advance of `c` in font units (no kerning or features), as an application without
/// the composer's spacing would set it.
pub fn advance(&mut self, face: &FontFace, c: char) -> Option<f64> {
self.cmap(face).get(&c).map(|g| face.advance(*g))
}
pub fn glyph(&mut self, face: &FontFace, c: char) -> Option<u32> {
self.cmap(face).get(&c).copied()
}
/// The character a glyph shows (the lowest code point mapping to it).
pub fn char_of(&mut self, face: &FontFace, gid: u32) -> Option<char> {
let map = self.reverse.entry(face.id()).or_insert_with(|| {
let mut m = HashMap::new();
for (c, g) in face.chars() {
m.entry(g).or_insert(c);
}
m
});
map.get(&gid).copied()
}
fn cmap(&mut self, face: &FontFace) -> &HashMap<char, u32> {
self.cmaps.entry(face.id()).or_insert_with(|| face.chars().into_iter().collect())
}
}
/// A family embedded in the presentation: its regular, bold, italic and bold italic faces.
#[derive(Clone, Debug, Default)]
pub struct EmbeddedFont {
pub family: String,
pub faces: [Option<Vec<u8>>; 4],
}
fn be16(d: &[u8], at: usize) -> Option<u16> {
Some(u16::from_be_bytes([*d.get(at)?, *d.get(at + 1)?]))
}
fn be32(d: &[u8], at: usize) -> Option<u32> {
Some(u32::from_be_bytes([*d.get(at)?, *d.get(at + 1)?, *d.get(at + 2)?, *d.get(at + 3)?]))
}
fn utf16_name(out: &mut Vec<u8>, s: &str) {
let units: Vec<u16> = s.encode_utf16().collect();
let bytes = (units.len() * 2).min(u16::MAX as usize & !1);
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&(bytes as u16).to_le_bytes());
for u in units.iter().take(bytes / 2) {
out.extend_from_slice(&u.to_le_bytes());
}
}
/// Why a face can't be embedded.
#[derive(Debug, PartialEq, Eq)]
pub enum NoEmbed {
/// The font's licence (OS/2 `fsType`) forbids embedding it, or embedding its outlines.
Restricted,
/// PowerPoint embeds TrueType outlines only (not CFF, collections or variable instances).
Format,
}
/// The face as Embedded OpenType (uncompressed, version 2.1), the form PowerPoint stores
/// embedded fonts in.
pub fn eot(face: &FontFace) -> Result<Vec<u8>, NoEmbed> {
use skrifa::raw::TableProvider as _;
use skrifa::raw::types::Tag;
let data = face.data();
if face.index() != 0 || face.is_variable() || data.get(..4) == Some(b"OTTO") || data.get(..4) == Some(b"ttcf") {
return Err(NoEmbed::Format);
}
let f = face.skrifa().ok_or(NoEmbed::Format)?;
if f.glyf().is_err() {
return Err(NoEmbed::Format);
}
let os2 = f.table_data(Tag::new(b"OS/2")).map(|t| t.as_bytes().to_vec()).unwrap_or_default();
let head = f.table_data(Tag::new(b"head")).map(|t| t.as_bytes().to_vec()).unwrap_or_default();
let fs_type = be16(&os2, 8).unwrap_or(0);
// Restricted License embedding, or Bitmap embedding only (bit 9): PowerPoint embeds the
// outlines, so neither may be embedded.
if face.restricted_licence() || fs_type & 0x0200 != 0 {
return Err(NoEmbed::Restricted);
}
let mut h: Vec<u8> = Vec::with_capacity(256);
h.extend_from_slice(&[0; 8]); // EOTSize, FontDataSize (filled in below)
h.extend_from_slice(&0x0002_0001u32.to_le_bytes());
h.extend_from_slice(&0u32.to_le_bytes()); // Flags: no compression, no obfuscation
h.extend_from_slice(os2.get(32..42).unwrap_or(&[0; 10]));
h.push(1); // DEFAULT_CHARSET
h.push(u8::from(face.italic));
h.extend_from_slice(&u32::from(be16(&os2, 4).unwrap_or(400)).to_le_bytes());
h.extend_from_slice(&fs_type.to_le_bytes());
h.extend_from_slice(&0x504Cu16.to_le_bytes());
for i in 0..4 {
h.extend_from_slice(&be32(&os2, 42 + 4 * i).unwrap_or(0).to_le_bytes());
}
let has_codepages = be16(&os2, 0).unwrap_or(0) >= 1;
for i in 0..2 {
h.extend_from_slice(&(if has_codepages { be32(&os2, 78 + 4 * i).unwrap_or(0) } else { 0 }).to_le_bytes());
}
h.extend_from_slice(&be32(&head, 8).unwrap_or(0).to_le_bytes());
h.extend_from_slice(&[0; 16]); // Reserved1–4
let family = english(face, StringId::FAMILY_NAME).unwrap_or_else(|| face.family.clone());
let style = english(face, StringId::SUBFAMILY_NAME).unwrap_or_else(|| face.style.clone());
let version = english(face, StringId::VERSION_STRING).unwrap_or_default();
let full = english(face, StringId::FULL_NAME).unwrap_or_else(|| format!("{family} {style}"));
utf16_name(&mut h, &family);
utf16_name(&mut h, &style);
utf16_name(&mut h, &version);
utf16_name(&mut h, &full);
utf16_name(&mut h, ""); // RootString
let total = h.len().checked_add(data.len()).and_then(|n| u32::try_from(n).ok()).ok_or(NoEmbed::Format)?;
h[0..4].copy_from_slice(&total.to_le_bytes());
h[4..8].copy_from_slice(&(data.len() as u32).to_le_bytes());
h.extend_from_slice(data);
Ok(h)
}
+130
View File
@@ -0,0 +1,130 @@
//! PowerPoint export (File › Export › PowerPoint).
//!
//! Every exported page becomes a slide of native, editable objects: frames become shapes (preset
//! rectangles and ellipses, custom geometry otherwise) with their fills, strokes and effects,
//! placed images become pictures cropped like their frames, and text frames become text boxes.
//!
//! Text keeps DesignCraft's composition by default ([`LineBreaks::Keep`]): each composed line ends
//! in a line break, justified word spacing and tracking become character spacing, and leading
//! becomes percentage line spacing (PowerPoint rounds exact point spacing to whole points; its
//! 100% line is 1.2 em for every font, so a percentage is exact). A threaded story is split at its
//! frames, and the lines a text wrap pushes aside become boxes of their own, since PowerPoint has
//! neither threads nor wraps. [`LineBreaks::Reflow`] instead writes one box per frame with native
//! columns and lets PowerPoint break the lines.
//!
//! Parent-page items are copied onto each slide; layers are flattened in stacking order. What has
//! no PowerPoint equivalent (blend modes, type on a path, some effects) is approximated or
//! dropped and reported in [`ExportReport::warnings`]; [`needs_raster`] names the objects a caller
//! should rasterise first.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod fonts;
mod package;
mod slide;
mod table;
mod text;
mod xml;
#[cfg(test)]
mod tests;
use designcraft_compose::Cache;
use designcraft_doc::Document;
pub use slide::needs_raster;
/// How text frames become text boxes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LineBreaks {
/// Keep DesignCraft's line breaks, word spacing and leading (looks the same; edits don't
/// re-break lines).
#[default]
Keep,
/// One box per frame with native columns; PowerPoint breaks the lines (edits reflow; lines,
/// hyphenation and wraps differ).
Reflow,
}
#[derive(Clone, Debug, Default)]
pub struct PptxOptions {
/// Document pages to export (0-based positions); all when `None`.
pub pages: Option<Vec<usize>>,
pub line_breaks: LineBreaks,
pub title: Option<String>,
pub author: Option<String>,
/// Creation time (Unix seconds) for the document properties; none when `None`.
pub created: Option<i64>,
/// Embed the TrueType fonts the text uses (when their licences allow it), so the slides
/// look the same where they aren't installed.
pub embed_fonts: bool,
}
/// The exported presentation and what could not be represented exactly.
#[derive(Clone, Debug, Default)]
pub struct ExportReport {
pub bytes: Vec<u8>,
pub slides: usize,
pub warnings: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum PptxError {
#[error("the document has no pages to export")]
NoPages,
#[error("page {0} does not exist")]
BadPage(usize),
#[error("could not write the presentation: {0}")]
Write(String),
}
pub type Result<T> = std::result::Result<T, PptxError>;
/// Export `doc` as a PowerPoint presentation.
pub fn export_pptx(doc: &Document, cache: &Cache, opts: &PptxOptions) -> Result<ExportReport> {
let count = doc.page_count();
let pages: Vec<usize> = match &opts.pages {
Some(v) => v.clone(),
None => (0..count).collect(),
};
if pages.is_empty() {
return Err(PptxError::NoPages);
}
if let Some(bad) = pages.iter().find(|p| **p >= count) {
return Err(PptxError::BadPage(bad + 1));
}
let mut ex = slide::Exporter::new(doc, cache, opts);
let mut slides = Vec::with_capacity(pages.len());
for abs in &pages {
slides.push(ex.slide(*abs)?);
}
let size = slides.first().map(|s| s.size).ok_or(PptxError::NoPages)?;
if slides.iter().any(|s| (s.size.0 - size.0).abs() > 0.5 || (s.size.1 - size.1).abs() > 0.5) {
ex.warn("pages of different sizes: PowerPoint has one slide size, so every slide uses the first page's");
}
let fonts = if opts.embed_fonts {
let (fonts, skipped) = ex.fonts.embedded();
if !skipped.is_empty() {
ex.warn(format!("fonts not embedded: {}", skipped.join(", ")));
}
fonts
} else {
let mut names: Vec<String> = ex.fonts.used.iter().map(|(f, _)| f.family.clone()).collect();
names.dedup();
if !names.is_empty() {
ex.warn(format!("fonts are not embedded; the slides need {} installed to look the same", names.join(", ")));
}
Vec::new()
};
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let bytes = package::write(&package::Package {
size,
slides: &slides,
media: &ex.media,
fonts: &fonts,
title: &title,
author: opts.author.as_deref().unwrap_or(""),
created: opts.created,
})?;
Ok(ExportReport { bytes, slides: slides.len(), warnings: ex.warnings })
}
+335
View File
@@ -0,0 +1,335 @@
//! The PresentationML package: one blank master and layout, a theme, the slides and their media.
use std::fmt::Write as _;
use std::io::Write as _;
use crate::fonts::EmbeddedFont;
use crate::xml::{emu_len, esc};
use crate::{PptxError, Result};
pub const NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#;
const DECL: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n";
const REL_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
pub const REL_IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
/// A relationship from a slide to another part.
#[derive(Clone, Debug)]
pub struct Rel {
pub id: String,
pub kind: &'static str,
pub target: String,
}
/// One slide: its shape tree content (everything inside `<p:spTree>` after the group
/// properties) and relationships beyond the layout (`rId1`).
#[derive(Clone, Debug, Default)]
pub struct Slide {
/// Page size in points.
pub size: (f64, f64),
pub shapes: String,
pub rels: Vec<Rel>,
pub name: String,
}
/// A media part (`ppt/media/<name>`).
#[derive(Clone, Debug)]
pub struct Media {
pub name: String,
pub bytes: Vec<u8>,
}
pub struct Package<'a> {
pub size: (f64, f64),
pub slides: &'a [Slide],
pub media: &'a [Media],
pub fonts: &'a [EmbeddedFont],
pub title: &'a str,
pub author: &'a str,
pub created: Option<i64>,
}
fn rels(list: &[(String, &str, String)]) -> String {
let mut s = format!("{DECL}<Relationships xmlns=\"{REL_NS}\">");
for (id, kind, target) in list {
let _ = write!(s, "<Relationship Id=\"{id}\" Type=\"{kind}\" Target=\"{}\"/>", esc(target));
}
s.push_str("</Relationships>");
s
}
/// PowerPoint accepts slide sizes from 1 inch to 56 inches.
fn slide_extent(pt: f64) -> i64 {
emu_len(pt).clamp(914_400, 51_206_400)
}
pub fn write(p: &Package) -> Result<Vec<u8>> {
let mut parts: Vec<(String, Vec<u8>)> = Vec::new();
let mut put = |name: &str, s: String| parts.push((name.to_string(), s.into_bytes()));
let n = p.slides.len();
// Content types.
let mut ct = format!(
"{DECL}<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\
<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\
<Default Extension=\"xml\" ContentType=\"application/xml\"/>\
<Default Extension=\"png\" ContentType=\"image/png\"/>\
<Default Extension=\"jpeg\" ContentType=\"image/jpeg\"/>\
<Default Extension=\"gif\" ContentType=\"image/gif\"/>\
<Default Extension=\"svg\" ContentType=\"image/svg+xml\"/>\
<Default Extension=\"fntdata\" ContentType=\"application/x-fontdata\"/>\
<Override PartName=\"/ppt/presentation.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml\"/>\
<Override PartName=\"/ppt/slideMasters/slideMaster1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml\"/>\
<Override PartName=\"/ppt/slideLayouts/slideLayout1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml\"/>\
<Override PartName=\"/ppt/theme/theme1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.theme+xml\"/>\
<Override PartName=\"/ppt/presProps.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.presProps+xml\"/>\
<Override PartName=\"/ppt/viewProps.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml\"/>\
<Override PartName=\"/ppt/tableStyles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml\"/>\
<Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/>\
<Override PartName=\"/docProps/app.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.extended-properties+xml\"/>"
);
for i in 1..=n {
let _ = write!(
ct,
"<Override PartName=\"/ppt/slides/slide{i}.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slide+xml\"/>"
);
}
ct.push_str("</Types>");
put("[Content_Types].xml", ct);
put(
"_rels/.rels",
rels(&[
("rId1".into(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "ppt/presentation.xml".into()),
("rId2".into(), "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", "docProps/core.xml".into()),
("rId3".into(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", "docProps/app.xml".into()),
]),
);
// Presentation: master rId1, slides rId2.., then the property parts.
let (cx, cy) = (slide_extent(p.size.0), slide_extent(p.size.1));
let mut ids = String::new();
let mut prels: Vec<(String, &str, String)> = vec![(
"rId1".into(),
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster",
"slideMasters/slideMaster1.xml".into(),
)];
for i in 1..=n {
let _ = write!(ids, "<p:sldId id=\"{}\" r:id=\"rId{}\"/>", 255 + i, i + 1);
prels.push((
format!("rId{}", i + 1),
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
format!("slides/slide{i}.xml"),
));
}
for (k, (kind, target)) in
[("presProps", "presProps.xml"), ("viewProps", "viewProps.xml"), ("theme", "theme/theme1.xml"), ("tableStyles", "tableStyles.xml")]
.iter()
.enumerate()
{
prels.push((format!("rId{}", n + 2 + k), rel_kind(kind), target.to_string()));
}
// Embedded fonts: one part per face, listed per family in regular, bold, italic, bold italic.
let mut font_list = String::new();
let mut font_parts: Vec<(String, &[u8])> = Vec::new();
for f in p.fonts {
let _ = write!(font_list, "<p:embeddedFont><p:font typeface=\"{}\"/>", esc(&f.family));
for (slot, tag) in ["regular", "bold", "italic", "boldItalic"].iter().enumerate() {
if let Some(Some(bytes)) = f.faces.get(slot) {
let id = format!("rId{}", prels.len() + 1);
let target = format!("fonts/font{}.fntdata", font_parts.len() + 1);
let _ = write!(font_list, "<p:{tag} r:id=\"{id}\"/>");
prels.push((id, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font", target.clone()));
font_parts.push((format!("ppt/{target}"), bytes.as_slice()));
}
}
font_list.push_str("</p:embeddedFont>");
}
let (embed_attr, font_list) = if font_parts.is_empty() {
("", String::new())
} else {
(" embedTrueTypeFonts=\"1\"", format!("<p:embeddedFontLst>{font_list}</p:embeddedFontLst>"))
};
put(
"ppt/presentation.xml",
format!(
"{DECL}<p:presentation {NS} saveSubsetFonts=\"1\"{embed_attr}><p:sldMasterIdLst><p:sldMasterId id=\"2147483648\" r:id=\"rId1\"/></p:sldMasterIdLst>\
<p:sldIdLst>{ids}</p:sldIdLst><p:sldSz cx=\"{cx}\" cy=\"{cy}\"/><p:notesSz cx=\"6858000\" cy=\"9144000\"/>{font_list}\
<p:defaultTextStyle><a:defPPr><a:defRPr lang=\"en-US\"/></a:defPPr></p:defaultTextStyle></p:presentation>"
),
);
put("ppt/_rels/presentation.xml.rels", rels(&prels));
put("ppt/presProps.xml", format!("{DECL}<p:presentationPr {NS}/>"));
put(
"ppt/viewProps.xml",
format!(
"{DECL}<p:viewPr {NS}><p:normalViewPr><p:restoredLeft sz=\"15620\"/><p:restoredTop sz=\"94660\"/></p:normalViewPr>\
<p:slideViewPr><p:cSldViewPr><p:cViewPr varScale=\"1\"><p:scale><a:sx n=\"100\" d=\"100\"/><a:sy n=\"100\" d=\"100\"/></p:scale><p:origin x=\"0\" y=\"0\"/></p:cViewPr></p:cSldViewPr></p:slideViewPr>\
<p:gridSpacing cx=\"76200\" cy=\"76200\"/></p:viewPr>"
),
);
put(
"ppt/tableStyles.xml",
format!(
"{DECL}<a:tblStyleLst xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" def=\"{{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}}\"/>"
),
);
// One blank master and layout.
let tree = "<p:spTree><p:nvGrpSpPr><p:cNvPr id=\"1\" name=\"\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"0\" cy=\"0\"/><a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"0\" cy=\"0\"/></a:xfrm></p:grpSpPr></p:spTree>";
put(
"ppt/slideMasters/slideMaster1.xml",
format!(
"{DECL}<p:sldMaster {NS}><p:cSld><p:bg><p:bgRef idx=\"1001\"><a:schemeClr val=\"bg1\"/></p:bgRef></p:bg>{tree}</p:cSld>\
<p:clrMap bg1=\"lt1\" tx1=\"dk1\" bg2=\"lt2\" tx2=\"dk2\" accent1=\"accent1\" accent2=\"accent2\" accent3=\"accent3\" accent4=\"accent4\" accent5=\"accent5\" accent6=\"accent6\" hlink=\"hlink\" folHlink=\"folHlink\"/>\
<p:sldLayoutIdLst><p:sldLayoutId id=\"2147483649\" r:id=\"rId1\"/></p:sldLayoutIdLst>\
<p:txStyles><p:titleStyle><a:lvl1pPr><a:defRPr sz=\"4400\"/></a:lvl1pPr></p:titleStyle><p:bodyStyle><a:lvl1pPr><a:defRPr sz=\"1800\"/></a:lvl1pPr></p:bodyStyle><p:otherStyle><a:lvl1pPr><a:defRPr sz=\"1800\"/></a:lvl1pPr></p:otherStyle></p:txStyles></p:sldMaster>"
),
);
put(
"ppt/slideMasters/_rels/slideMaster1.xml.rels",
rels(&[
(
"rId1".into(),
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
"../slideLayouts/slideLayout1.xml".into(),
),
("rId2".into(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "../theme/theme1.xml".into()),
]),
);
put(
"ppt/slideLayouts/slideLayout1.xml",
format!(
"{DECL}<p:sldLayout {NS} type=\"blank\" preserve=\"1\"><p:cSld name=\"Blank\">{tree}</p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>"
),
);
put(
"ppt/slideLayouts/_rels/slideLayout1.xml.rels",
rels(&[(
"rId1".into(),
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster",
"../slideMasters/slideMaster1.xml".into(),
)]),
);
put("ppt/theme/theme1.xml", theme());
for (i, s) in p.slides.iter().enumerate() {
let name = if s.name.is_empty() { String::new() } else { format!(" name=\"{}\"", esc(&s.name)) };
put(
&format!("ppt/slides/slide{}.xml", i + 1),
format!(
"{DECL}<p:sld {NS}><p:cSld{name}><p:spTree><p:nvGrpSpPr><p:cNvPr id=\"1\" name=\"\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>\
<p:grpSpPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"0\" cy=\"0\"/><a:chOff x=\"0\" y=\"0\"/><a:chExt cx=\"0\" cy=\"0\"/></a:xfrm></p:grpSpPr>{}</p:spTree></p:cSld>\
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>",
s.shapes
),
);
let mut list: Vec<(String, &str, String)> = vec![(
"rId1".into(),
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
"../slideLayouts/slideLayout1.xml".into(),
)];
list.extend(s.rels.iter().map(|r| (r.id.clone(), r.kind, r.target.clone())));
put(&format!("ppt/slides/_rels/slide{}.xml.rels", i + 1), rels(&list));
}
put("docProps/core.xml", core(p));
put(
"docProps/app.xml",
format!(
"{DECL}<Properties xmlns=\"http://schemas.openxmlformats.org/officeDocument/2006/extended-properties\" xmlns:vt=\"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes\"><Application>DesignCraft</Application><Slides>{n}</Slides><PresentationFormat>Custom</PresentationFormat></Properties>"
),
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let zerr = |e: zip::result::ZipError| PptxError::Write(e.to_string());
let ioerr = |e: std::io::Error| PptxError::Write(e.to_string());
for (name, bytes) in &parts {
zw.start_file(name.as_str(), deflate).map_err(zerr)?;
zw.write_all(bytes).map_err(ioerr)?;
}
for (name, bytes) in &font_parts {
zw.start_file(name.as_str(), deflate).map_err(zerr)?;
zw.write_all(bytes).map_err(ioerr)?;
}
for m in p.media {
// Compressed formats gain nothing from deflate.
let opts = if m.name.ends_with(".svg") { deflate } else { stored };
zw.start_file(format!("ppt/media/{}", m.name), opts).map_err(zerr)?;
zw.write_all(&m.bytes).map_err(ioerr)?;
}
Ok(zw.finish().map_err(zerr)?.into_inner())
}
fn rel_kind(k: &str) -> &'static str {
match k {
"presProps" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps",
"viewProps" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/viewProps",
"theme" => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
_ => "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles",
}
}
fn core(p: &Package) -> String {
let mut s = format!(
"{DECL}<cp:coreProperties xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:dcterms=\"http://purl.org/dc/terms/\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">"
);
if !p.title.is_empty() {
let _ = write!(s, "<dc:title>{}</dc:title>", esc(p.title));
}
if !p.author.is_empty() {
let _ = write!(s, "<dc:creator>{}</dc:creator>", esc(p.author));
}
if let Some(t) = p.created {
let (y, mo, d, h, mi, se) = civil(t);
let _ = write!(s, "<dcterms:created xsi:type=\"dcterms:W3CDTF\">{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z</dcterms:created>");
}
s.push_str("</cp:coreProperties>");
s
}
/// Unix seconds → UTC civil date and time (proleptic Gregorian).
fn civil(t: i64) -> (i64, u32, u32, u32, u32, u32) {
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(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) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = yoe + era * 400 + i64::from(m <= 2);
(y.clamp(0, 9999), m, d, (secs / 3600) as u32, (secs / 60 % 60) as u32, (secs % 60) as u32)
}
/// A neutral theme: text sets its own fonts and colours, so these only matter for new objects.
fn theme() -> String {
let fill3 = "<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>".repeat(3);
let ln = "<a:ln w=\"6350\"><a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill></a:ln>".repeat(3);
let fx = "<a:effectStyle><a:effectLst/></a:effectStyle>".repeat(3);
format!(
"{DECL}<a:theme xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" name=\"DesignCraft\"><a:themeElements>\
<a:clrScheme name=\"DesignCraft\"><a:dk1><a:srgbClr val=\"000000\"/></a:dk1><a:lt1><a:srgbClr val=\"FFFFFF\"/></a:lt1><a:dk2><a:srgbClr val=\"44546A\"/></a:dk2><a:lt2><a:srgbClr val=\"E7E6E6\"/></a:lt2>\
<a:accent1><a:srgbClr val=\"4472C4\"/></a:accent1><a:accent2><a:srgbClr val=\"ED7D31\"/></a:accent2><a:accent3><a:srgbClr val=\"A5A5A5\"/></a:accent3><a:accent4><a:srgbClr val=\"FFC000\"/></a:accent4><a:accent5><a:srgbClr val=\"5B9BD5\"/></a:accent5><a:accent6><a:srgbClr val=\"70AD47\"/></a:accent6>\
<a:hlink><a:srgbClr val=\"0563C1\"/></a:hlink><a:folHlink><a:srgbClr val=\"954F72\"/></a:folHlink></a:clrScheme>\
<a:fontScheme name=\"DesignCraft\"><a:majorFont><a:latin typeface=\"Arial\"/><a:ea typeface=\"\"/><a:cs typeface=\"\"/></a:majorFont><a:minorFont><a:latin typeface=\"Arial\"/><a:ea typeface=\"\"/><a:cs typeface=\"\"/></a:minorFont></a:fontScheme>\
<a:fmtScheme name=\"DesignCraft\"><a:fillStyleLst>{fill3}</a:fillStyleLst><a:lnStyleLst>{ln}</a:lnStyleLst><a:effectStyleLst>{fx}</a:effectStyleLst><a:bgFillStyleLst>{fill3}</a:bgFillStyleLst></a:fmtScheme>\
</a:themeElements><a:objectDefaults/><a:extraClrSchemeLst/></a:theme>"
)
}
#[cfg(test)]
mod tests {
use super::civil;
#[test]
fn civil_dates() {
assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0));
assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3));
}
}
+750
View File
@@ -0,0 +1,750 @@
//! A page as a slide: items in stacking order as shapes, pictures, groups and text boxes.
use std::collections::HashMap;
use std::fmt::Write as _;
use designcraft_color::swatch::resolve_gradient;
use designcraft_color::{BlendMode, GradientKind};
use designcraft_compose::Cache;
use designcraft_doc::{Arrowhead, AssetId, Cap, Content, Document, Graphic, Item, Join, Shape, Stroke, StrokeAlign, StrokeType};
use designcraft_geom::kurbo::{PathEl, Shape as _};
use designcraft_geom::{Affine, BezPath, Point, Rect, Vec2, corners};
use crate::fonts::Fonts;
use crate::package::{Media, REL_IMAGE, Rel, Slide};
use crate::xml::{angle, emu, emu_len, esc, hex, pct};
use crate::{PptxError, PptxOptions, Result};
/// Objects PowerPoint can't show as editable objects, which a caller should rasterise before
/// exporting: placed PDF and EPS graphics, type on a path, and effects without a DrawingML
/// counterpart.
pub fn needs_raster(doc: &Document, it: &Item) -> bool {
let fx = &it.effects;
let effects = fx.inner_glow.on || fx.bevel.on || fx.satin.on || fx.directional_feather.on || fx.gradient_feather.on;
let placed = |g: &Graphic| {
doc.assets.get(&g.asset).is_some_and(|a| {
let m = a.mime.as_str();
m == "application/pdf" || m.contains("postscript") || m.contains("illustrator")
})
};
let on_path = it.text_frame().is_some_and(|t| t.options.path.is_some());
effects || on_path || it.graphic().is_some_and(placed) || it.children().iter().any(|c| needs_raster(doc, c))
}
/// A DrawingML transform box (slide space, points).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct XfrmBox {
pub rect: Rect,
pub rot: f64,
pub flip_h: bool,
pub flip_v: bool,
}
impl XfrmBox {
pub fn plain(rect: Rect) -> Self {
XfrmBox { rect, rot: 0.0, flip_h: false, flip_v: false }
}
pub fn xml(&self) -> String {
let mut a = String::new();
let r = angle(self.rot);
if r != 0 {
let _ = write!(a, " rot=\"{r}\"");
}
if self.flip_h {
a.push_str(" flipH=\"1\"");
}
if self.flip_v {
a.push_str(" flipV=\"1\"");
}
format!(
"<a:xfrm{a}><a:off x=\"{}\" y=\"{}\"/><a:ext cx=\"{}\" cy=\"{}\"/></a:xfrm>",
emu(self.rect.x0),
emu(self.rect.y0),
emu_len(self.rect.width()),
emu_len(self.rect.height())
)
}
}
/// An affine split into rotation, axis scales and shear: `m = translate · rotate(rot) ·
/// [sx, shear·sy; 0, sy]`.
#[derive(Clone, Copy, Debug)]
pub struct Decomposed {
pub rot: f64,
pub sx: f64,
pub sy: f64,
pub shear: f64,
}
pub fn decompose(m: Affine) -> Decomposed {
let [a, b, c, d, _, _] = m.as_coeffs();
let sx = a.hypot(b);
let rot = b.atan2(a);
let (cos, sin) = (rot.cos(), rot.sin());
let c2 = cos * c + sin * d;
let d2 = -sin * c + cos * d;
let shear = if d2.abs() > 1e-12 { c2 / d2 } else { 0.0 };
Decomposed { rot: rot.to_degrees(), sx, sy: d2, shear }
}
/// The box `inner` occupies under `m` when `m` has no shear: rotated about its centre, flipped
/// vertically for a negative determinant. `None` for sheared transforms.
pub fn box_of(inner: Rect, m: Affine) -> Option<(XfrmBox, Decomposed)> {
let dm = decompose(m);
if dm.shear.abs() > 1e-6 || !dm.sx.is_finite() || !dm.sy.is_finite() {
return None;
}
let c = m * inner.center();
let (w, h) = (inner.width() * dm.sx.abs(), inner.height() * dm.sy.abs());
let rect = Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0);
Some((XfrmBox { rect, rot: dm.rot, flip_h: false, flip_v: dm.sy < 0.0 }, dm))
}
/// Shape geometry: a preset, or a custom path in the box's own (unrotated) space.
pub struct Geometry {
pub xfrm: XfrmBox,
pub xml: String,
}
fn custom_geometry(bp: &BezPath, w: f64, h: f64, filled: bool) -> String {
let (we, he) = (emu_len(w).max(1), emu_len(h).max(1));
let mut p = format!(
"<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l=\"0\" t=\"0\" r=\"r\" b=\"b\"/><a:pathLst><a:path w=\"{we}\" h=\"{he}\"{}>",
if filled { "" } else { " fill=\"none\"" }
);
let pt = |q: Point| format!("<a:pt x=\"{}\" y=\"{}\"/>", emu(q.x), emu(q.y));
let mut last = Point::ZERO;
for el in bp.elements() {
match *el {
PathEl::MoveTo(a) => {
let _ = write!(p, "<a:moveTo>{}</a:moveTo>", pt(a));
last = a;
}
PathEl::LineTo(a) => {
let _ = write!(p, "<a:lnTo>{}</a:lnTo>", pt(a));
last = a;
}
PathEl::QuadTo(a, b) => {
// As a cubic: PowerPoint's quadratic support is patchy.
let c1 = last + (a - last) * (2.0 / 3.0);
let c2 = b + (a - b) * (2.0 / 3.0);
let _ = write!(p, "<a:cubicBezTo>{}{}{}</a:cubicBezTo>", pt(c1), pt(c2), pt(b));
last = b;
}
PathEl::CurveTo(a, b, c) => {
let _ = write!(p, "<a:cubicBezTo>{}{}{}</a:cubicBezTo>", pt(a), pt(b), pt(c));
last = c;
}
PathEl::ClosePath => p.push_str("<a:close/>"),
}
}
p.push_str("</a:path></a:pathLst></a:custGeom>");
p
}
fn preset(name: &str) -> String {
format!("<a:prstGeom prst=\"{name}\"><a:avLst/></a:prstGeom>")
}
/// Is `bp` exactly the rectangle `r` (any start corner and direction)?
fn is_rect(bp: &BezPath, r: Rect) -> bool {
let mut pts = Vec::new();
for el in bp.elements() {
match *el {
PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(p),
PathEl::ClosePath => {}
_ => return false,
}
}
if pts.len() == 5 && pts.first().zip(pts.last()).is_some_and(|(a, b)| (*a - *b).hypot() < 1e-6) {
pts.pop();
}
pts.len() == 4
&& pts.iter().all(|p| ((p.x - r.x0).abs() < 1e-6 || (p.x - r.x1).abs() < 1e-6) && ((p.y - r.y0).abs() < 1e-6 || (p.y - r.y1).abs() < 1e-6))
}
/// The geometry of `it` (its path, corner options applied) under `m` (inner → slide).
pub fn geometry(it: &Item, m: Affine) -> Geometry {
let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) };
let closed = it.path.is_closed();
let inner = bp.bounding_box();
if let Some((xfrm, dm)) = box_of(inner, m) {
if it.corners.is_none() && is_rect(&bp, inner) {
return Geometry { xfrm, xml: preset("rect") };
}
if it.shape == Shape::Oval && it.corners.is_none() && closed {
return Geometry { xfrm, xml: preset("ellipse") };
}
// The path in the box's own space: scaled (not flipped: the box flips), from its corner.
let (kx, ky) = (dm.sx.abs(), dm.sy.abs());
let mut local = bp.clone();
local.apply_affine(Affine::scale_non_uniform(kx, ky) * Affine::translate(-inner.origin().to_vec2()));
return Geometry { xfrm, xml: custom_geometry(&local, xfrm.rect.width(), xfrm.rect.height(), closed) };
}
// Sheared: the transformed path in slide space.
let mut pb = bp;
pb.apply_affine(m);
let b = pb.bounding_box();
pb.apply_affine(Affine::translate(-b.origin().to_vec2()));
Geometry { xfrm: XfrmBox::plain(b), xml: custom_geometry(&pb, b.width(), b.height(), closed) }
}
pub struct Exporter<'a> {
pub doc: &'a Document,
pub cache: &'a Cache,
pub opts: &'a PptxOptions,
pub warnings: Vec<String>,
pub media: Vec<Media>,
/// Asset → (picture media name, SVG media name).
media_of: HashMap<AssetId, Option<(String, Option<String>)>>,
pub fonts: Fonts,
// Per slide.
pub out: String,
next_id: u32,
rels: Vec<Rel>,
rel_of: HashMap<String, String>,
/// Spread space → slide space.
pub to_slide: Affine,
/// The page's bleed area in spread space (items outside it are left out).
clip: Rect,
/// The page parent items are composed for (page numbers, running heads).
pub page: Option<usize>,
}
impl<'a> Exporter<'a> {
pub fn new(doc: &'a Document, cache: &'a Cache, opts: &'a PptxOptions) -> Self {
Exporter {
doc,
cache,
opts,
warnings: Vec::new(),
media: Vec::new(),
media_of: HashMap::new(),
fonts: Fonts::default(),
out: String::new(),
next_id: 2,
rels: Vec::new(),
rel_of: HashMap::new(),
to_slide: Affine::IDENTITY,
clip: Rect::ZERO,
page: None,
}
}
pub fn warn(&mut self, w: impl Into<String>) {
let w = w.into();
if !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
pub fn id(&mut self) -> u32 {
let i = self.next_id;
self.next_id = self.next_id.saturating_add(1);
i
}
pub fn slide(&mut self, abs: usize) -> Result<Slide> {
let doc = self.doc;
let (si, pi) = doc.page_loc(abs).ok_or(PptxError::BadPage(abs + 1))?;
let sp = doc.spreads.get(si).ok_or(PptxError::BadPage(abs + 1))?;
let page = sp.pages.get(pi).ok_or(PptxError::BadPage(abs + 1))?;
let bounds = page.bounds();
self.out.clear();
self.next_id = 2;
self.rels.clear();
self.rel_of.clear();
self.to_slide = Affine::translate(-bounds.origin().to_vec2());
let b = doc.settings.bleed;
self.clip = Rect::new(bounds.x0 - b[2].max(b[3]), bounds.y0 - b[0], bounds.x1 + b[2].max(b[3]), bounds.y1 + b[1]);
let page_name = doc.page_name(abs);
for layer in doc.layers.iter().rev() {
if !layer.visible || !layer.printable {
continue;
}
if page.show_parent_items
&& let Some((ppi, ppage)) = doc.parent_page_for(abs)
&& let Some(parent) = doc.parents.get(ppi)
&& let Some(pp) = parent.pages.get(ppage)
{
let dx = page.x - pp.x;
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
continue;
}
if parent.pages.len() > 1 && parent.page_at_x(it.bounds().center().x) != Some(ppage) {
continue;
}
// Parent items compose per page (page numbers, running heads).
self.page = Some(abs);
self.item(it, Affine::translate((dx, 0.0)), 1.0);
self.page = None;
}
}
for it in &sp.items {
if it.layer == layer.id {
self.item(it, Affine::IDENTITY, 1.0);
}
}
}
Ok(Slide {
size: (bounds.width(), bounds.height()),
shapes: std::mem::take(&mut self.out),
rels: std::mem::take(&mut self.rels),
name: page_name,
})
}
fn rel(&mut self, target: String) -> String {
if let Some(r) = self.rel_of.get(&target) {
return r.clone();
}
let id = format!("rId{}", self.rels.len() + 2);
self.rels.push(Rel { id: id.clone(), kind: REL_IMAGE, target: target.clone() });
self.rel_of.insert(target, id.clone());
id
}
/// One item under `parent` (inner-of-parent → spread), with an inherited opacity.
pub fn item(&mut self, it: &Item, parent: Affine, opacity: f32) {
if it.hidden || it.nonprinting {
return;
}
let reach = it.stroke.extent()
+ 2.0
+ if it.effects.drop_shadow.on { it.effects.drop_shadow.distance.abs() + it.effects.drop_shadow.size.abs() } else { 0.0 };
let vb = parent.transform_rect_bbox(it.bounds()).inflate(reach, reach);
if vb.x1 < self.clip.x0 || vb.x0 > self.clip.x1 || vb.y1 < self.clip.y0 || vb.y0 > self.clip.y1 {
return;
}
if it.blend != BlendMode::Normal {
self.warn("blend modes have no PowerPoint equivalent; those objects are drawn normally");
}
if needs_raster(self.doc, it) && !it.is_group() {
self.warn("some effects (inner glow, bevel and emboss, satin, gradient and directional feather), type on a path and placed PDF/EPS graphics can't be exported as PowerPoint objects; rasterise them (Object Export Options) or they are approximated");
}
let xf = parent * it.xf;
let opacity = opacity * it.opacity.clamp(0.0, 1.0);
if it.has_nested_items() {
// Paste Into: the frame, then its contents (PowerPoint can't clip a group to a shape).
self.shape(it, xf, opacity, None);
let fb = xf.transform_rect_bbox(it.inner_bounds());
for c in it.children() {
let cb = xf.transform_rect_bbox(c.bounds());
if cb.x0 < fb.x0 - 0.5 || cb.y0 < fb.y0 - 0.5 || cb.x1 > fb.x1 + 0.5 || cb.y1 > fb.y1 + 0.5 {
self.warn("contents pasted into a frame are not clipped to it");
}
self.item(c, xf, opacity);
}
return;
}
if !it.children().is_empty() {
self.group(it, xf, opacity);
return;
}
match &it.content {
Content::Graphic(g) => self.picture(it, g, xf, opacity),
Content::Text(t) => {
let t = t.clone();
crate::text::frame(self, it, &t, xf, opacity);
}
_ => self.shape(it, xf, opacity, None),
}
}
fn group(&mut self, it: &Item, xf: Affine, opacity: f32) {
let mut body = std::mem::take(&mut self.out);
let id = self.id();
for c in it.shown_children() {
self.item(c, xf, opacity);
}
let inner = std::mem::take(&mut self.out);
if inner.is_empty() {
self.out = body;
return;
}
// Children are in slide space: the group's child space is the slide's.
let inner_bounds = it.shown_children().map(|c| c.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO);
let b = (self.to_slide * xf).transform_rect_bbox(inner_bounds);
let (x, y, w, h) = (emu(b.x0), emu(b.y0), emu_len(b.width()), emu_len(b.height()));
let _ = write!(
body,
"<p:grpSp><p:nvGrpSpPr><p:cNvPr id=\"{id}\" name=\"{}\"{}/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x=\"{x}\" y=\"{y}\"/><a:ext cx=\"{w}\" cy=\"{h}\"/><a:chOff x=\"{x}\" y=\"{y}\"/><a:chExt cx=\"{w}\" cy=\"{h}\"/></a:xfrm></p:grpSpPr>{inner}</p:grpSp>",
esc(&name_of(it, id)),
descr(it)
);
self.out = body;
}
/// A shape with the item's geometry, fill, stroke and effects; `text` is a `<p:txBody>`.
pub fn shape(&mut self, it: &Item, xf: Affine, opacity: f32, text: Option<&str>) {
let m = self.to_slide * xf;
let g = geometry(it, m);
let fill = self.fill(&it.fill, it.path.is_closed(), opacity);
let line = self.line(&it.stroke, opacity);
let fx = self.effects(it, opacity);
if text.is_none() && fill == "<a:noFill/>" && line.contains("<a:noFill/>") && fx.is_empty() {
return;
}
let id = self.id();
let txbox = if text.is_some() && it.fill.is_none() && it.stroke.is_none() { " txBox=\"1\"" } else { "" };
let _ = write!(
self.out,
"<p:sp><p:nvSpPr><p:cNvPr id=\"{id}\" name=\"{}\"{}/><p:cNvSpPr{txbox}/><p:nvPr/></p:nvSpPr><p:spPr>{}{}{fill}{line}{fx}</p:spPr>{}</p:sp>",
esc(&name_of(it, id)),
descr(it),
g.xfrm.xml(),
g.xml,
text.unwrap_or("")
);
}
/// An sRGB colour element for a swatch and tint, `None` for [None] and unknown swatches.
pub fn color(&self, swatch: &str, tint: f32, alpha: f32) -> Option<String> {
if let Some(g) = resolve_gradient(&self.doc.swatches, swatch) {
// A gradient used where only a colour fits (text): its first stop.
let (_, c, a) = g.expanded_stops().into_iter().next()?;
return Some(srgb(c.to_rgb(), a * alpha));
}
let c = self.doc.resolve_color(swatch, tint)?;
Some(srgb(c.to_rgb(), alpha))
}
pub fn fill(&mut self, f: &designcraft_doc::Fill, closed: bool, opacity: f32) -> String {
if f.is_none() || !closed {
return "<a:noFill/>".into();
}
if let Some(g) = resolve_gradient(&self.doc.swatches, &f.swatch) {
let mut gs = String::new();
for (o, c, a) in g.expanded_stops() {
let _ = write!(gs, "<a:gs pos=\"{}\">{}</a:gs>", pct(o.clamp(0.0, 1.0) as f64), srgb(c.to_rgb(), a * opacity));
}
if gs.is_empty() {
return "<a:noFill/>".into();
}
if f.gradient_vector.is_some() {
self.warn("gradients set with the Gradient tool use their angle only; PowerPoint stretches them over the whole shape");
}
let shade = match g.kind {
GradientKind::Radial => {
"<a:path path=\"circle\"><a:fillToRect l=\"50000\" t=\"50000\" r=\"50000\" b=\"50000\"/></a:path>".to_string()
}
_ => {
let deg = match f.gradient_vector {
Some([x0, y0, x1, y1]) => (y1 - y0).atan2(x1 - x0).to_degrees(),
None => -f.gradient_angle.unwrap_or(0.0),
};
format!("<a:lin ang=\"{}\" scaled=\"0\"/>", angle(deg))
}
};
return format!("<a:gradFill rotWithShape=\"1\"><a:gsLst>{gs}</a:gsLst>{shade}</a:gradFill>");
}
match self.color(&f.swatch, f.tint, opacity) {
Some(c) => format!("<a:solidFill>{c}</a:solidFill>"),
None => "<a:noFill/>".into(),
}
}
pub fn line(&mut self, s: &Stroke, opacity: f32) -> String {
if s.is_none() || s.weight <= 0.0 {
return "<a:ln><a:noFill/></a:ln>".into();
}
let Some(color) = self.color(&s.swatch, s.tint, opacity) else { return "<a:ln><a:noFill/></a:ln>".into() };
let cap = match s.cap {
Cap::Butt => "flat",
Cap::Round => "rnd",
Cap::Projecting => "sq",
};
let cmpd = match s.kind {
StrokeType::ThickThin => "thickThin",
StrokeType::ThinThick => "thinThick",
StrokeType::ThinThin | StrokeType::ThickThick => "dbl",
StrokeType::ThinThickThin | StrokeType::ThickThinThick => "tri",
_ => "sng",
};
if matches!(s.kind, StrokeType::Wavy | StrokeType::Hashed | StrokeType::Stripes { .. } | StrokeType::Style { .. }) {
self.warn("wavy, hashed, striped and custom stroke styles are exported as solid lines");
}
let algn = match s.align {
StrokeAlign::Inside => " algn=\"in\"",
StrokeAlign::Outside => {
self.warn("strokes aligned outside are centred on the path (PowerPoint has centre and inside only)");
""
}
StrokeAlign::Center => "",
};
let w = s.weight;
let dash = match &s.kind {
StrokeType::Dashed { pattern } if pattern.iter().any(|v| *v > 0.0) => {
let mut v: Vec<f64> = pattern.iter().map(|x| x.max(0.0)).collect();
if v.len() % 2 == 1 {
v.extend(v.clone());
}
let mut d = String::from("<a:custDash>");
for pair in v.chunks(2) {
if let [a, b] = pair {
let _ = write!(d, "<a:ds d=\"{}\" sp=\"{}\"/>", pct(a / w), pct(b / w));
}
}
d.push_str("</a:custDash>");
d
}
StrokeType::Dotted => "<a:prstDash val=\"sysDot\"/>".into(),
_ => "<a:prstDash val=\"solid\"/>".into(),
};
let join = match s.join {
Join::Miter => format!("<a:miter lim=\"{}\"/>", pct(s.miter_limit.max(1.0))),
Join::Round => "<a:round/>".into(),
Join::Bevel => "<a:bevel/>".into(),
};
let ends = |tag: &str, a: Arrowhead, me: &mut Self| -> String {
let kind = match a {
Arrowhead::None => return String::new(),
Arrowhead::Simple | Arrowhead::SimpleWide => "arrow",
Arrowhead::Triangle | Arrowhead::TriangleWide | Arrowhead::Curved => "triangle",
Arrowhead::Barbed => "stealth",
Arrowhead::Circle | Arrowhead::CircleSolid => "oval",
Arrowhead::Square | Arrowhead::SquareSolid => "diamond",
Arrowhead::Bar => {
me.warn("bar arrowheads have no PowerPoint equivalent and are left out");
return String::new();
}
};
// PowerPoint's large heads come closest to DesignCraft's at the same weight.
format!("<a:{tag} type=\"{kind}\" w=\"lg\" len=\"lg\"/>")
};
let head = ends("headEnd", s.start, self);
let tail = ends("tailEnd", s.end, self);
format!("<a:ln w=\"{}\" cap=\"{cap}\" cmpd=\"{cmpd}\"{algn}><a:solidFill>{color}</a:solidFill>{dash}{join}{head}{tail}</a:ln>", emu_len(w))
}
/// The item's effects that DrawingML has (in schema order).
pub fn effects(&mut self, it: &Item, opacity: f32) -> String {
let fx = &it.effects;
let mut s = String::new();
if fx.outer_glow.on
&& let Some(c) = self.color(&fx.outer_glow.color, 1.0, fx.outer_glow.opacity * opacity)
{
let _ = write!(s, "<a:glow rad=\"{}\">{c}</a:glow>", emu_len(fx.outer_glow.size));
}
if fx.inner_shadow.on {
let sh = &fx.inner_shadow;
if let Some(c) = self.color(&sh.color, 1.0, sh.opacity * opacity) {
let a = self.doc.light_angle(sh.angle, sh.global_light);
let _ = write!(
s,
"<a:innerShdw blurRad=\"{}\" dist=\"{}\" dir=\"{}\">{c}</a:innerShdw>",
emu_len(sh.size),
emu_len(sh.distance.abs()),
shadow_dir(a)
);
}
}
if fx.drop_shadow.on {
let sh = &fx.drop_shadow;
if let Some(c) = self.color(&sh.color, 1.0, sh.opacity * opacity) {
let a = self.doc.light_angle(sh.angle, sh.global_light);
let _ = write!(
s,
"<a:outerShdw blurRad=\"{}\" dist=\"{}\" dir=\"{}\" algn=\"ctr\" rotWithShape=\"0\">{c}</a:outerShdw>",
emu_len(sh.size),
emu_len(sh.distance.abs()),
shadow_dir(a)
);
}
}
if fx.feather > 0.0 {
let _ = write!(s, "<a:softEdge rad=\"{}\"/>", emu_len(fx.feather));
}
if s.is_empty() { s } else { format!("<a:effectLst>{s}</a:effectLst>") }
}
/// The media for a placed graphic: PNG, JPEG and GIF as they are, SVG with a PNG fallback,
/// other rasters converted to PNG.
fn media_for(&mut self, asset: AssetId) -> Option<(String, Option<String>)> {
if let Some(m) = self.media_of.get(&asset) {
return m.clone();
}
let r = self.convert(asset);
self.media_of.insert(asset, r.clone());
r
}
fn convert(&mut self, asset: AssetId) -> Option<(String, Option<String>)> {
let a = self.doc.assets.get(&asset)?.clone();
let data: &[u8] = &a.data;
if data.is_empty() {
self.warn(format!("`{}` is missing (linked file not found); left out", a.name));
return None;
}
let n = self.media.len() + 1;
let mime = designcraft_images::mime(data);
let ext = match mime {
"image/png" => Some("png"),
"image/jpeg" => Some("jpeg"),
"image/gif" => Some("gif"),
_ => None,
};
if let Some(ext) = ext {
let name = format!("image{n}.{ext}");
self.media.push(Media { name: name.clone(), bytes: data.to_vec() });
return Some((name, None));
}
if mime == "image/svg+xml" {
let png = designcraft_images::render_svg(data, 2048).and_then(|(px, w, h)| encode_png(unpremultiply(px), w, h));
let Some(png) = png else {
self.warn(format!("`{}` could not be read; left out", a.name));
return None;
};
let name = format!("image{n}.png");
let svg = format!("image{}.svg", n + 1);
self.media.push(Media { name: name.clone(), bytes: png });
self.media.push(Media { name: svg.clone(), bytes: data.to_vec() });
return Some((name, Some(svg)));
}
if mime == "application/pdf" || mime == "application/octet-stream" {
self.warn(format!(
"`{}` (PDF, EPS or an unknown format) can't be placed in PowerPoint; rasterise it (Object Export Options) to include it",
a.name
));
return None;
}
let Some(img) = designcraft_images::decode_rgba(data) else {
self.warn(format!("`{}` could not be read; left out", a.name));
return None;
};
let (w, h) = img.dimensions();
let png = encode_png(img.into_raw(), w, h)?;
let name = format!("image{n}.png");
self.media.push(Media { name: name.clone(), bytes: png });
Some((name, None))
}
fn picture(&mut self, it: &Item, g: &Graphic, xf: Affine, opacity: f32) {
let g = if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() };
let Some((name, svg)) = self.media_for(g.asset) else {
self.shape(it, xf, opacity, None);
return;
};
let m = self.to_slide * xf;
let [a, b, c, d, _, _] = g.xf.as_coeffs();
let frame = it.inner_bounds();
let img = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1));
let axis = b.abs() < 1e-9 && c.abs() < 1e-9 && a > 0.0 && d > 0.0;
let (geo, crop) = if axis && img.width() > 0.0 && img.height() > 0.0 {
// The frame's geometry with the image cropped (or padded) to the frame's box.
let crop = format!(
"<a:srcRect l=\"{}\" t=\"{}\" r=\"{}\" b=\"{}\"/>",
pct((frame.x0 - img.x0) / img.width()),
pct((frame.y0 - img.y0) / img.height()),
pct((img.x1 - frame.x1) / img.width()),
pct((img.y1 - frame.y1) / img.height())
);
(geometry(it, m), crop)
} else {
// Rotated, skewed or flipped inside its frame: the image's own box, uncropped, and
// the frame drawn on its own.
self.warn("images rotated, skewed or flipped inside their frames are not cropped to the frame");
self.shape(it, xf, opacity, None);
let gi = Rect::new(0.0, 0.0, g.size.0, g.size.1);
let gm = m * g.xf;
let xfrm = box_of(gi, gm).map(|(x, _)| x).unwrap_or_else(|| XfrmBox::plain(gm.transform_rect_bbox(gi)));
(Geometry { xfrm, xml: preset("rect") }, String::new())
};
let rid = self.rel(format!("../media/{name}"));
let svg_ext = match svg {
Some(s) => {
let sid = self.rel(format!("../media/{s}"));
format!(
"<a:extLst><a:ext uri=\"{{96DAC541-7B7A-43D3-8B79-37D633B846F1}}\"><asvg:svgBlip xmlns:asvg=\"http://schemas.microsoft.com/office/drawing/2016/SVG/main\" r:embed=\"{sid}\"/></a:ext></a:extLst>"
)
}
None => String::new(),
};
let alpha = if opacity < 0.999 { format!("<a:alphaModFix amt=\"{}\"/>", pct(opacity as f64)) } else { String::new() };
let fill = self.fill(&it.fill, it.path.is_closed(), opacity);
let line = if axis { self.line(&it.stroke, opacity) } else { "<a:ln><a:noFill/></a:ln>".into() };
let fx = self.effects(it, opacity);
let id = self.id();
let _ = write!(
self.out,
"<p:pic><p:nvPicPr><p:cNvPr id=\"{id}\" name=\"{}\"{}/><p:cNvPicPr><a:picLocks noChangeAspect=\"1\"/></p:cNvPicPr><p:nvPr/></p:nvPicPr>\
<p:blipFill><a:blip r:embed=\"{rid}\">{alpha}{svg_ext}</a:blip>{crop}<a:stretch><a:fillRect/></a:stretch></p:blipFill>\
<p:spPr>{}{}{fill}{line}{fx}</p:spPr></p:pic>",
esc(&name_of(it, id)),
descr(it),
geo.xfrm.xml(),
geo.xml
);
}
/// Paragraph shading and rules, table cell fills: a plain filled rectangle in slide space.
pub fn rect_shape(&mut self, xfrm: XfrmBox, fill: &str, name: &str) {
let id = self.id();
let _ = write!(
self.out,
"<p:sp><p:nvSpPr><p:cNvPr id=\"{id}\" name=\"{}\"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>{}{}{fill}<a:ln><a:noFill/></a:ln></p:spPr></p:sp>",
esc(name),
xfrm.xml(),
preset("rect")
);
}
}
/// DrawingML shadow direction (clockwise from +x, y down) for a light angle (degrees,
/// counter-clockwise; the shadow falls away from the light).
fn shadow_dir(light: f64) -> i64 {
let a = light.to_radians();
let off = Vec2::new(-a.cos(), a.sin());
angle(off.y.atan2(off.x).to_degrees())
}
pub fn srgb(rgb: [f32; 3], alpha: f32) -> String {
let a = alpha.clamp(0.0, 1.0);
if a >= 0.999 {
format!("<a:srgbClr val=\"{}\"/>", hex(rgb))
} else {
format!("<a:srgbClr val=\"{}\"><a:alpha val=\"{}\"/></a:srgbClr>", hex(rgb), pct(a as f64))
}
}
fn name_of(it: &Item, id: u32) -> String {
if !it.name.is_empty() {
return it.name.clone();
}
let kind = match (&it.content, it.shape) {
(Content::Group { .. }, _) => "Group",
(Content::Text(_), _) => "Text Frame",
(Content::Graphic(_), _) => "Picture",
(_, Shape::Oval) => "Ellipse",
(_, Shape::Polygon) => "Polygon",
(_, Shape::GraphicLine) => "Line",
(_, Shape::Path) => "Path",
_ => "Rectangle",
};
format!("{kind} {id}")
}
fn descr(it: &Item) -> String {
if it.alt_text.is_empty() { String::new() } else { format!(" descr=\"{}\"", esc(&it.alt_text)) }
}
fn unpremultiply(mut px: Vec<u8>) -> Vec<u8> {
for p in px.as_chunks_mut::<4>().0 {
let a = p[3] as u32;
if a > 0 && a < 255 {
for c in &mut p[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
px
}
fn encode_png(rgba: Vec<u8>, w: u32, h: u32) -> Option<Vec<u8>> {
let img = image::RgbaImage::from_raw(w, h, rgba)?;
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).ok()?;
Some(out.into_inner())
}
+208
View File
@@ -0,0 +1,208 @@
//! Table fragments as native PowerPoint tables: the grid from the cells' edges, merged cells as
//! spans, fills, edges as cell borders, and each cell's composed text.
use std::fmt::Write as _;
use designcraft_compose::{FrameText, PlacedCell, StrokeSeg, TableFrag};
use designcraft_doc::StrokeType;
use designcraft_geom::{Affine, Rect};
use crate::slide::{Exporter, box_of};
use crate::text::{paragraphs, seg_rect, segments};
use crate::xml::{emu, emu_len};
/// Sorted distinct values (within 0.25 pt).
fn edges(mut v: Vec<f64>) -> Vec<f64> {
v.retain(|x| x.is_finite());
v.sort_by(f64::total_cmp);
v.dedup_by(|a, b| (*a - *b).abs() < 0.25);
v
}
fn index(edges: &[f64], x: f64) -> usize {
edges.iter().enumerate().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(0, |(i, _)| i)
}
pub fn tables(ex: &mut Exporter, ft: &FrameText, m: Affine, k: f64, opacity: f32) {
for t in &ft.tables {
table(ex, t, m, k, opacity);
}
}
/// The stroke along one side of a cell (`a`–`b`), if the segments on that line cover most of it
/// (a merged cell's side is drawn in pieces, one per grid cell).
fn edge_stroke(strokes: &[StrokeSeg], a: (f64, f64), b: (f64, f64)) -> Option<&StrokeSeg> {
let vertical = (a.0 - b.0).abs() < 0.25;
let (lo, hi) = if vertical { (a.1, b.1) } else { (a.0, b.0) };
let mut covered = 0.0;
let mut first = None;
for s in strokes {
let (on, s0, s1) = if vertical {
((s.a.x - a.0).abs() < 0.75 && (s.b.x - a.0).abs() < 0.75, s.a.y.min(s.b.y), s.a.y.max(s.b.y))
} else {
((s.a.y - a.1).abs() < 0.75 && (s.b.y - a.1).abs() < 0.75, s.a.x.min(s.b.x), s.a.x.max(s.b.x))
};
let overlap = s1.min(hi) - s0.max(lo);
if on && overlap > 0.0 {
covered += overlap;
first.get_or_insert(s);
}
}
first.filter(|_| covered > 0.5 * (hi - lo))
}
fn border(ex: &mut Exporter, tag: &str, s: Option<&StrokeSeg>, k: f64, opacity: f32) -> String {
let Some(s) = s.filter(|s| s.stroke.weight > 0.0) else { return format!("<a:{tag} w=\"0\"><a:noFill/></a:{tag}>") };
let st = &s.stroke;
let Some(c) = ex.color(&st.color, st.tint, opacity) else { return format!("<a:{tag} w=\"0\"><a:noFill/></a:{tag}>") };
let dash = match st.kind {
StrokeType::Dashed { .. } => "dash",
StrokeType::Dotted => "sysDot",
_ => "solid",
};
format!(
"<a:{tag} w=\"{}\" cap=\"flat\" cmpd=\"sng\"><a:solidFill>{c}</a:solidFill><a:prstDash val=\"{dash}\"/></a:{tag}>",
emu_len(st.weight * k)
)
}
fn table(ex: &mut Exporter, t: &TableFrag, m: Affine, k: f64, opacity: f32) {
if t.cells.is_empty() {
return;
}
let Some((xfrm, dm)) = box_of(t.rect, m) else { return };
if dm.rot.abs() > 0.01 || xfrm.flip_v {
ex.warn("tables in rotated or flipped frames are exported upright");
}
let xs = edges(t.cells.iter().flat_map(|c| [c.rect.x0, c.rect.x1]).collect());
let ys = edges(t.cells.iter().flat_map(|c| [c.rect.y0, c.rect.y1]).collect());
if xs.len() < 2 || ys.len() < 2 {
return;
}
let (nc, nr) = (xs.len() - 1, ys.len() - 1);
// Which placed cell starts at each grid position, and which positions merged cells cover.
let mut origin: Vec<Option<&PlacedCell>> = vec![None; nc * nr];
let mut covered: Vec<(bool, bool)> = vec![(false, false); nc * nr];
let mut span: Vec<(usize, usize)> = vec![(1, 1); nc * nr];
for c in &t.cells {
let (c0, c1) = (index(&xs, c.rect.x0), index(&xs, c.rect.x1));
let (r0, r1) = (index(&ys, c.rect.y0), index(&ys, c.rect.y1));
if c1 <= c0 || r1 <= r0 {
continue;
}
let Some(slot) = origin.get_mut(r0 * nc + c0) else { continue };
*slot = Some(c);
if let Some(sp) = span.get_mut(r0 * nc + c0) {
*sp = (c1 - c0, r1 - r0);
}
for r in r0..r1 {
for col in c0..c1 {
if (r, col) != (r0, c0)
&& let Some(cv) = covered.get_mut(r * nc + col)
{
*cv = (col > c0, r > r0);
}
}
}
}
if t.cells.iter().any(|c| c.graphic.is_some()) {
ex.warn("graphic cells are exported empty");
}
let id = ex.id();
let mut s = format!(
"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id=\"{id}\" name=\"Table {id}\"/><p:cNvGraphicFramePr><a:graphicFrameLocks noGrp=\"1\"/></p:cNvGraphicFramePr><p:nvPr/></p:nvGraphicFramePr>\
<p:xfrm><a:off x=\"{}\" y=\"{}\"/><a:ext cx=\"{}\" cy=\"{}\"/></p:xfrm><a:graphic><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/table\"><a:tbl><a:tblPr/><a:tblGrid>",
emu(xfrm.rect.x0),
emu(xfrm.rect.y0),
emu_len(xfrm.rect.width()),
emu_len(xfrm.rect.height())
);
for w in xs.windows(2) {
let _ = write!(s, "<a:gridCol w=\"{}\"/>", emu_len((w[1] - w[0]) * k));
}
s.push_str("</a:tblGrid>");
for r in 0..nr {
let h = ys.get(r + 1).zip(ys.get(r)).map_or(0.0, |(b, a)| b - a);
let _ = write!(s, "<a:tr h=\"{}\">", emu_len(h * k));
for col in 0..nc {
let at = r * nc + col;
let (hm, vm) = covered.get(at).copied().unwrap_or((false, false));
if hm || vm {
let _ = write!(
s,
"<a:tc{}{}><a:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang=\"en-US\" dirty=\"0\"/></a:p></a:txBody><a:tcPr/></a:tc>",
if hm { " hMerge=\"1\"" } else { "" },
if vm { " vMerge=\"1\"" } else { "" }
);
continue;
}
let (gs, rs) = span.get(at).copied().unwrap_or((1, 1));
let mut attrs = String::new();
if gs > 1 {
let _ = write!(attrs, " gridSpan=\"{gs}\"");
}
if rs > 1 {
let _ = write!(attrs, " rowSpan=\"{rs}\"");
}
let Some(c) = origin.get(at).copied().flatten() else {
let _ = write!(
s,
"<a:tc{attrs}><a:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang=\"en-US\" dirty=\"0\"/></a:p></a:txBody><a:tcPr marL=\"0\" marR=\"0\" marT=\"0\" marB=\"0\"><a:noFill/></a:tcPr></a:tc>"
);
continue;
};
s.push_str(&cell(ex, c, &attrs, t, k, opacity));
}
s.push_str("</a:tr>");
}
s.push_str("</a:tbl></a:graphicData></a:graphic></p:graphicFrame>");
ex.out.push_str(&s);
}
fn cell(ex: &mut Exporter, c: &PlacedCell, attrs: &str, t: &TableFrag, k: f64, opacity: f32) -> String {
let rc = c.rect;
let mut body = String::new();
let mut margins = [0.0f64; 4];
if let Some(cft) = c.text.frames.first() {
let segs = segments(ex, &c.text, cft, &c.source, &[]);
// A cell holds one box: its lines, from the first segment's slot.
let mut all = segs.first().cloned().unwrap_or_default();
for sg in segs.iter().skip(1) {
all.lines.extend(sg.lines.iter().cloned());
all.x1 = all.x1.max(sg.x1);
}
if !all.lines.is_empty() {
let r = seg_rect(&all);
let r = Rect::new(r.x0 + c.origin.x, r.y0 + c.origin.y, r.x1 + c.origin.x, r.y1 + c.origin.y);
margins = [(r.x0 - rc.x0).max(0.0), (r.y0 - rc.y0).max(0.0), (rc.x1 - r.x1).max(0.0), (rc.y1 - r.y1).max(0.0)];
let local = Rect::new(r.x0 - c.origin.x, r.y0 - c.origin.y, r.x1 - c.origin.x, r.y1 - c.origin.y);
body = paragraphs(ex, &all, local, k, opacity);
}
}
if body.is_empty() {
body = "<a:p><a:endParaRPr lang=\"en-US\" dirty=\"0\"/></a:p>".into();
}
let [l, tp, r, b] = margins.map(|v| emu_len(v * k));
let ln = |side: char| -> ((f64, f64), (f64, f64)) {
match side {
'L' => ((rc.x0, rc.y0), (rc.x0, rc.y1)),
'R' => ((rc.x1, rc.y0), (rc.x1, rc.y1)),
'T' => ((rc.x0, rc.y0), (rc.x1, rc.y0)),
_ => ((rc.x0, rc.y1), (rc.x1, rc.y1)),
}
};
let mut pr = format!("<a:tcPr marL=\"{l}\" marR=\"{r}\" marT=\"{tp}\" marB=\"{b}\">");
for (tag, side) in [("lnL", 'L'), ("lnR", 'R'), ("lnT", 'T'), ("lnB", 'B')] {
let (a, bb) = ln(side);
let seg = edge_stroke(&t.strokes, a, bb);
pr.push_str(&border(ex, tag, seg, k, opacity));
}
match c.fill.as_ref().and_then(|(sw, tint)| ex.color(sw, *tint, opacity)) {
Some(col) => {
let _ = write!(pr, "<a:solidFill>{col}</a:solidFill>");
}
None => pr.push_str("<a:noFill/>"),
}
pr.push_str("</a:tcPr>");
format!("<a:tc{attrs}><a:txBody><a:bodyPr/><a:lstStyle/>{body}</a:txBody>{pr}</a:tc>")
}
+289
View File
@@ -0,0 +1,289 @@
//! Package structure and object mapping tests.
use std::io::Read;
use std::sync::Arc;
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{
Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Stroke, StrokeType, TextFrameOptions,
};
use designcraft_geom::{Affine, Rect, shapes};
use crate::{LineBreaks, PptxError, PptxOptions, export_pptx};
fn unzip(bytes: &[u8]) -> zip::ZipArchive<std::io::Cursor<Vec<u8>>> {
zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap()
}
fn part(z: &mut zip::ZipArchive<std::io::Cursor<Vec<u8>>>, name: &str) -> String {
let mut s = String::new();
z.by_name(name).unwrap_or_else(|_| panic!("missing {name}")).read_to_string(&mut s).unwrap();
s
}
fn export(d: &Document, opts: &PptxOptions) -> zip::ZipArchive<std::io::Cursor<Vec<u8>>> {
unzip(&export_pptx(d, &Cache::new(), opts).unwrap().bytes)
}
fn rect_item(d: &mut Document, r: Rect, shape: Shape) -> ItemId {
let id = ItemId(d.alloc());
let path = if shape == Shape::Oval { shapes::ellipse(r) } else { shapes::rectangle(r) };
let mut it = Item::new(id, d.default_layer(), shape, path);
it.fill = Fill::swatch("[Black]");
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
id
}
fn edit(d: &mut Document, id: ItemId, f: impl FnOnce(&mut Item)) {
let it = d.spreads.iter_mut().flat_map(|sp| Arc::make_mut(sp).items.iter_mut()).find(|it| it.id == id).unwrap();
f(Arc::make_mut(it));
}
/// The text of each shape on a slide (runs joined).
fn shape_texts(slide: &str) -> Vec<String> {
slide
.split("<p:sp>")
.skip(1)
.map(|sp| sp.split("<a:t>").skip(1).filter_map(|t| t.split("</a:t>").next()).collect::<String>())
.filter(|t| !t.is_empty())
.collect()
}
/// Every XML part parses.
fn well_formed(z: &mut zip::ZipArchive<std::io::Cursor<Vec<u8>>>) {
let names: Vec<String> = z.file_names().map(str::to_string).collect();
for n in names.iter().filter(|n| n.ends_with(".xml") || n.ends_with(".rels")) {
let x = part(z, n);
let mut r = quick_xml::Reader::from_str(&x);
loop {
match r.read_event() {
Ok(quick_xml::events::Event::Eof) => break,
Ok(_) => {}
Err(e) => panic!("{n}: {e}"),
}
}
}
}
#[test]
fn package_has_the_parts_powerpoint_needs() {
let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() });
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello & <world>", ParaFormat::default()).unwrap();
let r = export_pptx(&d, &Cache::new(), &PptxOptions::default()).unwrap();
assert_eq!(r.slides, 2);
let mut z = unzip(&r.bytes);
for p in [
"[Content_Types].xml",
"_rels/.rels",
"ppt/presentation.xml",
"ppt/_rels/presentation.xml.rels",
"ppt/slideMasters/slideMaster1.xml",
"ppt/slideLayouts/slideLayout1.xml",
"ppt/theme/theme1.xml",
"ppt/slides/slide1.xml",
"ppt/slides/slide2.xml",
"ppt/slides/_rels/slide2.xml.rels",
"docProps/core.xml",
] {
assert!(z.by_name(p).is_ok(), "{p}");
}
well_formed(&mut z);
let pres = part(&mut z, "ppt/presentation.xml");
let (w, h) = (d.settings.page_width, d.settings.page_height);
assert!(pres.contains(&format!("<p:sldSz cx=\"{}\" cy=\"{}\"/>", (w * 12_700.0).round() as i64, (h * 12_700.0).round() as i64)), "{pres}");
let s1 = part(&mut z, "ppt/slides/slide1.xml");
assert_eq!(shape_texts(&s1), ["Hello &amp; &lt;world&gt;"]);
}
#[test]
fn shapes_keep_geometry_fill_stroke_and_effects() {
let mut d = Document::new(&NewDocument::default());
let a = rect_item(&mut d, Rect::new(72.0, 72.0, 172.0, 122.0), Shape::Rectangle);
let b = rect_item(&mut d, Rect::new(200.0, 72.0, 300.0, 122.0), Shape::Oval);
let c = rect_item(&mut d, Rect::new(72.0, 200.0, 172.0, 300.0), Shape::Rectangle);
edit(&mut d, a, |it| {
it.stroke = Stroke { swatch: "[Black]".into(), weight: 2.0, kind: StrokeType::Dashed { pattern: vec![4.0, 2.0] }, ..Stroke::default() };
it.opacity = 0.5;
});
edit(&mut d, b, |it| it.effects.drop_shadow.on = true);
edit(&mut d, c, |it| {
let ctr = it.bounds().center();
it.xf = Affine::rotate_about(30f64.to_radians(), ctr);
});
let mut z = export(&d, &PptxOptions::default());
let s = part(&mut z, "ppt/slides/slide1.xml");
// 72 pt from the page corner, 100 × 50 pt.
assert!(s.contains("<a:off x=\"914400\" y=\"914400\"/><a:ext cx=\"1270000\" cy=\"635000\"/></a:xfrm><a:prstGeom prst=\"rect\">"), "{s}");
assert!(s.contains("<a:prstGeom prst=\"ellipse\">"));
assert!(s.contains("<a:custDash><a:ds d=\"200000\" sp=\"100000\"/></a:custDash>"), "{s}");
assert!(s.contains("<a:alpha val=\"50000\"/>"));
assert!(s.contains("<a:outerShdw"));
assert!(s.contains("rot=\"1800000\""), "{s}");
}
#[test]
fn composed_lines_become_line_breaks_with_the_story_text() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let text = "Every page begins as an empty field of possibility. The grid arrives first: margins that frame the reading area, columns that set the rhythm.";
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 200.0, 400.0), lid, text, ParaFormat::default()).unwrap();
let cache = Cache::new();
let lines = cache.get(&d, sid, None).frame(fid).map_or(0, |f| f.lines.len());
assert!(lines > 3, "the frame breaks the text into lines");
let r = export_pptx(&d, &cache, &PptxOptions::default()).unwrap();
let mut z = unzip(&r.bytes);
let s = part(&mut z, "ppt/slides/slide1.xml");
assert_eq!(s.matches("<a:br>").count(), lines - 1, "{s}");
// The text survives intact (line-end spaces kept, the composer's hyphens shown).
let joined = shape_texts(&s).concat();
assert_eq!(joined.replace('-', ""), text);
assert!(s.contains("<a:spcPct"), "leading as percentage line spacing");
assert!(s.contains("typeface=\"Source Serif 4\""));
}
#[test]
fn reflow_writes_one_box_with_native_columns() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 500.0, 300.0), lid, "One\nTwo", ParaFormat::default()).unwrap();
edit(&mut d, fid, |it| {
if let Content::Text(t) = &mut it.content {
t.options = TextFrameOptions { columns: 2, gutter: 12.0, ..t.options.clone() };
}
});
let mut z = export(&d, &PptxOptions { line_breaks: LineBreaks::Reflow, ..Default::default() });
let s = part(&mut z, "ppt/slides/slide1.xml");
assert!(s.contains("numCol=\"2\" spcCol=\"152400\""), "{s}");
assert!(!s.contains("<a:br>"));
assert_eq!(s.matches("<a:p>").count(), 2);
}
#[test]
fn threaded_story_splits_at_its_frames() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega";
let (f1, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 160.0, 110.0), lid, text, ParaFormat::default()).unwrap();
let f2 = rect_item(&mut d, Rect::new(300.0, 72.0, 388.0, 400.0), Shape::Rectangle);
edit(&mut d, f2, |it| {
it.fill = Fill::none();
it.content = Content::Text(designcraft_doc::TextFrame { story: sid, options: TextFrameOptions::default() });
});
if let Some(st) = d.stories.get_mut(&sid) {
Arc::make_mut(st).frames = vec![f1, f2];
}
let mut z = export(&d, &PptxOptions::default());
let s = part(&mut z, "ppt/slides/slide1.xml");
// Two text shapes, the story starting in the first and ending in the second.
let texts: Vec<String> = shape_texts(&s).iter().map(|t| t.replace('-', "")).collect();
assert_eq!(texts.len(), 2, "{s}");
assert!(texts[0].starts_with("alpha") && !texts[0].contains("omega"), "{texts:?}");
assert!(texts[1].trim_end().ends_with("omega"), "{texts:?}");
assert_eq!(texts.concat().trim_end(), text);
}
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255]));
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
#[test]
fn pictures_are_cropped_to_their_frames() {
let mut d = Document::new(&NewDocument::default());
let asset = AssetId(d.alloc());
d.assets.insert(
asset,
Arc::new(Asset {
id: asset,
name: "red.png".into(),
mime: "image/png".into(),
link: None,
data: Arc::new(png(200, 100)),
pixels: Some((200, 100)),
page: 0,
}),
);
let id = rect_item(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0), Shape::Rectangle);
edit(&mut d, id, |it| {
it.fill = Fill::none();
// 200 × 100 image at 1:1, its left quarter cut off by the frame.
it.content = Content::Graphic(Graphic {
asset,
size: (200.0, 100.0),
xf: Affine::translate((50.0, 100.0)),
auto_fit: designcraft_doc::Fitting::None,
fit_align: 0,
crop: [0.0; 4],
});
});
let mut z = export(&d, &PptxOptions::default());
assert!(z.by_name("ppt/media/image1.png").is_ok());
let s = part(&mut z, "ppt/slides/slide1.xml");
assert!(s.contains("<a:srcRect l=\"25000\" t=\"0\" r=\"25000\" b=\"0\"/>"), "{s}");
let rels = part(&mut z, "ppt/slides/_rels/slide1.xml.rels");
assert!(rels.contains("../media/image1.png"));
}
#[test]
fn bad_pages_and_hostile_geometry_are_errors_not_crashes() {
let mut d = Document::new(&NewDocument::default());
let r = export_pptx(&d, &Cache::new(), &PptxOptions { pages: Some(vec![5]), ..Default::default() });
assert!(matches!(r, Err(PptxError::BadPage(6))));
assert!(matches!(export_pptx(&d, &Cache::new(), &PptxOptions { pages: Some(vec![]), ..Default::default() }), Err(PptxError::NoPages)));
let a = rect_item(&mut d, Rect::new(10.0, 10.0, 10.0, 10.0), Shape::Rectangle);
let b = rect_item(&mut d, Rect::new(10.0, 10.0, 50.0, 50.0), Shape::Rectangle);
edit(&mut d, a, |it| it.xf = Affine::new([0.0, 0.0, 0.0, 0.0, 0.0, 0.0]));
edit(&mut d, b, |it| it.xf = Affine::new([f64::NAN, 0.0, 0.0, 1e300, f64::INFINITY, 0.0]));
let lid = d.default_layer();
let (t, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 60.0, 60.0), lid, "x", ParaFormat::default()).unwrap();
edit(&mut d, t, |it| it.xf = Affine::new([1.0, 0.0, 3.0, 1.0, 0.0, 0.0]));
let mut z = export(&d, &PptxOptions::default());
well_formed(&mut z);
}
#[test]
fn fonts_embed_as_embedded_opentype() {
let face = designcraft_fonts::FontDb::global().face("Source Serif 4", "Regular");
let eot = crate::fonts::eot(&face).unwrap();
let le32 = |at: usize| u32::from_le_bytes([eot[at], eot[at + 1], eot[at + 2], eot[at + 3]]);
assert_eq!(le32(0) as usize, eot.len());
assert_eq!(le32(4) as usize, face.data().len());
assert_eq!(le32(8), 0x0002_0001);
assert_eq!(u16::from_le_bytes([eot[34], eot[35]]), 0x504C, "magic");
assert!(eot.ends_with(face.data()));
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Embedded", ParaFormat::default()).unwrap();
let mut z = export(&d, &PptxOptions { embed_fonts: true, ..Default::default() });
let pres = part(&mut z, "ppt/presentation.xml");
assert!(
pres.contains("embedTrueTypeFonts=\"1\"") && pres.contains("<p:embeddedFont><p:font typeface=\"Source Serif 4\"/><p:regular r:id="),
"{pres}"
);
assert!(z.by_name("ppt/fonts/font1.fntdata").is_ok());
let ct = part(&mut z, "[Content_Types].xml");
assert!(ct.contains("application/x-fontdata"));
}
#[test]
fn fonts_whose_licence_forbids_outline_embedding_are_not_embedded() {
use designcraft_fonts::testing::{font_with, with_fs_type};
let db = designcraft_fonts::FontDb::with_font_dirs(vec![]);
// Restricted (0x0002, 0x0003 with reserved bit 0) and Bitmap embedding only (bit 9, with any
// other bits) refuse; Installable, Preview & Print and Editable embed.
for (fs_type, embeds) in [(0x0000, true), (0x0004, true), (0x0008, true), (0x0002, false), (0x0003, false), (0x0200, false), (0x0208, false)] {
let family = format!("PPTX Licence {fs_type:04x}");
assert_eq!(db.add_font(with_fs_type(font_with(&family, &['l']).unwrap(), fs_type).unwrap()), 1);
let r = crate::fonts::eot(&db.face(&family, "Regular"));
if embeds {
assert!(r.is_ok(), "fsType {fs_type:#06x}");
} else {
assert!(matches!(r, Err(crate::fonts::NoEmbed::Restricted)), "fsType {fs_type:#06x}");
}
}
}
+625
View File
@@ -0,0 +1,625 @@
//! Text frames as text boxes.
//!
//! [`LineBreaks::Keep`]: a frame's composed lines are cut into boxes where the column, the line
//! slot (a wrap) or a gap changes; each box holds its lines as paragraphs with line breaks. Line
//! starts come from indents, widths from character spacing on the runs (PowerPoint has no word
//! spacing, so a justified space is a run of its own), baselines from percentage line spacing and
//! space before.
//!
//! [`LineBreaks::Reflow`]: one box per frame, native columns, paragraph attributes from the
//! styles.
use std::fmt::Write as _;
use designcraft_compose::{ComposedStory, FrameText, Line, PlacedGlyph, RunStyle};
use designcraft_doc::{Align, Item, TextFrame};
use designcraft_geom::{Affine, Rect};
use crate::LineBreaks;
use crate::slide::{Exporter, XfrmBox, box_of};
use crate::xml::{emu, emu_len, esc, pct};
/// PowerPoint's single line is 1.2 em for every font; percentage spacing scales it.
const SINGLE: f64 = 1.2;
/// Where PowerPoint puts a line's baseline in its line box (fraction from the top).
const BASELINE_IN_LINE: f64 = 0.75;
/// Largest drift (points) a run's character spacing may leave before a new run starts.
const TOLERANCE: f64 = 0.3;
/// Private-use characters DesignCraft stores for markers (page numbers, section markers, breaks).
fn is_marker(c: char) -> bool {
('\u{E000}'..='\u{E1FF}').contains(&c)
}
/// One piece of a line: a cluster's text and where the composer put it.
#[derive(Clone, Debug)]
struct Unit {
text: String,
style: u32,
face: designcraft_fonts::FaceRef,
size: f64,
/// Baseline shift (points, down positive).
y: f64,
x: f64,
end: f64,
/// Width PowerPoint gives the text without extra spacing.
natural: f64,
tab: bool,
rs: Option<RunStyle>,
}
/// A run of units sharing style and character spacing.
#[derive(Clone, Debug)]
struct Run {
text: String,
style: u32,
face: designcraft_fonts::FaceRef,
size: f64,
y: f64,
/// Character spacing, points.
spc: f64,
rs: Option<RunStyle>,
}
/// A laid-out line ready to write.
#[derive(Clone, Debug)]
pub(crate) struct OutLine {
runs: Vec<Run>,
/// Text-space x where the line's first unit starts.
start: f64,
/// Text-space x where it ends.
end: f64,
baseline: f64,
descent: f64,
/// Largest font size on the line (PowerPoint's line height follows it).
size: f64,
/// The composer's leading for the line.
leading: f64,
tabs: Vec<f64>,
para: usize,
last_in_para: bool,
/// The line ends at a space the composer doesn't draw.
trailing_space: bool,
}
/// Lines that share a text box.
#[derive(Clone, Debug, Default)]
pub(crate) struct Segment {
pub lines: Vec<OutLine>,
pub x0: f64,
pub x1: f64,
}
pub fn frame(ex: &mut Exporter, it: &Item, tfr: &TextFrame, xf: Affine, opacity: f32) {
let doc = ex.doc;
let cs = ex.cache.get(doc, tfr.story, ex.page);
let Some(ft) = cs.frame(it.id) else {
ex.shape(it, xf, opacity, None);
return;
};
let story_text = doc.story(tfr.story).map(|s| s.text.clone()).unwrap_or_default();
let vertical = doc.frame_vertical(it);
if vertical {
ex.warn("vertical text frames are exported with PowerPoint's vertical text (line breaks may differ)");
}
let m = ex.to_slide * xf;
if ex.opts.line_breaks == LineBreaks::Reflow || vertical {
reflow(ex, it, tfr, ft, &cs, &story_text, xf, opacity, vertical);
return;
}
let Some((_, dm)) = box_of(it.inner_bounds(), m) else {
ex.warn("skewed text frames are exported unskewed");
// Upright where the skewed frame's box is (its parents' transforms included).
let inner = it.inner_bounds();
let mut plain = it.clone();
plain.xf = Affine::translate(xf.transform_rect_bbox(inner).origin().to_vec2() - inner.origin().to_vec2());
frame(ex, &plain, tfr, plain.xf, opacity);
return;
};
let k = (dm.sx * dm.sy).abs().sqrt();
let table_lines: Vec<usize> = ft.tables.iter().map(|t| t.line).collect();
let segs = segments(ex, &cs, ft, &story_text, &table_lines);
let simple = segs.len() == 1 && ft.notes.is_empty() && ft.tables.is_empty() && ft.decos.is_empty();
if simple && let Some(seg) = segs.first() {
// One box: the frame itself carries the text, inset to where the lines sit.
let inner = it.inner_bounds();
let r = seg_rect(seg);
let insets = [r.x0 - inner.x0, r.y0 - inner.y0, inner.x1 - r.x1, inner.y1 - r.y1];
if insets.iter().all(|v| *v >= -0.01) {
let body = body(ex, seg, r, k, opacity, insets);
ex.shape(it, xf, opacity, Some(&body));
return;
}
}
ex.shape(it, xf, opacity, None);
decos(ex, ft, m, opacity);
for seg in &segs {
text_box(ex, seg, m, k, opacity, "Text");
}
for n in &ft.notes {
if let Some(nft) = n.text.frames.first() {
let nm = m * Affine::translate(n.origin.to_vec2());
let note = segments(ex, &n.text, nft, &n.source, &[]);
decos(ex, nft, nm, opacity);
for seg in &note {
text_box(ex, seg, nm, k, opacity, "Footnote");
}
}
}
crate::table::tables(ex, ft, m, k, opacity);
anchored(ex, tfr, ft, xf, opacity);
}
/// Anchored objects in the frame's text.
fn anchored(ex: &mut Exporter, tfr: &TextFrame, ft: &FrameText, xf: Affine, opacity: f32) {
let Some(st) = ex.doc.story(tfr.story) else { return };
let objects: Vec<_> = ft.objects.iter().filter_map(|o| st.objects.get(o.index).map(|obj| (obj.item.clone(), o.origin))).collect();
for (item, origin) in objects {
ex.item(&item, xf * Affine::translate(origin.to_vec2()), opacity);
}
}
/// Paragraph shading and rules.
fn decos(ex: &mut Exporter, ft: &FrameText, m: Affine, opacity: f32) {
for d in &ft.decos {
let Some(c) = ex.color(&d.color, d.tint, opacity) else { continue };
if let Some((xfrm, _)) = box_of(d.rect, m) {
ex.rect_shape(xfrm, &format!("<a:solidFill>{c}</a:solidFill>"), "Paragraph Shading");
}
}
}
pub(crate) fn seg_rect(seg: &Segment) -> Rect {
let first = seg.lines.first();
let top = first.map_or(0.0, |l| l.baseline - BASELINE_IN_LINE * line_height(seg, 0));
let bottom = seg.lines.last().map_or(top + 1.0, |l| l.baseline + l.descent);
Rect::new(seg.x0, top, seg.x1.max(seg.x0 + 1.0), bottom.max(top + 1.0))
}
/// The PowerPoint line height used for line `i` of a segment: the baseline distance inside its
/// paragraph, else the composer's leading for the line.
fn line_height(seg: &Segment, i: usize) -> f64 {
let Some(l) = seg.lines.get(i) else { return 12.0 };
para_step(seg, i).unwrap_or(if l.leading > 0.0 { l.leading } else { l.size * SINGLE })
}
/// The baseline-to-baseline distance inside line `i`'s paragraph (the median, in this segment).
fn para_step(seg: &Segment, i: usize) -> Option<f64> {
let para = seg.lines.get(i)?.para;
let ls: Vec<&OutLine> = seg.lines.iter().filter(|l| l.para == para).collect();
let mut d: Vec<f64> = ls.windows(2).map(|w| w[1].baseline - w[0].baseline).filter(|v| *v > 0.0).collect();
if d.is_empty() {
return None;
}
d.sort_by(f64::total_cmp);
d.get(d.len() / 2).copied()
}
fn text_box(ex: &mut Exporter, seg: &Segment, m: Affine, k: f64, opacity: f32, name: &str) {
let r = seg_rect(seg);
let Some((xfrm, _)) = box_of(r, m) else { return };
let body = body(ex, seg, r, k, opacity, [0.0; 4]);
write_box(ex, xfrm, &body, name);
}
fn write_box(ex: &mut Exporter, xfrm: XfrmBox, body: &str, name: &str) {
let id = ex.id();
let _ = write!(
ex.out,
"<p:sp><p:nvSpPr><p:cNvPr id=\"{id}\" name=\"{name} {id}\"/><p:cNvSpPr txBox=\"1\"/><p:nvPr/></p:nvSpPr><p:spPr>{}<a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom><a:noFill/></p:spPr>{body}</p:sp>",
xfrm.xml()
);
}
/// Cut a frame's lines into segments and lay out their runs.
pub(crate) fn segments(ex: &mut Exporter, cs: &ComposedStory, ft: &FrameText, story: &str, skip: &[usize]) -> Vec<Segment> {
let mut out: Vec<Segment> = Vec::new();
let mut prev: Option<&Line> = None;
for (i, l) in ft.lines.iter().enumerate() {
if skip.contains(&i) {
prev = None;
continue;
}
let ol = out_line(ex, cs, l, story);
let joins = prev.is_some_and(|p| {
p.column == l.column
&& (p.x0 - l.x0).abs() < 0.5
&& (p.x1 - l.x1).abs() < 0.5
&& l.baseline > p.baseline
&& l.baseline - p.baseline < 2.5 * p.leading.max(l.leading).max(ol.size)
});
match out.last_mut() {
Some(seg) if joins => seg.lines.push(ol),
_ => out.push(Segment { lines: vec![ol], x0: l.x0, x1: l.x1 }),
}
prev = Some(l);
}
out
}
/// The text for glyph `g`: the story's characters, or what the glyph shows when the composer
/// inserted or changed it (page numbers, list labels, hyphens, case changes).
fn glyph_text(ex: &mut Exporter, g: &PlacedGlyph, story: &str, prev_src: &mut Option<usize>) -> Option<String> {
let src = story.get(g.byte..g.byte.saturating_add(g.len)).filter(|t| !t.is_empty() && !t.chars().any(is_marker));
let face = g.face.get();
if let Some(t) = src {
*prev_src = Some(g.byte);
// A single character drawn as another glyph (All Caps): what the glyph shows.
let mut cs = t.chars();
if let (Some(c), None) = (cs.next(), cs.next())
&& let Some(shown) = ex.fonts.char_of(face, g.gid)
&& shown != c
&& ex.fonts.glyph(face, c) != Some(g.gid)
&& shown.to_lowercase().eq(c.to_lowercase())
{
return Some(shown.to_string());
}
return Some(t.to_string());
}
if g.len == 0 && *prev_src == Some(g.byte) {
// Further glyphs of a cluster already written.
return None;
}
*prev_src = None;
ex.fonts.char_of(face, g.gid).map(|c| c.to_string())
}
fn out_line(ex: &mut Exporter, cs: &ComposedStory, l: &Line, story: &str) -> OutLine {
let mut units: Vec<Unit> = Vec::new();
let mut prev_src = None;
let is_tab = |g: &PlacedGlyph| story.get(g.byte..g.byte.saturating_add(g.len)) == Some("\t");
let last_shown = l.glyphs.iter().rposition(|g| g.visible || is_tab(g));
let mut trailing_space = false;
for (i, g) in l.glyphs.iter().enumerate() {
let next_x = l.glyphs.get(i + 1).map_or(g.x + g.adv, |n| n.x);
let is_tab = is_tab(g);
if !g.visible && !is_tab {
if last_shown.is_some_and(|k| i > k) {
trailing_space |= story.get(g.byte..g.byte.saturating_add(g.len)).is_some_and(|t| t.chars().any(char::is_whitespace));
} else if let Some(u) = units.last_mut() {
// Invisible glyphs inside the line still take up their room.
u.end = next_x.max(u.end);
}
continue;
}
let face = g.face.get();
let size = g.sy * face.upem;
let text = if is_tab { Some("\t".to_string()) } else { glyph_text(ex, g, story, &mut prev_src) };
let Some(text) = text else {
if let Some(u) = units.last_mut() {
u.end = next_x.max(u.end);
}
continue;
};
let natural: f64 = if is_tab { 0.0 } else { text.chars().map(|c| ex.fonts.advance(face, c).unwrap_or(face.upem * 0.5) * g.sy).sum() };
let rs = cs.styles.get(g.style as usize).cloned();
units.push(Unit { text, style: g.style, face: g.face, size, y: g.y, x: g.x, end: next_x, natural, tab: is_tab, rs });
}
// A space the composer left out of the line (it ends there): in the story at the line's end.
let end_byte = l.glyphs.iter().filter(|g| g.len > 0).map(|g| g.byte.saturating_add(g.len)).max().unwrap_or(l.range.end);
let after = story.get(end_byte.min(story.len())..).and_then(|t| t.chars().next());
trailing_space |= !l.last_in_para && after.is_some_and(|c| c.is_whitespace() && c != '\n' && c != '\r');
let start = units.first().map_or(l.x0, |u| u.x);
let end = units.last().map_or(l.x0, |u| u.end);
let mut runs: Vec<Run> = Vec::new();
let mut tabs = Vec::new();
// PowerPoint's pen, and the spacing of the run it is in.
let mut pen = start;
for u in &units {
if u.tab {
tabs.push(u.end);
runs.push(Run { text: "\t".into(), style: u.style, face: u.face, size: u.size, y: u.y, spc: 0.0, rs: u.rs.clone() });
pen = u.end;
continue;
}
let n = u.text.chars().count().max(1) as f64;
if let Some(r) = runs.last_mut().filter(|r| !r.text.ends_with('\t'))
&& r.style == u.style
&& r.face.get().id() == u.face.get().id()
&& (r.size - u.size).abs() < 0.005
&& (r.y - u.y).abs() < 0.01
&& (pen + u.natural + r.spc * n - u.end).abs() <= TOLERANCE
{
r.text.push_str(&u.text);
pen += u.natural + r.spc * n;
continue;
}
// A new run whose spacing lands this unit where the composer put it (0.01 pt steps).
let spc = ((u.end - pen - u.natural) / n * 100.0).round() / 100.0;
let spc = if spc.is_finite() { spc.clamp(-100.0, 100.0) } else { 0.0 };
runs.push(Run { text: u.text.clone(), style: u.style, face: u.face, size: u.size, y: u.y, spc, rs: u.rs.clone() });
pen += u.natural + spc * n;
}
let size = units.iter().map(|u| u.size).fold(0.0, f64::max);
OutLine {
runs,
start,
end,
baseline: l.baseline,
descent: l.descent,
size: if size > 0.0 { size } else { (l.ascent + l.descent).max(1.0) / SINGLE },
leading: l.leading,
tabs,
para: l.para,
last_in_para: l.last_in_para,
trailing_space,
}
}
/// Paragraph alignment read off the lines: centred and right-aligned lines leave room on both or
/// the left side; everything else starts at its left indent.
fn align_of(seg: &Segment, lines: &[OutLine]) -> &'static str {
let gaps: Vec<(f64, f64)> = lines.iter().map(|l| (l.start - seg.x0, seg.x1 - l.end)).collect();
let roomy = |g: &(f64, f64)| g.0 > 1.0 || g.1 > 1.0;
if !gaps.iter().any(roomy) {
return "l";
}
if gaps.iter().all(|(a, b)| (a - b).abs() < 0.75) && gaps.iter().any(|(a, _)| *a > 1.0) {
return "ctr";
}
// Flush right with room on the left: more than an indented first line of justified text.
let indented = gaps.iter().filter(|(a, _)| *a > 1.0).count();
if gaps.iter().all(|(_, b)| *b < 0.75) && (indented > 1 || (gaps.len() == 1 && indented == 1)) {
return "r";
}
"l"
}
/// `<p:txBody>` for a segment whose box is `r` (text space); `insets` place the text inside a
/// larger shape (left, top, right, bottom).
fn body(ex: &mut Exporter, seg: &Segment, r: Rect, k: f64, opacity: f32, insets: [f64; 4]) -> String {
let [li, ti, ri, bi] = insets.map(|v| emu_len(v * k));
format!(
"<p:txBody><a:bodyPr wrap=\"none\" lIns=\"{li}\" tIns=\"{ti}\" rIns=\"{ri}\" bIns=\"{bi}\" rtlCol=\"0\" anchor=\"t\"><a:noAutofit/></a:bodyPr><a:lstStyle/>{}</p:txBody>",
paragraphs(ex, seg, r, k, opacity)
)
}
/// The `<a:p>` elements of a segment whose box is `r` (text space).
pub(crate) fn paragraphs(ex: &mut Exporter, seg: &Segment, r: Rect, k: f64, opacity: f32) -> String {
let mut s = String::new();
let mut prev_end: Option<(f64, f64)> = None;
// The segment's lines, a paragraph at a time; `at` is the index of each one's first line.
let mut at = 0;
for lines in seg.lines.chunk_by(|a, b| a.para == b.para && !a.last_in_para) {
let i = at;
at += lines.len();
let (Some(first), Some(last)) = (lines.first(), lines.last()) else { continue };
let mut h = line_height(seg, i);
let r_in = BASELINE_IN_LINE;
if let Some((prev_base, prev_h)) = prev_end
&& lines.len() == 1
{
// A one-line paragraph closer to the line before than its own line box allows (tight
// display type): a smaller box puts it there.
let natural = (1.0 - r_in) * prev_h + r_in * h;
let gap = first.baseline - prev_base;
if gap < natural {
h = ((gap - (1.0 - r_in) * prev_h) / r_in).max(first.size * 0.2);
}
}
let algn = align_of(seg, lines);
let mut ppr = format!("<a:pPr algn=\"{algn}\"");
if algn == "l" {
let rest = lines.get(1).map_or(first.start, |l| l.start) - r.x0;
let ind = first.start - r.x0 - rest;
if rest.abs() > 0.005 {
let _ = write!(ppr, " marL=\"{}\"", emu(rest * k));
}
if ind.abs() > 0.005 {
let _ = write!(ppr, " indent=\"{}\"", emu(ind * k));
}
}
ppr.push('>');
let _ = write!(ppr, "<a:lnSpc><a:spcPct val=\"{}\"/></a:lnSpc>", pct(h / (first.size * SINGLE)));
if let Some((prev_base, prev_h)) = prev_end {
// PowerPoint puts the next line one line box down: the rest of the previous line's box
// and the top part of this one. Space Before makes up the difference.
let natural = (1.0 - r_in) * prev_h + r_in * h;
let before = first.baseline - prev_base - natural;
if before > 0.005 {
let _ = write!(ppr, "<a:spcBef><a:spcPts val=\"{}\"/></a:spcBef>", (before * k * 100.0).round() as i64);
}
}
let tabs: Vec<f64> = lines.iter().flat_map(|l| l.tabs.iter().copied()).collect();
if !tabs.is_empty() {
ppr.push_str("<a:tabLst>");
let mut sorted = tabs.clone();
sorted.sort_by(f64::total_cmp);
sorted.dedup_by(|a, b| (*a - *b).abs() < 0.01);
for t in sorted {
let _ = write!(ppr, "<a:tab pos=\"{}\" algn=\"l\"/>", emu((t - r.x0) * k));
}
ppr.push_str("</a:tabLst>");
}
ppr.push_str("</a:pPr>");
s.push_str("<a:p>");
s.push_str(&ppr);
for (n, l) in lines.iter().enumerate() {
if n > 0 {
let _ = write!(s, "<a:br>{}</a:br>", rpr(ex, l.runs.first(), l.size, k, opacity, "a:rPr"));
}
let n_runs = l.runs.len();
for (ri, run) in l.runs.iter().enumerate() {
// The undrawn space at a left-aligned line's end goes back in (invisible there),
// so removing the break doesn't join words.
let tail = if ri + 1 == n_runs && l.trailing_space && algn == "l" && !run.text.ends_with(char::is_whitespace) { " " } else { "" };
let _ = write!(s, "<a:r>{}<a:t>{}{tail}</a:t></a:r>", rpr(ex, Some(run), run.size, k, opacity, "a:rPr"), esc(&run.text));
}
}
s.push_str(&rpr(ex, last.runs.last(), last.size, k, opacity, "a:endParaRPr"));
s.push_str("</a:p>");
prev_end = Some((last.baseline, h));
}
s
}
/// Run properties (`a:rPr`, or `a:endParaRPr` for the paragraph mark).
fn rpr(ex: &mut Exporter, run: Option<&Run>, size: f64, k: f64, opacity: f32, tag: &str) -> String {
// PowerPoint draws raised and lowered text at 2/3 of its size: state it larger.
let shifted = run.is_some_and(|r| r.y.abs() > 0.01 && r.size > 0.0);
let size = if shifted { size * 1.5 } else { size };
let sz = ((size * k * 100.0).round() as i64).clamp(100, 400_000);
let Some(run) = run else { return format!("<{tag} lang=\"en-US\" sz=\"{sz}\" dirty=\"0\"/>") };
let cs = run.rs.as_ref();
let font = ex.fonts.use_face(run.face);
let mut a = format!("<{tag} lang=\"en-US\" sz=\"{sz}\"");
let italic = font.italic || cs.is_some_and(|c| c.skew.abs() > 0.01);
if font.bold {
a.push_str(" b=\"1\"");
}
if italic {
a.push_str(" i=\"1\"");
}
if let Some(st) = cs {
if st.underline {
a.push_str(" u=\"sng\"");
}
if st.strikethrough {
a.push_str(" strike=\"sngStrike\"");
}
}
// Kerning off: the composer's kerning is in the character spacing already.
a.push_str(" kern=\"400000\"");
let spc = (run.spc * k * 100.0).round() as i64;
if spc != 0 && tag == "a:rPr" {
let _ = write!(a, " spc=\"{spc}\"");
}
if shifted {
let _ = write!(a, " baseline=\"{}\"", pct(-run.y / size));
}
a.push_str(" dirty=\"0\">");
if let Some(st) = cs {
if st.stroke != designcraft_color::swatch::NONE
&& st.stroke_weight > 0.0
&& let Some(c) = ex.color(&st.stroke, st.stroke_tint, opacity)
{
let _ = write!(a, "<a:ln w=\"{}\"><a:solidFill>{c}</a:solidFill></a:ln>", emu_len(st.stroke_weight * k));
}
match ex.color(&st.fill, st.fill_tint, opacity) {
Some(c) => {
let _ = write!(a, "<a:solidFill>{c}</a:solidFill>");
}
None => a.push_str("<a:noFill/>"),
}
}
let fam = esc(&font.family);
let _ = write!(a, "<a:latin typeface=\"{fam}\"/><a:ea typeface=\"{fam}\"/><a:cs typeface=\"{fam}\"/></{tag}>");
a
}
/// Reflow: the frame as one shape whose text PowerPoint sets, in the frame's columns.
#[allow(clippy::too_many_arguments)]
fn reflow(ex: &mut Exporter, it: &Item, tfr: &TextFrame, ft: &FrameText, cs: &ComposedStory, story: &str, xf: Affine, opacity: f32, vertical: bool) {
let doc = ex.doc;
let Some(st) = doc.story(tfr.story) else {
ex.shape(it, xf, opacity, None);
return;
};
let o = &tfr.options;
let m = ex.to_slide * xf;
let k = box_of(it.inner_bounds(), m).map_or(1.0, |(_, d)| (d.sx * d.sy).abs().sqrt());
let [t, l, b, r] = o.inset.map(|v| emu_len(v * k));
let cols = ft.columns.len().max(1);
let mut s = format!("<p:txBody><a:bodyPr wrap=\"square\" lIns=\"{l}\" tIns=\"{t}\" rIns=\"{r}\" bIns=\"{b}\" rtlCol=\"0\"");
if cols > 1 {
let _ = write!(s, " numCol=\"{cols}\" spcCol=\"{}\"", emu_len(o.gutter * k));
}
if vertical {
s.push_str(" vert=\"eaVert\"");
}
let anchor = match o.vertical_justification {
designcraft_doc::VerticalJustification::Center => "ctr",
designcraft_doc::VerticalJustification::Bottom => "b",
designcraft_doc::VerticalJustification::Justify => "just",
designcraft_doc::VerticalJustification::Top => "t",
};
let _ = write!(s, " anchor=\"{anchor}\"><a:noAutofit/></a:bodyPr><a:lstStyle/>");
// The paragraphs this frame shows, with the runs of their composed glyphs.
let ranges = st.para_ranges();
let mut first = true;
for (pi, range) in ranges.iter().enumerate() {
if range.end < ft.range.start || range.start >= ft.range.end.max(ft.range.start + 1) {
continue;
}
let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi).collect();
let Some(pf) = st.paras.get(pi) else { continue };
let (pp, chars) = doc.styles.resolve_para(pf);
let algn = match pp.align {
Align::Center => "ctr",
Align::Right => "r",
Align::LeftJustified | Align::CenterJustified | Align::RightJustified => "just",
Align::FullyJustified => "dist",
_ => "l",
};
let mut ppr = format!("<a:pPr algn=\"{algn}\"");
if pp.left_indent.abs() > 0.005 {
let _ = write!(ppr, " marL=\"{}\"", emu(pp.left_indent * k));
}
if pp.right_indent.abs() > 0.005 {
let _ = write!(ppr, " marR=\"{}\"", emu(pp.right_indent * k));
}
if pp.first_line_indent.abs() > 0.005 {
let _ = write!(ppr, " indent=\"{}\"", emu(pp.first_line_indent * k));
}
ppr.push('>');
let size = chars.size.max(1.0);
let lead = match lines.windows(2).map(|w| w[1].baseline - w[0].baseline).find(|d| *d > 0.0) {
Some(d) => d,
None => lines.first().map_or(size * pp.auto_leading, |l| l.leading.max(1.0)),
};
let _ = write!(ppr, "<a:lnSpc><a:spcPct val=\"{}\"/></a:lnSpc>", pct(lead / (size * SINGLE)));
if !first && pp.space_before > 0.0 {
let _ = write!(ppr, "<a:spcBef><a:spcPts val=\"{}\"/></a:spcBef>", (pp.space_before * k * 100.0).round() as i64);
}
if pp.space_after > 0.0 {
let _ = write!(ppr, "<a:spcAft><a:spcPts val=\"{}\"/></a:spcAft>", (pp.space_after * k * 100.0).round() as i64);
}
ppr.push_str("</a:pPr>");
s.push_str("<a:p>");
s.push_str(&ppr);
let mut last_size = size;
for l in &lines {
let ol = out_line(ex, cs, l, story);
let mut runs: Vec<Run> = Vec::new();
for mut r in ol.runs {
// PowerPoint sets the spacing; only tracking-sized adjustments would survive, and
// they vary line to line, so none is kept.
r.spc = 0.0;
match runs.last_mut() {
Some(p)
if p.style == r.style
&& p.face.get().id() == r.face.get().id()
&& (p.size - r.size).abs() < 0.005
&& (p.y - r.y).abs() < 0.01 =>
{
p.text.push_str(&r.text)
}
_ => runs.push(r),
}
}
// Composer hyphens at line ends go: PowerPoint breaks the lines itself.
if l.hyphenated
&& let Some(r) = runs.last_mut()
&& r.text.ends_with(['-', '\u{2010}'])
{
r.text.pop();
}
if ol.trailing_space
&& let Some(r) = runs.last_mut()
&& !r.text.ends_with(char::is_whitespace)
{
r.text.push(' ');
}
for run in &runs {
let _ = write!(s, "<a:r>{}<a:t>{}</a:t></a:r>", rpr(ex, Some(run), run.size, k, opacity, "a:rPr"), esc(&run.text));
last_size = run.size;
}
}
s.push_str(&rpr(ex, None, last_size, k, opacity, "a:endParaRPr"));
s.push_str("</a:p>");
first = false;
}
s.push_str("</p:txBody>");
ex.shape(it, xf, opacity, Some(&s));
}
+75
View File
@@ -0,0 +1,75 @@
//! XML text helpers and DrawingML units.
/// English Metric Units per point.
pub const EMU_PER_PT: f64 = 12_700.0;
/// Points → EMU, saturating (input-derived geometry may be huge or not finite).
pub fn emu(pt: f64) -> i64 {
if !pt.is_finite() {
return 0;
}
(pt * EMU_PER_PT).round().clamp(-27_273_042_316_900.0, 27_273_042_316_900.0) as i64
}
/// A non-negative extent in EMU (DrawingML rejects negative sizes).
pub fn emu_len(pt: f64) -> i64 {
emu(pt).max(0)
}
/// Degrees → DrawingML angle units (60 000ths of a degree), normalised to 0..360°.
pub fn angle(deg: f64) -> i64 {
if !deg.is_finite() {
return 0;
}
(deg.rem_euclid(360.0) * 60_000.0).round() as i64 % 21_600_000
}
/// A 0..1 fraction → DrawingML percentage units (1000ths of a percent).
pub fn pct(v: f64) -> i64 {
if !v.is_finite() {
return 0;
}
(v * 100_000.0).round().clamp(-100_000_000.0, 100_000_000.0) as i64
}
/// Escape text for element content and attribute values. Characters XML 1.0 can't hold are
/// dropped.
pub fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => o.push_str("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\'' => o.push_str("&apos;"),
'\t' | '\n' | '\r' => o.push(c),
c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
c => o.push(c),
}
}
o
}
/// sRGB 0..1 → `RRGGBB`.
pub fn hex(rgb: [f32; 3]) -> String {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
format!("{:02X}{:02X}{:02X}", q(rgb[0]), q(rgb[1]), q(rgb[2]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn units() {
assert_eq!(emu(1.0), 12_700);
assert_eq!(emu(f64::NAN), 0);
assert_eq!(emu_len(-3.0), 0);
assert_eq!(angle(-90.0), 16_200_000);
assert_eq!(angle(360.0), 0);
assert_eq!(pct(0.5), 50_000);
assert_eq!(esc("a<b & \"c\"\u{1}"), "a&lt;b &amp; &quot;c&quot;");
assert_eq!(hex([1.0, 0.0, 0.5]), "FF0080");
}
}
+55 -20
View File
@@ -156,14 +156,27 @@ fn item_area(it: &Item) -> Option<Rect> {
return None;
}
let pad = it.stroke.extent().max(0.0) + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 };
Some(it.bounds().inflate(pad, pad))
Some(painted_bounds(it).inflate(pad, pad))
}
/// An item's bounds, grown to its text: text is composed in the box around the frame's path in
/// the frame's own coordinates, which a sheared then rotated frame turns past its path's bounds.
fn painted_bounds(it: &Item) -> Rect {
let mut b = it.bounds();
if it.is_text_frame() {
b = b.union(it.xf.transform_rect_bbox(it.inner_bounds()));
}
for c in it.children() {
b = b.union(it.xf.transform_rect_bbox(painted_bounds(c)));
}
b
}
/// A text frame's area: its spread and bounds with room for glyph overhang.
fn frame_area(d: &Document, f: ItemId) -> Option<(SpreadRef, Rect)> {
let loc = d.find(f)?;
let it = d.item_at(&loc)?;
let b = d.parent_xf(&loc).transform_rect_bbox(it.bounds());
let b = d.parent_xf(&loc).transform_rect_bbox(painted_bounds(it));
// Overhang of italics, swashes and big initials, and anchored objects pinned to lines.
Some((loc.spread, b.inflate(24.0 + 0.1 * b.width().max(b.height()), 24.0)))
}
@@ -207,32 +220,26 @@ mod tests {
assert!(dmg.iter().any(|(_, r)| r.contains(designcraft_geom::Point::new(350.0, 100.0))));
}
#[test]
fn patched_render_equals_full_render() {
/// Channels where `old`'s render patched over `new`'s damage differs from `new`'s full render.
fn patched_diff(old: &Document, new: &Document, (w, h): (u32, u32), zoom: f64) -> usize {
use crate::{Placed, RenderOptions, Renderer};
use designcraft_geom::{Affine, Vec2};
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 300.0, 200.0), lid, "Hello there", ParaFormat::default()).unwrap();
let cache = designcraft_compose::Cache::new();
let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::translate(Vec2::ZERO) }];
let (w, h) = (400u32, 300u32);
let view = Affine::scale(1.3);
let view = Affine::scale(zoom);
let mut r = Renderer::new();
r.threads = 0;
let opts = RenderOptions::default();
let old = d.clone();
let mut img = r.render(&old, &cache, &placed, w, h, view, &opts);
d.story_mut(sid).unwrap().insert(5, ", typed");
let full = r.render(&d, &cache, &placed, w, h, view, &opts);
let mut img = r.render(old, &cache, &placed, w, h, view, &opts);
let full = r.render(new, &cache, &placed, w, h, view, &opts);
// Patch the damaged pixels.
let regions = damage(&old, &d).unwrap();
let regions = damage(old, new).unwrap();
let u = regions.iter().map(|(_, r)| *r).reduce(|a, b| a.union(b)).unwrap();
let x0 = ((u.x0 * 1.3).floor().max(0.0)) as u32;
let y0 = ((u.y0 * 1.3).floor().max(0.0)) as u32;
let x1 = ((u.x1 * 1.3).ceil() as u32).min(w);
let y1 = ((u.y1 * 1.3).ceil() as u32).min(h);
let patch = r.render(&d, &cache, &placed, x1 - x0, y1 - y0, Affine::translate((-(x0 as f64), -(y0 as f64))) * view, &opts);
let x0 = ((u.x0 * zoom).floor().max(0.0)) as u32;
let y0 = ((u.y0 * zoom).floor().max(0.0)) as u32;
let x1 = ((u.x1 * zoom).ceil() as u32).min(w);
let y1 = ((u.y1 * zoom).ceil() as u32).min(h);
let patch = r.render(new, &cache, &placed, x1 - x0, y1 - y0, Affine::translate((-(x0 as f64), -(y0 as f64))) * view, &opts);
for y in y0..y1 {
for x in x0..x1 {
let pi = (((y - y0) * (x1 - x0) + (x - x0)) * 4) as usize;
@@ -240,10 +247,38 @@ mod tests {
img.pixels[ii..ii + 4].copy_from_slice(&patch.pixels[pi..pi + 4]);
}
}
let diff = img.pixels.iter().zip(&full.pixels).filter(|(a, b)| a.abs_diff(**b) > 2).count();
img.pixels.iter().zip(&full.pixels).filter(|(a, b)| a.abs_diff(**b) > 2).count()
}
#[test]
fn patched_render_equals_full_render() {
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(72.0, 72.0, 300.0, 200.0), lid, "Hello there", ParaFormat::default()).unwrap();
let old = d.clone();
d.story_mut(sid).unwrap().insert(5, ", typed");
let diff = patched_diff(&old, &d, (400, 300), 1.3);
assert_eq!(diff, 0, "patched image differs from the full render in {diff} channels");
}
#[test]
fn moving_a_sheared_turned_text_frame_leaves_no_trail() {
use designcraft_geom::Affine;
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let lid = d.default_layer();
let text = "Lorem ipsum dolor sit amet. ".repeat(60);
let (f, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 200.0), lid, &text, ParaFormat::default()).unwrap();
// Sheared outline (its box's corners are outside it, the text's first and last lines in
// them), then turned: the text reaches past the outline's bounds.
let it = d.item_mut(f).unwrap();
it.path = designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 200.0)).transformed(Affine::skew(0.5, 0.0));
it.xf = Affine::translate((150.0, 60.0)) * Affine::rotate(30f64.to_radians());
let old = d.clone();
d.item_mut(f).unwrap().xf = Affine::translate((210.0, 90.0)) * Affine::rotate(30f64.to_radians());
let diff = patched_diff(&old, &d, (700, 560), 1.0);
assert_eq!(diff, 0, "the moved text left {diff} stale channels behind");
}
#[test]
fn only_changed_frames_of_a_thread_are_damaged() {
let mut d = Document::new(&NewDocument { pages: 3, facing_pages: false, ..Default::default() });
+13 -2
View File
@@ -1308,9 +1308,10 @@ fn offer_parent_chain(
offer_parent_chain(best, cx, req, flags, axis, tol, doc_x, doc_side, overridden, next_index, seen);
}
/// Left page of a facing parent, otherwise the last page. Same choice as `Document::parent_page_for`.
/// First page of a multi-page parent for a left-hand or single-sided page, otherwise the last page.
/// Same choice as `Document::parent_page_for`.
fn shown_parent_page_index(page_count: usize, side: PageSide) -> Option<usize> {
if page_count >= 2 && side == PageSide::Left { Some(0) } else { page_count.checked_sub(1) }
if page_count >= 2 && matches!(side, PageSide::Left | PageSide::Single) { Some(0) } else { page_count.checked_sub(1) }
}
fn shift_x(r: Rect, dx: f64) -> Rect {
@@ -1461,6 +1462,16 @@ mod tests {
use super::*;
/// Snapping offers the parent page the canvas shows: a single-sided page uses a two-page parent's first page.
#[test]
fn shown_parent_page_matches_page_side() {
assert_eq!(shown_parent_page_index(2, PageSide::Single), Some(0));
assert_eq!(shown_parent_page_index(2, PageSide::Left), Some(0));
assert_eq!(shown_parent_page_index(2, PageSide::Right), Some(1));
assert_eq!(shown_parent_page_index(1, PageSide::Single), Some(0));
assert_eq!(shown_parent_page_index(0, PageSide::Single), None);
}
// The factory flags are const. Pin them anyway.
#[test]
#[allow(clippy::assertions_on_constants)]
+1 -1
View File
@@ -52,7 +52,7 @@ pub fn paint_mark(ui: &egui::Ui, rect: Rect, color: Color32) {
let mut res = vello_cpu::Resources::new();
rc.render(&mut pm, &mut res);
let ci = egui::ColorImage::from_rgba_premultiplied([px as usize, px as usize], pm.data_as_u8_slice());
let t = ctx.load_texture("artcraft_mark", ci, egui::TextureOptions::LINEAR);
let t = crate::widgets::load_texture(ctx, "artcraft_mark", ci, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(key, t.clone()));
t
});
+315 -6
View File
@@ -12,6 +12,10 @@ use serde_json::{Value, json};
use crate::{DesignApp, ScreenMode, View, theme::Tokens};
pub const RULER: f32 = 15.0;
/// Width of the view's scrollbars.
pub const SCROLLBAR: f32 = 10.0;
/// Shortest scrollbar thumb.
const MIN_THUMB: f32 = 18.0;
/// Canvas ↔ screen transform.
#[derive(Clone, Copy, Debug)]
@@ -307,10 +311,14 @@ fn mods(i: &egui::InputState, space: bool) -> Mods {
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
let outer = ui.available_rect_before_wrap();
let rot = app.view().map_or(0, |v| v.rotation % 4);
let presenting = app.ui.screen_mode == ScreenMode::Presentation;
// Scrollbars sit outside the view (right and bottom); rulers and the view share the rest.
let bars = !presenting && app.session.active().is_some();
let full = if bars { Rect::from_min_max(outer.min, outer.max - vec2(SCROLLBAR, SCROLLBAR)) } else { outer };
// Rulers measure the unrotated layout, so they hide while the view is rotated.
let rulers = app.ui.rulers && app.ui.screen_mode != ScreenMode::Presentation && rot == 0;
let rulers = app.ui.rulers && !presenting && rot == 0;
let screen = if rulers { Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full };
let rect = view_rect(screen, rot);
app.canvas_rect = Some(rect);
@@ -319,6 +327,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
fit(app, rect, "spread");
}
handle_input(app, ui, &resp, rect);
if bars {
scrollbars(app, ui, outer, screen, rect, &t);
}
let Some(st) = app.session.active() else { return };
let Some(&v) = app.view() else { return };
let xf = Xf::new(rect, &v);
@@ -408,11 +419,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
}
// Cursor.
if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p)) {
let space = ui.input(|i| i.key_down(egui::Key::Space)) && !app.session.wants_text();
let wants_text = app.session.wants_text();
let space = ui.input(|i| i.key_down(egui::Key::Space)) && !wants_text;
let middle = ui.input(|i| i.pointer.middle_down());
let c = if middle {
let alt_hand: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_alt_hand", app.pane)))).unwrap_or(false);
let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false);
let alt_text = wants_text && !tool_drag && ui.input(|i| i.modifiers.alt);
let c = if middle || alt_hand {
Cursor::HandGrab
} else if space {
} else if space || alt_text {
Cursor::Hand
} else {
app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info())
@@ -587,6 +602,11 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key:
let Some(regions) = designcraft_render::damage::damage_with(&old, &new, Some(&app.session.cache)) else { return false };
let t0 = crate::now_ms();
let (tw, th) = ((sh.size.0 as f64 * ppp).round() as i64, (sh.size.1 as f64 * ppp).round() as i64);
// A texture `upload` sampled down to the GPU limit has other pixels than the view: a region
// computed in view pixels would be written outside it. Render it whole instead.
if app.canvas.texture.as_ref().is_none_or(|t| t.size() != [tw.max(0) as usize, th.max(0) as usize]) {
return false;
}
let k = sh.zoom * ppp;
let mut px: Option<(i64, i64, i64, i64)> = None;
for (r, rect) in &regions {
@@ -667,6 +687,7 @@ fn upload(app: &mut DesignApp, ctx: &egui::Context, mut img: designcraft_render:
designcraft_render::separation_view(&mut img, None, app.ui.ink_limit);
}
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let ci = crate::widgets::fit_texture(ci, ctx.input(|i| i.max_texture_side));
match &mut app.canvas.texture {
Some(tex) => tex.set(ci, egui::TextureOptions::LINEAR),
None => app.canvas.texture = Some(ctx.load_texture("canvas", ci, egui::TextureOptions::LINEAR)),
@@ -1336,7 +1357,7 @@ fn draw_inverse_highlight(
let mut res = vello_cpu::Resources::new();
rc.render(&mut pm, &mut res);
let ci = egui::ColorImage::from_rgba_premultiplied([w as usize, h as usize], pm.data_as_u8_slice());
let t = ctx.load_texture("inverse_selection", ci, egui::TextureOptions::NEAREST);
let t = crate::widgets::load_texture(ctx, "inverse_selection", ci, egui::TextureOptions::NEAREST);
ctx.data_mut(|d| d.insert_temp(cache_id, (key, t.clone())));
t
}
@@ -1596,6 +1617,108 @@ fn fmt_tick(v: f64) -> String {
if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") }
}
/// Move the view's content by a screen-space distance `d` (rotated views move in screen axes).
fn pan(app: &mut DesignApp, xf: &Xf, d: egui::Vec2) {
let d = xf.unrotate_delta(d);
if let Some(v) = app.view_mut() {
v.origin = Point::new(v.origin.x - d.x as f64 / v.zoom, v.origin.y - d.y as f64 / v.zoom);
}
}
/// A scrollbar thumb on a track `track` pixels long, for `visible` (the view) within `content`
/// (the pasteboard), both spans along the bar's axis in screen pixels: the thumb's start and
/// length on the track, and the content pixels one thumb pixel moves (0 when nothing scrolls).
fn thumb_span(track: f32, content: (f32, f32), visible: (f32, f32)) -> (f32, f32, f32) {
let track = track.max(0.0);
let (lo, hi) = (content.0.min(visible.0), content.1.max(visible.1));
let (total, shown) = (hi - lo, visible.1 - visible.0);
let scrollable = total - shown;
if !total.is_finite() || !shown.is_finite() || total <= 0.0 || scrollable <= 0.0 {
return (0.0, track, 0.0);
}
let len = (track * shown / total).max(MIN_THUMB.min(track));
let free = track - len;
if free <= 0.0 {
return (0.0, track, 0.0);
}
((visible.0 - lo) / scrollable * free, len, scrollable / free)
}
/// The track and thumb of the vertical (or horizontal) scrollbar of the view `screen` inside
/// `outer`, and the content pixels per thumb pixel.
fn scrollbar_rects(outer: Rect, screen: Rect, content: Rect, vertical: bool) -> (Rect, Rect, f32) {
if vertical {
let track = Rect::from_min_max(pos2(outer.max.x - SCROLLBAR, screen.min.y), pos2(outer.max.x, screen.max.y));
let (start, len, k) = thumb_span(track.height(), (content.min.y, content.max.y), (screen.min.y, screen.max.y));
(track, Rect::from_min_size(pos2(track.min.x, track.min.y + start), vec2(SCROLLBAR, len)), k)
} else {
let track = Rect::from_min_max(pos2(screen.min.x, outer.max.y - SCROLLBAR), pos2(screen.max.x, outer.max.y));
let (start, len, k) = thumb_span(track.width(), (content.min.x, content.max.x), (screen.min.x, screen.max.x));
(track, Rect::from_min_size(pos2(track.min.x + start, track.min.y), vec2(len, SCROLLBAR)), k)
}
}
/// The view's scrollbars, on the right and bottom edges of `outer`: they span the pasteboard;
/// dragging a thumb pans, clicking the track pages by one view.
fn scrollbars(app: &mut DesignApp, ui: &mut egui::Ui, outer: Rect, screen: Rect, rect: Rect, t: &Tokens) {
let content = |app: &DesignApp| {
let st = app.session.active()?;
let v = app.view()?;
let xf = Xf::new(rect, v);
Some((xf, xf.rect(CanvasLayout::new(&st.doc, st.editing_parents).pasteboard)))
};
let Some((xf, cb)) = content(app) else { return };
let press = ui.input(|i| i.pointer.press_origin());
let mut hot = [false; 2];
for (n, vertical) in [true, false].into_iter().enumerate() {
let (track, thumb, k) = scrollbar_rects(outer, screen, cb, vertical);
let id = egui::Id::new(("canvas_scrollbar", app.pane, vertical));
let resp = ui.interact(track, id, Sense::click_and_drag());
let grab_id = id.with("grab");
if resp.drag_started() {
let on_thumb = press.is_some_and(|p| thumb.contains(p));
ui.data_mut(|d| d.insert_temp(grab_id, on_thumb));
}
let grabbed = resp.dragged() && ui.data(|d| d.get_temp::<bool>(grab_id)).unwrap_or(false);
if !resp.dragged() {
ui.data_mut(|d| d.remove::<bool>(grab_id));
}
let along = |p: egui::Vec2| if vertical { p.y } else { p.x };
let page = along(screen.size());
let mut moved = 0.0;
if grabbed {
moved = -along(resp.drag_delta()) * k;
} else if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
let (p, a, b) = if vertical { (p.y, thumb.min.y, thumb.max.y) } else { (p.x, thumb.min.x, thumb.max.x) };
if p < a {
moved = page;
} else if p > b {
moved = -page;
}
}
if moved != 0.0 && moved.is_finite() {
pan(app, &xf, if vertical { vec2(0.0, moved) } else { vec2(moved, 0.0) });
}
if let Some(slot) = hot.get_mut(n) {
*slot = grabbed || resp.hovered();
}
}
// Painted from the view as it is after this frame's input.
let Some((_, cb)) = content(app) else { return };
let painter = ui.painter_at(outer);
let right = Rect::from_min_max(pos2(outer.max.x - SCROLLBAR, outer.min.y), outer.max);
let bottom = Rect::from_min_max(pos2(outer.min.x, outer.max.y - SCROLLBAR), outer.max);
painter.rect_filled(right, 0.0, t.panel_darker);
painter.rect_filled(bottom, 0.0, t.panel_darker);
for (n, vertical) in [true, false].into_iter().enumerate() {
let (_, thumb, _) = scrollbar_rects(outer, screen, cb, vertical);
let alpha = if hot.get(n).copied().unwrap_or(false) { 0.6 } else { 0.35 };
painter.rect_filled(thumb.shrink(2.0), SCROLLBAR / 2.0 - 2.0, t.text.gamma_multiply(alpha));
}
}
fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect) {
let Some(v) = app.view().copied() else { return };
let xf = Xf::new(rect, &v);
@@ -1633,6 +1756,21 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
}
return;
}
// Editing text (Space types a space): Alt held at the press gives the Hand tool until the
// button is released, and the caret stays. Alt pressed later in a text drag copies the text.
let alt_hand_id = egui::Id::new(("canvas_alt_hand", app.pane));
let mut alt_hand: bool = ui.data(|d| d.get_temp(alt_hand_id)).unwrap_or(false);
let (press, alt, primary_down) = ui.input(|i| (i.pointer.primary_pressed(), i.modifiers.alt, i.pointer.primary_down()));
if press && alt && wants_text && !tool_drag && resp.hovered() {
alt_hand = true;
}
if alt_hand {
if resp.dragged() {
pan(app, &xf, resp.drag_delta());
}
ui.data_mut(|d| d.insert_temp(alt_hand_id, primary_down));
return;
}
// Ruler guides need the rulers (hidden while the view is rotated).
if !space && xf.rot == 0 && guide_drag(app, ui, resp, rect, &xf) {
return;
@@ -2238,6 +2376,38 @@ mod tests {
(s.horizontal_units, s.vertical_units)
}
/// A viewport wider than the GPU's largest texture: the canvas texture is sampled down, and an
/// edit repaints it whole. A region repainted in view pixels would land in the wrong place,
/// or outside the texture (a wgpu validation error). A view that fits is still patched.
#[test]
fn an_edit_repaints_a_canvas_texture_sampled_down_to_the_gpu_limit_whole() {
for (width, sampled_down) in [(1440.0, false), (2600.0, true)] {
let mut app = 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, 400, 300], "content": "text", "text": "Text"})).unwrap();
let mut h = Harness::builder().with_size(vec2(width, 900.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(6);
let limit = h.ctx.input(|i| i.max_texture_side);
let size = h.state().canvas.texture.as_ref().map(|t| t.size()).unwrap();
assert!(size[0] <= limit && size[1] <= limit, "{size:?}");
let shown = h.state().canvas.shown.unwrap().size;
assert_eq!(shown.0 > limit as f32, sampled_down, "view {shown:?}, limit {limit}");
let patches = h.state().canvas.patches;
// A small edit: one more character in the frame.
h.state_mut().run("text.insert", json!({"story": frame["story"], "at": 4, "text": "s"})).unwrap();
h.run_steps(6);
assert_eq!(h.state().canvas.patches == patches, sampled_down, "width {width}");
assert_eq!(h.state().canvas.texture.as_ref().map(|t| t.size()), Some(size));
}
}
#[test]
fn selected_merged_owners_paint_the_same_row_and_column_targets() {
for column in [true, false] {
@@ -2497,3 +2667,142 @@ mod tests {
assert_eq!(baseline_grid_lines(0.0, 12.0, f64::INFINITY).count(), MAX_BASELINES);
}
}
#[cfg(test)]
mod scrollbar_tests {
use egui::{Event, Modifiers, PointerButton};
use egui_kittest::Harness;
use super::*;
use crate::test_window::{Window, open, wheel};
fn window() -> Harness<'static, Window> {
let mut h = open(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()), vec2(1440.0, 900.0));
// Zoomed in so both axes scroll.
set_zoom(&mut h.state_mut().app, 2.0);
h.run_steps(3);
h
}
fn origin(h: &Harness<'static, Window>) -> Point {
h.state().app.view().map(|v| v.origin).unwrap_or_default()
}
/// The scrollbar's track and thumb as the canvas lays them out for the current view, and
/// the content pixels per thumb pixel.
fn bar(h: &Harness<'static, Window>, vertical: bool) -> (Rect, Rect, f32) {
let app = &h.state().app;
let rect = app.canvas_rect.unwrap();
let st = app.session.active().unwrap();
let xf = Xf::new(rect, app.view().unwrap());
let cb = xf.rect(CanvasLayout::new(&st.doc, st.editing_parents).pasteboard);
let outer = Rect::from_min_max(rect.min, rect.max + vec2(SCROLLBAR, SCROLLBAR));
scrollbar_rects(outer, rect, cb, vertical)
}
/// A primary-button drag from `a` to `b` in a few moves, with `mods` held throughout.
fn drag(h: &mut Harness<'static, Window>, a: Pos2, b: Pos2, mods: Modifiers) {
h.event(Event::ModifiersChanged(mods));
h.hover_at(a);
h.step();
h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: mods });
h.step();
for n in 1..=4 {
h.hover_at(a + (b - a) * n as f32 / 4.0);
h.step();
}
h.event(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: mods });
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
#[test]
fn document_views_have_scrollbars_beside_the_view() {
let h = window();
let rect = h.state().app.canvas_rect.unwrap();
for vertical in [true, false] {
let r = h.ctx.read_response(egui::Id::new(("canvas_scrollbar", 0u8, vertical))).expect("scrollbar").rect;
let (track, thumb, k) = bar(&h, vertical);
assert_eq!(r, track);
assert!(k > 0.0, "the zoomed-in view scrolls");
if vertical {
assert_eq!((r.min.x, r.width(), r.min.y, r.max.y), (rect.max.x, SCROLLBAR, rect.min.y, rect.max.y));
assert!(thumb.height() < track.height());
} else {
assert_eq!((r.min.y, r.height(), r.min.x, r.max.x), (rect.max.y, SCROLLBAR, rect.min.x, rect.max.x));
assert!(thumb.width() < track.width());
}
}
}
#[test]
fn dragging_a_thumb_pans_and_clicking_the_track_pages() {
let mut h = window();
let zoom = h.state().app.view().unwrap().zoom;
// Vertical thumb: down 40 px moves the view down by 40 thumb pixels' worth of content.
let (_, thumb, k) = bar(&h, true);
let before = origin(&h);
drag(&mut h, thumb.center(), thumb.center() + vec2(0.0, 40.0), Modifiers::NONE);
let after = origin(&h);
let want = 40.0 * k as f64 / zoom;
assert!((after.y - before.y - want).abs() < want * 0.2, "{before:?} → {after:?}, want +{want}");
assert_eq!(after.x, before.x);
// Horizontal thumb: left 30 px.
let (_, thumb, k) = bar(&h, false);
drag(&mut h, thumb.center(), thumb.center() - vec2(30.0, 0.0), Modifiers::NONE);
let next = origin(&h);
let want = 30.0 * k as f64 / zoom;
assert!((after.x - next.x - want).abs() < want * 0.2, "{after:?} → {next:?}, want -{want}");
assert_eq!(next.y, after.y);
// A click on the vertical track above the thumb pages up by one view.
let (track, thumb, _) = bar(&h, true);
assert!(thumb.min.y - track.min.y > 4.0);
crate::test_window::click_at(&mut h, pos2(track.center().x, track.min.y + 2.0));
let paged = origin(&h);
let view = h.state().app.canvas_rect.unwrap().height() as f64 / zoom;
assert!((next.y - paged.y - view).abs() < 0.01, "{next:?} → {paged:?}, want -{view}");
}
#[test]
fn scrolling_the_view_moves_the_thumbs() {
let mut h = window();
let (_, v0, _) = bar(&h, true);
let (_, h0, _) = bar(&h, false);
let c = h.state().app.canvas_rect.unwrap().center();
wheel(&mut h, c, vec2(-50.0, -80.0));
let (_, v1, _) = bar(&h, true);
let (_, h1, _) = bar(&h, false);
assert!(v1.min.y > v0.min.y && h1.min.x > h0.min.x, "{v0:?} → {v1:?}, {h0:?} → {h1:?}");
let r = h.ctx.read_response(egui::Id::new(("canvas_scrollbar", 0u8, true))).expect("scrollbar");
assert_eq!(r.rect, bar(&h, true).0);
}
#[test]
fn alt_drag_while_editing_text_pans_and_keeps_the_caret() {
let mut h = window();
let app = &mut h.state_mut().app;
let rect = app.canvas_rect.unwrap();
let xf = Xf::new(rect, app.view().unwrap());
// A text frame filling the visible part of the page, with the caret in it.
let (a, b) = (xf.to_canvas(rect.min + vec2(20.0, 20.0)), xf.to_canvas(rect.max - vec2(20.0, 20.0)));
let r = app.run("frame.create", json!({"rect": [a.x, a.y, b.x, b.y], "content": "text", "text": "Hello brave world"})).unwrap();
let sid = r["story"].as_u64().unwrap();
app.run("tool.select", json!({"tool": "type"})).unwrap();
app.run("text.select", json!({"story": sid, "anchor": 3, "focus": 3})).unwrap();
assert!(app.session.wants_text());
h.run_steps(2);
let caret = h.state().app.session.doc().unwrap().selection.text;
let before = origin(&h);
let zoom = h.state().app.view().unwrap().zoom;
let from = rect.center();
drag(&mut h, from, from + vec2(60.0, 40.0), Modifiers::ALT);
let after = origin(&h);
assert!(after.x < before.x && after.y < before.y, "{before:?} → {after:?}");
assert!((before.x - after.x - 60.0 / zoom).abs() < 15.0 / zoom, "{before:?} → {after:?}");
assert_eq!(h.state().app.session.doc().unwrap().selection.text, caret);
assert!(h.state_mut().app.session.wants_text(), "still editing text");
// Without Alt the same drag selects text and leaves the view alone.
drag(&mut h, from, from + vec2(60.0, 40.0), Modifiers::NONE);
assert_eq!(origin(&h), after);
}
}
+30 -3
View File
@@ -10,6 +10,13 @@ use crate::theme::{Tokens, semibold};
use crate::widgets::{caption, measure, number};
use crate::{DesignApp, icons};
/// Width of the app bar's right-hand slot: what is left of the row, minus a pixel of slack so
/// that rounding at fractional scales (115%) never makes the row a hair wider than the scroll
/// area and shows a needless scroll bar. Never narrower than the controls themselves.
fn tools_slot_width(available: f32, tools_w: f32) -> f32 {
(available - 1.0).max(tools_w)
}
pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let left = if app.integrated_titlebar { 78 } else { 8 };
@@ -40,7 +47,7 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
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 size = vec2(tools_slot_width(ui.available_width(), 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.
@@ -825,12 +832,18 @@ pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) {
fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Frame::NONE.fill(t.panel).corner_radius(10.0).inner_margin(egui::Margin::same(14)).show(ui, |ui| {
ui.set_max_width(640.0);
// Wide enough for the longer translations of the line under the title, with the button
// at the right edge; the text wraps before the button's space rather than run under it.
const BUTTON: egui::Vec2 = vec2(190.0, 38.0);
ui.set_max_width(760.0);
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::hover());
crate::about::paint_mark(ui, r, crate::about::BRAND);
ui.add_space(8.0);
let text_width = (ui.available_width() - BUTTON.x - 16.0).max(120.0);
ui.vertical(|ui| {
ui.set_max_width(text_width);
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
ui.label(crate::rtl::widget(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Join the ArtCraft community")).font(semibold(14.0)).color(t.text_strong),
@@ -857,7 +870,7 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
});
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::about::discord_button(ui, crate::i18n::tr(&app.ui.language, "Join our Discord"), vec2(190.0, 38.0)) {
if crate::about::discord_button(ui, crate::i18n::tr(&app.ui.language, "Join our Discord"), BUTTON) {
let _ = app.run("help.discord", json!({}));
}
});
@@ -973,6 +986,20 @@ mod tests {
assert_eq!(after, before);
}
/// At 115% the app bar fits a wide window exactly: it must not scroll sideways by a rounding error.
#[test]
fn the_app_bar_does_not_scroll_at_115_percent() {
let w = 1400.0;
let mut h = test_window::open_scaled(app(), vec2(w, 900.0), 1.15);
assert_eq!(h.ctx.pixels_per_point(), 1.15);
let before = [rect(&h, "Help"), rect(&h, "Share")];
assert!(before.iter().all(|r| r.max.x <= w), "{before:?}");
wheel(&mut h, before[0].center(), vec2(0.0, -3000.0));
wheel(&mut h, before[0].center(), vec2(-3000.0, 0.0));
let after = [rect(&h, "Help"), rect(&h, "Share")];
assert_eq!(after, before, "the app bar scrolled sideways although everything fits");
}
#[test]
fn a_scrolled_bar_keeps_its_captions_on_one_line() {
let mut app = app();
+190
View File
@@ -1501,7 +1501,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
"documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"),
"findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"),
"paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"),
"deleteParagraphStyle" => crate::i18n::tr(&app.ui.language, "Delete Paragraph Style"),
"characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"),
"deleteCharacterStyle" => crate::i18n::tr(&app.ui.language, "Delete Character Style"),
"footnoteOptions" => crate::i18n::tr(&app.ui.language, "Footnote Options"),
"paragraphRules" => crate::i18n::tr(&app.ui.language, "Paragraph Rules"),
"insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"),
@@ -1818,7 +1820,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
}
}
"paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d),
"deleteParagraphStyle" => delete_paragraph_style(app, ui, &mut d),
"characterStyleOptions" => character_style_options(app, ui, &mut d),
"deleteCharacterStyle" => delete_character_style(app, ui, &mut d),
"footnoteOptions" => footnote_options(app, ui, &mut d),
"paragraphRules" => {
let current = d.fields.get("current").cloned().unwrap_or_default();
@@ -2397,6 +2401,14 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
Ok(r)
}
"deleteParagraphStyle" => {
let name = d.s("name");
let replace = d.s("replaceWith");
app.run(
"style.paragraph.delete",
json!({"name": name, "replaceWith": if replace.is_empty() { json!(designcraft_doc::BASIC_PARAGRAPH) } else { json!(replace) }}),
)
}
"paragraphRules" => {
let attrs = rule_edits(&d, "");
if attrs.is_empty() {
@@ -2441,6 +2453,14 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
r
}
"deleteCharacterStyle" => {
let name = d.s("name");
let replace = d.s("replaceWith");
app.run(
"style.character.delete",
json!({"name": name, "replaceWith": if replace.is_empty() { json!(designcraft_doc::NO_CHAR_STYLE) } else { json!(replace) }}),
)
}
"colorPicker" => {
let hex = d.s("hex");
app.run("object.color", json!({"color": hex, "target": d.s("target"), "text": d.b("text")}))
@@ -3272,6 +3292,51 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
});
}
fn delete_paragraph_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let name = d.s("name");
ui.set_min_width(380.0);
crate::rtl::label(
ui,
egui::RichText::new(
crate::i18n::tr(&app.ui.language, "Delete the paragraph style \"{name}\"?")
.replace("{name}", crate::i18n::style_name(&app.ui.language, &name)),
)
.size(12.5),
);
ui.add_space(8.0);
let Some(st) = app.session.active() else { return };
let in_use = st.doc.stories.values().any(|s| s.paras.iter().any(|f| f.style == name));
let names: Vec<String> = st.doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| *n != name).collect();
if in_use {
crate::rtl::label(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "This style is in use. Text using it will be reassigned to:"))
.size(11.0)
.color(crate::theme::Tokens::get(ui.ctx()).text_dim),
);
} else {
crate::rtl::label(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace with:")).size(11.0).color(crate::theme::Tokens::get(ui.ctx()).text_dim),
);
}
ui.add_space(4.0);
if !d.fields.contains_key("replaceWith") {
d.fields.insert("replaceWith".into(), json!(designcraft_doc::BASIC_PARAGRAPH));
}
let replace = d.s("replaceWith");
egui::ComboBox::from_id_salt("del_para_replace")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace)))
.width(260.0)
.show_ui(ui, |ui| {
for n in &names {
if ui.selectable_label(*n == replace, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() {
d.fields.insert("replaceWith".into(), json!(n));
}
}
});
}
/// The section list on the left of the style options dialogs.
fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, sections: &[(&str, &str)]) {
ui.vertical(|ui| {
@@ -3758,6 +3823,51 @@ fn line_options(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields,
});
}
fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let name = d.s("name");
ui.set_min_width(380.0);
crate::rtl::label(
ui,
egui::RichText::new(
crate::i18n::tr(&app.ui.language, "Delete the character style \"{name}\"?")
.replace("{name}", crate::i18n::style_name(&app.ui.language, &name)),
)
.size(12.5),
);
ui.add_space(8.0);
let Some(st) = app.session.active() else { return };
let in_use = st.doc.stories.values().any(|s| s.chars.iter().any(|r| r.format.style == name));
let names: Vec<String> = st.doc.styles.character.iter().map(|c| c.name.clone()).filter(|n| *n != name).collect();
if in_use {
crate::rtl::label(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "This style is in use. Text using it will be reassigned to:"))
.size(11.0)
.color(crate::theme::Tokens::get(ui.ctx()).text_dim),
);
} else {
crate::rtl::label(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace with:")).size(11.0).color(crate::theme::Tokens::get(ui.ctx()).text_dim),
);
}
ui.add_space(4.0);
if !d.fields.contains_key("replaceWith") {
d.fields.insert("replaceWith".into(), json!(designcraft_doc::NO_CHAR_STYLE));
}
let replace = d.s("replaceWith");
egui::ComboBox::from_id_salt("del_char_replace")
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace)))
.width(260.0)
.show_ui(ui, |ui| {
for n in [designcraft_doc::NO_CHAR_STYLE].into_iter().chain(names.iter().map(String::as_str)) {
if ui.selectable_label(n == replace, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() {
d.fields.insert("replaceWith".into(), json!(n));
}
}
});
}
fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) {
let cur = d.s(key);
let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string();
@@ -6130,6 +6240,86 @@ mod tests {
}
}
fn test_app() -> crate::DesignApp {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 1})).unwrap();
app
}
#[test]
fn paragraph_style_options_dialog_saves_edits() {
let mut app = test_app();
let name = app.session.execute("style.paragraph.create", &json!({"name": "Body Copy"})).unwrap()["name"].as_str().unwrap().to_string();
app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": name, "p.align": "center"})));
confirm(&mut app).expect("OK saves the style edit");
let st = app.session.active().unwrap();
assert_eq!(st.doc.styles.para(&name).unwrap().para.align, Some(designcraft_doc::Align::Center));
}
#[test]
fn delete_paragraph_style_dialog_removes_the_style_and_reassigns_text() {
let mut app = test_app();
let name = app.session.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string();
let sid = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap();
app.session.execute("text.insert", &json!({"text": "Hello"})).unwrap();
app.session.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
app.session.execute("style.paragraph.apply", &json!({"name": name})).unwrap();
app.ui.dialog = Some(Dialog::new("deleteParagraphStyle", json!({"name": name, "replaceWith": designcraft_doc::BASIC_PARAGRAPH})));
confirm(&mut app).expect("OK deletes the style");
let st = app.session.active().unwrap();
assert!(st.doc.styles.para(&name).is_none(), "style removed");
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).unwrap();
assert_eq!(story.paras[0].style, designcraft_doc::BASIC_PARAGRAPH, "text reassigned to the replacement style");
}
#[test]
fn delete_paragraph_style_dialog_defaults_replacement_to_basic_paragraph() {
let mut app = test_app();
let name = app.session.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string();
// No `replaceWith` set — the dialog's default field.
app.ui.dialog = Some(Dialog::new("deleteParagraphStyle", json!({"name": name})));
confirm(&mut app).expect("OK deletes the style with the default replacement");
let st = app.session.active().unwrap();
assert!(st.doc.styles.para(&name).is_none());
}
#[test]
fn character_style_options_dialog_saves_edits() {
let mut app = test_app();
let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
app.ui.dialog = Some(Dialog::new("characterStyleOptions", json!({"name": name, "c.tracking": 20.0})));
confirm(&mut app).expect("OK saves the style edit");
let st = app.session.active().unwrap();
assert_eq!(st.doc.styles.char_style(&name).unwrap().chars.tracking, Some(20.0));
}
#[test]
fn delete_character_style_dialog_removes_the_style_and_reassigns_text() {
let mut app = test_app();
let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
let sid = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap();
app.session.execute("text.insert", &json!({"text": "Hello"})).unwrap();
app.session.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
app.session.execute("style.character.apply", &json!({"name": name})).unwrap();
app.ui.dialog = Some(Dialog::new("deleteCharacterStyle", json!({"name": name, "replaceWith": designcraft_doc::NO_CHAR_STYLE})));
confirm(&mut app).expect("OK deletes the style");
let st = app.session.active().unwrap();
assert!(st.doc.styles.char_style(&name).is_none(), "style removed");
let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).unwrap();
assert_eq!(story.chars[0].format.style, designcraft_doc::NO_CHAR_STYLE, "text reassigned to the replacement style");
}
#[test]
fn delete_character_style_dialog_defaults_replacement_to_none() {
let mut app = test_app();
let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string();
// No `replaceWith` set — the dialog's default field.
app.ui.dialog = Some(Dialog::new("deleteCharacterStyle", json!({"name": name})));
confirm(&mut app).expect("OK deletes the style with the default replacement");
let st = app.session.active().unwrap();
assert!(st.doc.styles.char_style(&name).is_none());
}
#[test]
fn pdf_export_defaults() {
let d = Dialog::new("pdfExport", json!({}));
+14 -4
View File
@@ -5,6 +5,7 @@ use std::{collections::HashMap, sync::OnceLock};
mod ar;
mod cs;
mod de;
mod it;
mod ja;
mod ka;
@@ -3133,6 +3134,7 @@ fn column(lang: &str) -> Option<usize> {
/// `s` in `lang` (English, or the string itself, when there's no translation).
pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
static GERMAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
static JAPANESE: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
static TRANSLATIONS: OnceLock<HashMap<&'static str, [&'static str; 5]>> = OnceLock::new();
static ARABIC: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
@@ -3144,8 +3146,13 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
if lang == "ar" {
return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
}
// The Japanese table covers the whole interface; the shared table's Japanese column is the fallback
// for keys added there after it.
// The German and Japanese tables cover the whole interface; the shared table's German and Japanese
// columns are the fallback for keys added there after them.
if lang == "de"
&& let Some(text) = GERMAN.get_or_init(|| de::TABLE.iter().copied().collect()).get(s).copied()
{
return text;
}
if lang == "ja"
&& let Some(text) = JAPANESE.get_or_init(|| ja::TABLE.iter().copied().collect()).get(s).copied()
{
@@ -3237,13 +3244,16 @@ mod tests {
fn cached_lookup_preserves_every_translation_across_language_switches() {
for (key, translations) in TABLE {
for (lang, expected) in ["de", "fr", "es", "ja", "zh"].into_iter().zip(translations) {
// `ja::TABLE` translates the whole interface and wins over the shared Japanese column.
if lang != "ja" {
// `de::TABLE` and `ja::TABLE` translate the whole interface and win over the shared columns.
if lang != "de" && lang != "ja" {
assert_eq!(tr(lang, key), *expected, "{lang}: {key}");
}
}
assert_eq!(tr("", key), *key);
}
for (key, expected) in de::TABLE {
assert_eq!(tr("de", key), *expected, "de: {key}");
}
for (key, expected) in ar::TABLE {
assert_eq!(tr("ar", key), *expected, "ar: {key}");
}
+1
View File
@@ -509,6 +509,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Export HTML…", "Exportovat HTML…"),
("Export IDML…", "Exportovat IDML…"),
("Export PDF (Interactive)…", "Exportovat PDF (interaktivní)…"),
("Export PowerPoint…", "Exportovat PowerPoint…"),
("Export Page as PNG…", "Exportovat stránku jako PNG…"),
("Export Selection as Snippet", "Exportovat výběr jako úryvek"),
("Export Tagging", "Tagování pro export"),
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1830,4 +1830,5 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert to Flex Layout", "მოქნილ განლაგებად გარდაქმნა"),
("Remove Flex Layout", "მოქნილი განლაგების წაშლა"),
("Show Flex Controls", "მოქნილი მართვის ელემენტების ჩვენება"),
("Export PowerPoint…", "PowerPoint-ად ექსპორტი…"),
];
+7 -2
View File
@@ -2314,11 +2314,16 @@ pub(crate) mod test_window {
}
/// The window at `size` with a new document open.
pub fn open(mut app: crate::DesignApp, size: egui::Vec2) -> Harness<'static, Window> {
pub fn open(app: crate::DesignApp, size: egui::Vec2) -> Harness<'static, Window> {
open_scaled(app, size, 1.0)
}
/// The window at a size in points and a scale, with a new document open.
pub fn open_scaled(mut app: crate::DesignApp, size: egui::Vec2, pixels_per_point: f32) -> 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(
let mut h = Harness::builder().with_size(size).with_pixels_per_point(pixels_per_point).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 {
+38 -4
View File
@@ -27,6 +27,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
),
("app.exportFixedEpub", "Export EPUB (Fixed Layout)…", None, "{path?} — pre-paginated EPUB 3"),
("app.exportHtml", "Export HTML…", None, "{path?} — one self-contained web page"),
("app.exportPptx", "Export PowerPoint…", None, "{path?} — every page as a slide of editable shapes, pictures and text"),
("app.exportXml", "Export XML…", None, "{path?} — the tagged content"),
("app.printBooklet", "Print Booklet…", None, "{path?} — saddle-stitched printer spreads as PDF"),
("app.qrCode", "Generate QR Code…", None, "{} — the QR Code dialog (object.qrCode does the work)"),
@@ -249,6 +250,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:app.exportFixedEpub",
"ui:app.exportInteractivePdf",
"ui:app.exportHtml",
"ui:app.exportPptx",
"ui:app.exportXml",
"ui:app.exportText",
">Export",
@@ -931,6 +933,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
}
"app.exportFixedEpub" => export_bytes(app, p, "epub", "file.exportFixedEpub"),
"app.exportHtml" => export_bytes(app, p, "html", "file.exportHtml"),
"app.exportPptx" => export_bytes(app, p, "pptx", "file.exportPptx"),
"app.exportXml" => export_bytes(app, p, "xml", "file.exportXml"),
"app.printBooklet" => export_bytes(app, p, "pdf", "file.printBooklet"),
"app.menus" => {
@@ -2014,7 +2017,7 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
return enabled(app, "object.fit");
}
if ui_label(id).is_some() {
return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.");
return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help.");
}
enabled(app, id)
}
@@ -2189,9 +2192,15 @@ fn clipped_submenu<'a, R>(
/// ⌘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);
if params.is_null() && ctx.text_edit_focused() && forward_to_text_edit(app, ctx, id) {
return;
}
// The canvas text editor is not an egui TextEdit, so its clipboard events must pass through
// egui for the desktop integration to read/write the macOS clipboard.
if params.is_null() && app.session.wants_text() && matches!(id, "edit.copy" | "edit.cut" | "edit.paste") && forward_to_text_edit(app, ctx, id) {
return;
}
activate(app, id, params);
}
/// Whether a native menu bar item can be chosen: as in the in-window menus, but the editing
@@ -2205,7 +2214,7 @@ const TEXT_FIELD_COMMANDS: [&str; 6] = ["edit.selectAll", "edit.copy", "edit.cut
/// 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 {
fn forward_to_text_edit(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 })
};
@@ -3722,6 +3731,31 @@ mod tests {
assert_eq!(document_items(&app), (2, 0), "nothing was pasted into the document");
}
#[test]
fn native_copy_and_paste_use_the_system_clipboard_for_canvas_text() {
let ctx = egui::Context::default();
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], "content": "text", "text": "hello"})).unwrap();
let story = *app.session.active().unwrap().doc.stories.keys().next().unwrap();
app.select_tool("type");
app.run("text.select", json!({"story": story.0, "anchor": 0, "focus": 5})).unwrap();
for _ in 0..3 {
native_frame(&mut app, &ctx, vec![], None);
}
assert!(app.session.wants_text());
native_frame(&mut app, &ctx, vec![], Some("edit.copy"));
let copied = native_frame(&mut app, &ctx, vec![], None);
assert!(copied.platform_output.commands.contains(&egui::OutputCommand::CopyText("hello".into())));
let paste = native_frame(&mut app, &ctx, vec![], Some("edit.paste"));
let commands = &paste.viewport_output[&egui::ViewportId::ROOT].commands;
assert!(commands.contains(&egui::ViewportCommand::RequestPaste), "{commands:?}");
native_frame(&mut app, &ctx, vec![egui::Event::Paste("world".into())], None);
assert_eq!(app.session.active().unwrap().doc.story(story).unwrap().text, "world");
}
#[test]
fn native_undo_and_redo_act_on_the_focused_field() {
let ctx = egui::Context::default();
+54 -3
View File
@@ -71,9 +71,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
}
});
}
let cols = ((ui.available_width() / CELL).floor() as u32).max(1);
// The scroll bar floats over the grid's right edge: leave it room.
let scroll = &ui.spacing().scroll;
let bar = scroll.bar_inner_margin + scroll.bar_width + scroll.bar_outer_margin;
let cols = (((ui.available_width() - bar) / CELL).floor() as u32).max(1);
let ppp = ui.ctx().pixels_per_point();
let cell_px = (CELL * ppp).round() as u32;
let cell_px = (CELL * ppp).round().max(1.0) as u32;
// The grid is one texture: no more rows than the GPU's largest texture holds (a narrow
// panel of a large CJK font ran past it).
let max_rows = (ui.ctx().input(|i| i.max_texture_side) as u32 / cell_px).max(1);
chars.truncate((max_rows * cols) as usize);
let ink = t.text_strong;
let key = egui::Id::new(("glyph_grid", &st.family, &st.style, scope, &q, cols, cell_px, ink.to_array()));
let tex: egui::TextureHandle = match ui.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
@@ -81,7 +88,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
None => {
let img = designcraft_render::glyphs::glyph_grid(&db, &st.family, &st.style, &chars, cols, cell_px, ink.to_array());
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let h = ui.ctx().load_texture("glyph_grid", ci, egui::TextureOptions::LINEAR);
let h = crate::widgets::load_texture(ui.ctx(), "glyph_grid", ci, egui::TextureOptions::LINEAR);
ui.data_mut(|d| d.insert_temp(key, h.clone()));
h
}
@@ -138,3 +145,47 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
);
ui.data_mut(|d| d.insert_temp(id, st));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_grid_leaves_room_for_the_scroll_bar() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, "");
crate::theme::apply(&ctx, &Tokens::for_brightness(crate::theme::Brightness::MediumDark));
let scroll = ctx.global_style().spacing.scroll;
let bar = scroll.bar_inner_margin + scroll.bar_width + scroll.bar_outer_margin;
for width in [236.0, 256.0, 300.0, 333.0] {
let mut panel = egui::Rect::NOTHING;
let mut grid = None;
for _ in 0..2 {
// The app raises the texture limit for the grid (see `test_window`).
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 700.0))),
max_texture_side: Some(8192),
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
ui.allocate_ui(vec2(width, 650.0), |ui| {
ui.set_width(width);
panel = ui.max_rect();
show(&mut app, ui);
});
});
out.textures_delta.clear();
// The grid is the one textured mesh that isn't text.
grid = out.shapes.iter().find_map(|s| match &s.shape {
egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => Some(m.calc_bounds()),
_ => None,
});
}
let grid = grid.expect("the glyph grid is drawn");
assert!(grid.right() <= panel.right() - bar + 0.5, "{width}: the grid {grid:?} runs under the scroll bar of {panel:?}");
assert!(grid.right() > panel.right() - bar - CELL, "{width}: no column is left out ({grid:?})");
}
}
}
+136 -21
View File
@@ -217,29 +217,67 @@ const SWATCH_MENU_ROW_H: f32 = 22.0;
/// One swatch-menu row: a chip and the swatch name; the whole row is clickable (the chip included).
/// `name == current` highlights it. Returns true when the row was clicked.
pub fn swatch_menu_row(ui: &mut egui::Ui, doc: &designcraft_doc::Document, name: &str, current: &str) -> bool {
/// Where a swatch row's name starts, after its chip.
const SWATCH_MENU_TEXT_X: f32 = 28.0;
fn swatch_menu_font() -> egui::FontId {
egui::FontId::proportional(12.5)
}
/// One swatch in a swatch list, `width` wide: the swatch under the pointer is highlighted (white
/// on blue, like a menu), the current one shaded.
pub fn swatch_menu_row(ui: &mut egui::Ui, doc: &designcraft_doc::Document, name: &str, current: &str, width: f32) -> bool {
let t = Tokens::get(ui.ctx());
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), SWATCH_MENU_ROW_H), egui::Sense::click());
let (row, resp) = ui.allocate_exact_size(vec2(width, SWATCH_MENU_ROW_H), egui::Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, name == current, name));
if name == current {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
let hovered = resp.hovered();
if hovered {
ui.painter().rect_filled(row, 2.0, t.accent_strong);
} else if name == current {
ui.painter().rect_filled(row, 2.0, t.row_selected);
}
let (c, g) = crate::widgets::swatch_colors(doc, name, 1.0);
let chip = egui::Rect::from_min_size(row.min + vec2(4.0, 3.0), vec2(16.0, 16.0));
crate::widgets::paint_chip(ui.painter(), chip, c, g);
// A name too long for the list is cut at the row's end.
let text = ui.painter().with_clip_rect(ui.clip_rect().intersect(row.shrink2(vec2(2.0, 0.0))));
crate::rtl::paint(
ui.painter(),
row.min + vec2(28.0, SWATCH_MENU_ROW_H / 2.0),
&text,
row.min + vec2(SWATCH_MENU_TEXT_X, SWATCH_MENU_ROW_H / 2.0),
egui::Align2::LEFT_CENTER,
name,
egui::FontId::proportional(12.5),
t.text,
swatch_menu_font(),
if hovered { egui::Color32::WHITE } else { t.text },
);
resp.clicked()
}
/// The list in a swatch popup → the swatch picked. It is as wide as its longest name (within
/// limits), and its rows stop short of the scroll bar that floats over the list's edge.
pub fn swatch_list(ui: &mut egui::Ui, doc: &designcraft_doc::Document, current: &str) -> Option<String> {
let font = swatch_menu_font();
let widest = doc
.swatches
.iter()
.map(|sw| ui.painter().layout_no_wrap(sw.name.clone(), font.clone(), egui::Color32::PLACEHOLDER).size().x)
.fold(0.0, f32::max);
let scroll = &ui.spacing().scroll;
let bar = scroll.bar_inner_margin + scroll.bar_width + scroll.bar_outer_margin;
let width = (SWATCH_MENU_TEXT_X + widest + 10.0 + bar).clamp(200.0, 360.0);
ui.set_min_width(width);
ui.set_max_width(width);
let mut picked = None;
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for sw in &doc.swatches {
if swatch_menu_row(ui, doc, &sw.name, current, width - bar) {
picked = Some(sw.name.clone());
ui.close();
}
}
});
picked
}
/// A swatch dropdown showing a chip and name; `on_pick` gets the chosen swatch name.
pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current: Option<String>, on_pick: impl FnOnce(&mut DesignApp, String)) {
let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };
@@ -261,16 +299,7 @@ pub fn swatch_picker(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, current:
Tokens::get(ui.ctx()).icon,
);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(200.0);
ui.set_max_width(280.0);
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for sw in &doc.swatches {
if swatch_menu_row(ui, &doc, &sw.name, &cur) {
picked = Some(sw.name.clone());
ui.close();
}
}
});
picked = swatch_list(ui, &doc, &cur);
});
});
if let Some(p) = picked {
@@ -418,7 +447,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf
let tex = tex.unwrap_or_else(|| {
let img = designcraft_render::glyphs::text_line(&fonts, f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array());
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let h = ui.ctx().load_texture(format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR);
let h = crate::widgets::load_texture(ui.ctx(), format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR);
ui.data_mut(|d| d.insert_temp(key, h.clone()));
h
});
@@ -757,3 +786,89 @@ mod font_menu_ui_tests {
}
}
}
#[cfg(test)]
mod swatch_list_tests {
use super::*;
fn rects(shapes: &[egui::epaint::ClippedShape]) -> Vec<(egui::Rect, egui::Color32)> {
fn walk(s: &egui::Shape, out: &mut Vec<(egui::Rect, egui::Color32)>) {
match s {
egui::Shape::Rect(r) => out.push((r.rect, r.fill)),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut out = vec![];
shapes.iter().for_each(|s| walk(&s.shape, &mut out));
out
}
fn texts(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, egui::Rect)> {
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.job.text.clone(), t.visual_bounding_rect())),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut out = vec![];
shapes.iter().for_each(|s| walk(&s.shape, &mut out));
out
}
#[test]
fn the_swatch_under_the_pointer_is_highlighted_and_names_clear_the_scroll_bar() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", serde_json::json!({})).unwrap();
// A long name, as process swatches have.
app.run("swatch.create", serde_json::json!({"name": "C=15 M=100 Y=100 K=0 Long Name", "color": {"c": 15, "m": 100, "y": 100, "k": 0}}))
.unwrap();
let long = "C=15 M=100 Y=100 K=0 Long Name";
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, "");
let tokens = Tokens::for_brightness(crate::theme::Brightness::MediumDark);
crate::theme::apply(&ctx, &tokens);
let mut time = 0.0;
let mut frame = |app: &mut DesignApp, events: Vec<egui::Event>| {
time += 0.1;
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 700.0))),
time: Some(time),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| swatch_picker(app, ui, "list", Some("[Black]".into()), |_, _| {}));
out.textures_delta.clear();
out.shapes
};
frame(&mut app, vec![]);
let chip = egui::pos2(17.0, 17.0);
for pressed in [true, false] {
frame(
&mut app,
vec![
egui::Event::PointerMoved(chip),
egui::Event::PointerButton { pos: chip, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() },
],
);
}
let shapes = frame(&mut app, vec![]);
let (_, name) = texts(&shapes).into_iter().find(|(t, _)| t == "[Registration]").expect("the list is open");
let pointer = name.center();
frame(&mut app, vec![egui::Event::PointerMoved(pointer)]);
let shapes = frame(&mut app, vec![]);
let all = rects(&shapes);
let (row, _) = *all.iter().find(|(r, c)| *c == tokens.accent_strong && r.contains(pointer)).expect("the hovered row is highlighted");
let (list, _) = *all
.iter()
.filter(|(r, c)| *c == tokens.panel && r.contains_rect(row))
.min_by(|a, b| a.0.area().total_cmp(&b.0.area()))
.expect("the popup");
let scroll = ctx.global_style().spacing.scroll;
assert!(row.right() + scroll.bar_width <= list.right(), "rows {row:?} stop short of the scroll bar in {list:?}");
let (_, long_rect) = texts(&shapes).into_iter().find(|(t, _)| t == long).unwrap();
assert!(long_rect.right() <= row.right(), "the longest name {long_rect:?} fits its row {row:?}");
assert!(texts(&shapes).iter().any(|(t, r)| t == "[Black]" && r.right() <= row.right()));
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option
&designcraft_render::RenderOptions { printing_only: true, ..Default::default() },
)?;
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let tex = ctx.load_texture(format!("thumb{abs}"), ci, egui::TextureOptions::LINEAR);
let tex = crate::widgets::load_texture(ctx, format!("thumb{abs}"), ci, egui::TextureOptions::LINEAR);
// Drop thumbnails of older revisions.
let (uid, rev) = (st.uid, st.revision);
map.retain(|k, _| !(k.0 == uid && k.1 != rev));
+1 -10
View File
@@ -760,16 +760,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) {
fn swatch_menu(app: &mut DesignApp, resp: &egui::Response, doc: &designcraft_doc::Document, cur: &str, on_pick: impl FnOnce(&mut DesignApp, String)) {
let mut picked = None;
egui::Popup::menu(resp).show(|ui| {
ui.set_min_width(200.0);
ui.set_max_width(280.0);
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for sw in &doc.swatches {
if super::swatch_menu_row(ui, doc, &sw.name, cur) {
picked = Some(sw.name.clone());
ui.close();
}
}
});
picked = super::swatch_list(ui, doc, cur);
});
if let Some(p) = picked {
on_pick(app, p);
+14
View File
@@ -148,6 +148,20 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren
ui.data_mut(|d| d.insert_temp(id, name));
});
}
if para && !n.starts_with('[') {
ui.separator();
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Paragraph Style…"))).clicked() {
app.ui.dialog = Some(crate::dialogs::Dialog::new("deleteParagraphStyle", json!({"name": n})));
ui.close();
}
}
if !para && !n.starts_with('[') {
ui.separator();
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Character Style…"))).clicked() {
app.ui.dialog = Some(crate::dialogs::Dialog::new("deleteCharacterStyle", json!({"name": n})));
ui.close();
}
}
});
}
+2 -20
View File
@@ -168,16 +168,7 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Task Bar fill"));
let mut picked = None;
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(180.0);
ui.set_max_width(280.0);
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for s in &doc.swatches {
if panels::swatch_menu_row(ui, &doc, &s.name, &sw) {
picked = Some(s.name.clone());
ui.close();
}
}
});
picked = panels::swatch_list(ui, &doc, &sw);
});
if let Some(p) = picked {
let _ = app.run("type.char", json!({"attrs": {"fill": p}}));
@@ -241,16 +232,7 @@ fn object_controls(app: &mut DesignApp, ui: &mut Ui) {
});
let mut picked = None;
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(180.0);
ui.set_max_width(280.0);
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for s in &doc.swatches {
if panels::swatch_menu_row(ui, &doc, &s.name, &sw) {
picked = Some(s.name.clone());
ui.close();
}
}
});
picked = panels::swatch_list(ui, &doc, &sw);
});
if let Some(p) = picked {
let _ = app.run(cmd, json!({"swatch": p}));
+46
View File
@@ -717,3 +717,49 @@ mod tests {
assert_eq!(m.step(), 1.0);
}
}
/// `img` made to fit the GPU's largest texture (`max_side` pixels a side), sampled down
/// (nearest pixel) when it is larger. A texture past the limit panics in the renderer, so every
/// UI texture goes through this: an oversized image loses resolution, not the document.
pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage {
let [w, h] = img.size;
let max_side = max_side.max(1);
if w <= max_side && h <= max_side {
return img;
}
let k = (max_side as f64 / w.max(h) as f64).min(1.0);
let (nw, nh) = (((w as f64 * k).floor() as usize).clamp(1, max_side), ((h as f64 * k).floor() as usize).clamp(1, max_side));
let mut pixels = Vec::with_capacity(nw * nh);
for y in 0..nh {
let sy = (y * h / nh).min(h.saturating_sub(1));
for x in 0..nw {
let sx = (x * w / nw).min(w.saturating_sub(1));
pixels.push(img.pixels.get(sy * w + sx).copied().unwrap_or(Color32::TRANSPARENT));
}
}
egui::ColorImage::new([nw, nh], pixels)
}
/// [`egui::Context::load_texture`] for an image of any size (see [`fit_texture`]).
pub fn load_texture(ctx: &egui::Context, name: impl Into<String>, img: egui::ColorImage, options: egui::TextureOptions) -> egui::TextureHandle {
let max = ctx.input(|i| i.max_texture_side);
ctx.load_texture(name, fit_texture(img, max), options)
}
#[cfg(test)]
mod texture_tests {
/// The glyph grid that crashed the renderer: 7 columns of 1200 characters at 60 px, 10320 px
/// tall against an 8192 px limit.
#[test]
fn oversized_images_fit_the_texture_limit() {
let img = egui::ColorImage::new([420, 10320], vec![egui::Color32::WHITE; 420 * 10320]);
let out = super::fit_texture(img, 8192);
assert!(out.size[0] <= 8192 && out.size[1] == 8192, "{:?}", out.size);
assert_eq!(out.pixels.len(), out.size[0] * out.size[1]);
let small = egui::ColorImage::new([10, 10], vec![egui::Color32::RED; 100]);
assert_eq!(super::fit_texture(small, 8192).size, [10, 10]);
let wide = egui::ColorImage::new([20000, 3], vec![egui::Color32::RED; 60000]);
let out = super::fit_texture(wide, 8192);
assert!(out.size[0] == 8192 && out.size[1] >= 1);
}
}
+21
View File
@@ -139,6 +139,27 @@ and `text.move` return, a story's `length` in `get_story` and `inspect_document`
`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.
## Text and image fitting
Hyphenation is on by default for new text (`hyphenate: true`). To turn it off, select the text or a text frame,
then call `execute`:
```text
execute {"command": "type.para", "params": {"attrs": {"hyphenate": false}}}
```
With a text selection, this formats the selected paragraphs; with selected text frames, it formats their whole stories.
Use `execute` with `object.fit` to fit a selected graphic frame (or pass `ids`):
| `mode` | What it does |
|---|---|
| `fillProportionally` | Preserves aspect ratio, covers the frame and clips overflow. |
| `fitProportionally` | Preserves aspect ratio, shows the whole image and may leave empty space. |
| `fitContentToFrame` | Stretches the content to the frame's width and height. |
| `centerContent` | Keeps the current size and positions content at the fitting alignment (center by default). |
| `fitFrameToContent` | Resizes the frame to the content's bounds. |
## Tools
| Tool | What it does |
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, first version) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (document views row: scrollbars, Alt pans while editing text) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel,
against InDesign 2026. The look was measured from the running app
@@ -34,6 +34,7 @@ basics of editing fail for too many users on Windows and Linux.
| Content grabber, frame fitting by double-click | ✓ | ✓ | ✓ | |
| Smart guides, snapping to guides/grid/baseline | ✓ | ✓ (2,049-line `snap.rs`) | ~ | vertical smart spacing between two rectangles (#188) |
| Nudging with arrows, Shift ×10 | ✓ | ✓ | ✓ | |
| Document views: scrollbars (each split pane), Hand tool by Space, middle drag, or Alt while editing text | ✓ | ✓ | ✓ | `canvas.rs` `scrollbar_tests` |
| Gridify while drawing, Gap tool, Live Distribute | ✓ | ✓ | ✓ | |
| Type tool: caret, selection, double/triple click, drag-and-drop text | ✓ | ✓ | ~ | formatting at a caret discarded (#289); text editing complaints (#23, #174) |
| Threading: click out port, load text cursor, click frame | ✓ | ✗ ports drawn only; no thread/unthread command; Primary Text Frame and autoflow on Place | ✗ | **alpha blocker** (#352, #194, #165, #140) |
@@ -54,7 +55,7 @@ basics of editing fail for too many users on Windows and Linux.
| UI scaling, System appearance | ✓ | scaling ✓ (#281); follows macOS light/dark ✗ (#237) | ~ | |
| Window controls, full screen | ✓ | ~ | ~ | misaligned on macOS (#333) |
Counted 2026-10-10: 27 rows, 11 ✓, 14 ~, 2 ✗ → **67% presence**, scoring ✓ = 1, ~ = 0.5. Ready
Counted 2026-10-11: 28 rows, 12 ✓, 14 ~, 2 ✗ → **68% presence**, scoring ✓ = 1, ~ = 0.5. Ready
for real work is lower, ~45%, because threading is missing and three of the ~ rows (copy/paste,
context menus, typing at a caret) are things a layout artist does every minute, and their failures stop work.
+1
View File
@@ -43,6 +43,7 @@ pub const TABLE: &[(&str, Class)] = &[
("pdf", Class::Layer(3)),
("idml", Class::Layer(3)),
("epub", Class::Layer(3)),
("pptx", Class::Layer(3)),
("textimport", Class::Layer(3)),
("format", Class::Layer(3)),
("tools", Class::Layer(4)),