Commit Graph

27 Commits

Author SHA1 Message Date
xmaks82 7af79cd9f0
Fonts: treat Windows charset names as the font itself (#904)
Old Russian and Central European documents name fonts by their Windows 3.1
charset aliases: "Times New Roman Cyr", "Arial CYR", "Courier New CE",
"Arial (Hebrew)". Windows maps these to the font itself (FontSubstitutes);
we treated them as unknown families and fell back to the sans default.
resolve() now tries the base family, and substitutes for it when it isn't
installed (Times New Roman Cyr -> Liberation Serif, like Times New Roman).

Co-authored-by: Магда М.Г. <mail@mgmagda.ru>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:29:22 -04:00
Dmitrii Khlapov 0f9dc5c6ae
#776: draw Wingdings, Wingdings 2/3, Webdings and Symbol text as Unicode symbols (#876)
Word's symbol fonts are proprietary and never bundled, so text set in them
showed the substitute's letters. New wordcraft_doc::symbol_fonts maps every
code (stored as U+F020-U+F0FF, e.g. w:sym, or as a plain 8-bit code) to its
Unicode equivalent: Symbol from Unicode's Adobe symbol.txt, the Wingdings
and Webdings sets from Unicode L2/12-368 (WG2 N4384), checked against UCD.
Layout shapes the mapped characters (fallback faces draw them); PDF text,
copy as text and TXT export read as the symbols; the stored text and docx
round trips are unchanged.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:26:21 -04:00
Dmitrii Khlapov 1350cb959b
Font list previews no longer keep every family in memory (#848) (#874)
* #848: font list previews no longer load every family they show

The Font box drew each entry in its own face through the full layout
pipeline, which loaded the whole family into the font database for good
(faces are never unloaded). Every trip through the list kept tens of
megabytes. Entries are now drawn with FontDb::preview_face: a loaded
family's face is shared, an installed one is read into a face the
database doesn't keep and is freed when dropped, and render_text_line
draws it without the shared outline cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Font preview test tolerates other tests loading fallbacks; gaps rows 1–3 de-duplicated, #848 noted in row 3

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:14:56 -04:00
Dmitrii Khlapov 7ee3cea875
Fonts: documents use the craft-fonts Simplified Chinese face too (#888)
Builds with craft-fonts (releases, the FreeBSD job) put only its Japanese
faces in the document font database; Noto Sans CJK SC served the interface
alone. Simplified-only ideographs in a document then had no face unless the
system had one, and the run-level CJK pick from #567 found no face covering a
mixed run, which the FreeBSD job's
east_asian_run_uses_one_face_covering_the_whole_run caught. The Chinese craft
faces now follow the Japanese ones (Mincho stays first for Japanese text).

fallback_reads_each_installed_font_file_once_however_many_characters_miss
used U+30001.., part of which Noto Sans CJK SC maps (CJK Extension G); it now
uses Supplementary Private Use Area-A, which no font here maps.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:46:24 -04:00
jeleesky f4dfc8eb85
Fix CJK fallback mixing serif/sans faces within one run (#567)
* Fix CJK fallback mixing serif/sans faces within one run

Per-character fallback picked the first covering face in load order, so a
run without an East Asian font could draw JIS-covered ideographs in a
bundled Japanese Mincho and simplified-only ones in the SC sans, mixing
serif and sans glyphs even inside one word. Pick one face covering all
of the run's East Asian characters when available, keeping the
per-character fallback when none does.

* Review fixes for #567: cache the run-level CJK fallback

- fallback_for_east_asian is cached per (character set, excluded face,
  bold, italic) and dropped when the loaded faces change or the system
  fallback is switched, like the per-character cache, so relayout doesn't
  redo the search; the cache is bounded in entries and in key size.
- loaded_fallback and loaded_covering share one fallback_order helper.
- Test for the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:24:03 -04:00
naxvog 37318985b5
Layout: multiple line spacing adds its leading below the text, like Word (#719)
Benchmarked against docxide-pdf's 390 Word-referenced fixtures, the
largest systematic gap was the vertical position of text: WordCraft put
the baseline one descent above the bottom of the line box, so all the
extra leading of 1.15 or double spacing went above the text (1.8pt at
1.15-spaced 12pt). Word keeps the text one ascent below the line top and
puts the extra leading below it; below single spacing it shrinks the
whole box.

The hhea line gap now counts toward the ascent instead of being split
half above, half below. Line heights stay the same, but every descent
is half a line gap smaller, so text in exact, at-least and grid-snapped
lines (which keep the descent below the baseline or centre the glyph
box) sits up to half a line gap lower: 0.25pt for 12pt Times New Roman.
On the suite that brings those lines closer to Word as well (exact:
5 fixtures better, 1 worse; at-least: 5 better, none worse).

On the suite: mean Jaccard 29.2 -> 33.5%, SSIM 49.4 -> 50.3%, median
baseline offset from Word +0.68 -> -0.06pt, documents within 1pt
35 -> 51%.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:58:37 -04:00
Dmitrii Khlapov 1b96e04b2c
Add fonts at run time: Add Font…, drop a font file, font.add (#582) (#668)
* Add fonts at run time: Add Font…, drop a font file, font.add (#582)

Rebased onto main as one commit (the branch had merge commits from main).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #582: font test no longer depends on the system's fallback font

The FreeBSD runner has no system fonts, so a missing family fell back to
Source Serif 4, the font the test compared against. The test now adds a
renamed JetBrains Mono under a name without Serif/Sans/Mono (which picks a
fallback of that kind), with narrow-letter sample text: monospaced, it
measures unlike any proportional fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 00:38:20 -04:00
Brandon Thomas 19684cff26
layout: make Myanmar fallback tests deterministic (#807)
tests_complex::myanmar_syllables_are_single_clusters and
myanmar_in_a_latin_font_falls_back_to_a_regular_myanmar_face failed about
1 run in 4 of the full layout suite (cluster starts [0, 3, 6, 12, 18], "one
face for the word") but passed alone.

Root cause: a race in FontDb's system font fallback, not the tests. Tests
share FontDb::global(), and layout asks for a fallback per character from
several threads. A search marks a preferred family ("Padauk" for Myanmar) as
tried before loading it; a second search racing it skipped the family as
already tried, fell through to the coverage index, and loaded the first
cataloged file in path order that maps the character (Padauk-Bold.ttf on
its own). Characters of one syllable then fell back to different faces, so
the shaper split the syllable and the word's faces differed. Separately, a
search's answer was cached under the face count read after the search, so
an answer chosen before another thread's load could be cached as current.

Fix (crates/fonts):
- System fallback searches run one at a time (a `searching` lock). A search
  that waited re-checks the loaded faces first, so it uses the family the
  previous search loaded instead of loading another file.
- The fallback cache keys an answer by the face count it was chosen from,
  and doesn't cache an answer from before a concurrent load.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:29:58 -04:00
Dmitrii Khlapov 3fd97c3e58
Complex scripts: shape Myanmar, Khmer and Indic text; caret and line breaks respect syllables (#604) (#652)
Rebased onto main as one commit (the branch had merge commits from main).

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 23:22:57 -04:00
Valentin Klein b75bd3c56a
Fonts: Palatino Linotype and Book Antiqua prefer Palatino look-alikes (#670) (#677)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 22:42:05 -04:00
Valentin Klein dab5092e83
Font dialog: separate Asian and Latin text fonts (#505) (#536)
* Font list: installed Chinese fonts are listed, by their Chinese names in a Chinese interface (#505)

The font database read only the English family name of each font, so a
Chinese interface listed SimSun, Microsoft YaHei and WenQuanYi Zen Hei
by their English names and never as 宋体, 微软雅黑 or 文泉驿正黑, and a
document naming a font by its Chinese name fell back to another font.

- fonts: keep each face's localized family names (name IDs 16 and 1,
  every face of a collection, read during the scan without loading the
  file); FontDb::families_in(lang) lists them in place of the English
  names for a matching interface language (zh-hans, zh-hant, ja, ko);
  face, has_family and styles accept either name.
- ui: the ribbon and mini toolbar font boxes show the interface
  language's names and store the English name.
- East Asian font: choosing a Chinese, Japanese or Korean font also sets
  it as the East Asian font (w:eastAsia), read and written in DOCX;
  layout draws CJK text in a run's installed East Asian font.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Font dialog: separate Asian and Latin text fonts (#505)

Word's Font dialog sets the font of Chinese, Japanese and Korean text
apart from the font of Latin text; WordCraft's had one font box.

- Font dialog: an "Asian text font" box (the installed Chinese, Japanese
  and Korean fonts, by their interface-language names, type to find)
  above the "Latin text font" box, shown for a Chinese or Japanese
  interface, East Asian text in the selection or a selection with an
  Asian font. OK sets only the fonts changed, in one undo step.
- format.font takes "eastAsia" (w:eastAsia) besides "name"; either or
  both; format.state reports fontEastAsia.
- fonts: the scan reads each font's OS/2 code pages, so
  FontDb::east_asian_families lists CJK fonts without loading them.
- Tests: dialog sets each font on its own; layout sets CJK text in the
  run's Asian font and Latin text in its Latin font (skipped without an
  installed CJK font); command and undo; the scan finds a CJK font.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:42:56 -04:00
Valentin Klein b2c51c6500
Fonts: index system font coverage once, so fallback no longer re-reads every font per character (#605) (#610)
System fallback read and parsed every cataloged font file (<40 MB) again for each distinct
character no loaded font covers; only that character's miss was remembered. Text in a script
no installed font covers (Tangut, CJK Ext G, ...) froze layout for seconds to minutes.

Each file is now read once, when a fallback search first reaches it, and its coverage is kept
as sorted code point ranges; later searches answer from the ranges and only read a file that
maps the character (to load it). Rescans keep the ranges of files still installed. Preferred
fallback families are tried once each, the miss set is capped, and indexing a damaged file is
bounded (faces, mappings, ranges). Search order and the fonts chosen are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:24:48 -04:00
Valentin Klein ee04e2b7f8
Font list: installed Chinese fonts are listed, by their Chinese names in a Chinese interface (#505) (#506)
The font database read only the English family name of each font, so a
Chinese interface listed SimSun, Microsoft YaHei and WenQuanYi Zen Hei
by their English names and never as 宋体, 微软雅黑 or 文泉驿正黑, and a
document naming a font by its Chinese name fell back to another font.

- fonts: keep each face's localized family names (name IDs 16 and 1,
  every face of a collection, read during the scan without loading the
  file); FontDb::families_in(lang) lists them in place of the English
  names for a matching interface language (zh-hans, zh-hant, ja, ko);
  face, has_family and styles accept either name.
- ui: the ribbon and mini toolbar font boxes show the interface
  language's names and store the English name.
- East Asian font: choosing a Chinese, Japanese or Korean font also sets
  it as the East Asian font (w:eastAsia), read and written in DOCX;
  layout draws CJK text in a run's installed East Asian font.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:27:53 -04:00
Valentin Klein 2e9d4a22ce
Chinese interface: keep a system CJK fallback after the bundled face; find PingFang on macOS 12 (#486) (#495)
The screenshots in #486 show exactly the characters BIZ UDPGothic (the
Japanese interface face) lacks: 选项规语简户另为导 … in Simplified and
啟內另 … in Traditional, with "?" in the Inter-based medium/semibold
families (no U+25FB replacement glyph) and boxes in the proportional one.
That is the font chain of v0.4.0 and earlier, which embedded no Chinese
face and had no installed-font fallback; v0.5.0 (#248) embeds Noto Sans
CJK SC. This hardens the chain so it can't silently regress:

- The installed CJK font is chosen by coverage: it is added after the
  embedded faces whenever they leave a CJK character of the CJK catalogs
  or the language names without a glyph (not only when no "Hans" face
  exists), and the face picked is the one mapping the most of those
  characters.
- macOS: look inside com_apple_MobileAsset_Font<N>/<hash>.asset/AssetData
  (bounded, one level) where macOS 10.15+ keeps PingFang.ttc, also under
  /System/Library/Assets; PingFangUI.ttc as a last resort.
- Test: every CJK character of the zh-hans, zh-hant and ja catalogs and
  the language names has a glyph in the embedded chain in all three
  interface weights (skips without craft-fonts; fails with a Japanese-only
  craft-fonts, listing exactly the characters from the screenshots).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:26:23 -04:00
Brandon Thomas 6b40846789
i18n: translate the remaining untranslated interface strings; test the font fallback cache with legacy names (#276)
* i18n: translate the remaining untranslated interface strings; test the font fallback cache with legacy family names (#233, #239)

Follow-ups from integrating #233 and #239: every tl! string now has a row in every
catalog, and a test pins that loading a face by its legacy family name drops the
fallback cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: Serbian rows for the newly translated strings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:31:59 -04:00
Valentin Klein 18d3a39278
Chinese interface shows every character instead of boxes on Windows (#248)
* Tests: shapes and pictures can be clicked, resized, dragged and nudged

Fixed by #46 but only text boxes were covered. These walk the editor's
path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test,
select, resize with arrange.bounds, drag to float with Square wrap,
nudge, undo. Covers #142 and #82.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Font list offers every weight a family installs, named as Word names them (#219)

Fonts were listed and matched only by their typographic family name
(name ID 16, e.g. "Roboto"), so a face other than Regular, Bold or Italic
(Light, Medium, SemiBold, Black, Condensed...) never appeared in the font
list, and a document asking for "Roboto Light" fell back to a substitute.

Each face now also carries its legacy family name, the one Word lists and
stores in documents: name ID 1 for static fonts ("Source Sans 3
Semibold"), or the family plus the style words other than Regular, Bold
and Italic for a variable font's named instances ("Inter Light"). The
font list includes these names, has_family and face() resolve them (bold
and italic still choose or synthesise within the faces the name covers),
and asking for a legacy name loads the whole installed family. The
system font scan now also reads fvar, so a variable font's instances are
listed before it is loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Absolutely positioned VML text boxes float instead of sitting inline (#149)

A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its
style was read as an inline object, so a tall text box in a header made the
header as tall as the box and pushed the body down the page.

The reader now maps the VML style to the same float model DrawingML anchors
use: left/margin-left and top/margin-top offsets, mso-position-*-relative
reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-*
and w10:wrap (square, tight, through, topAndBottom; none or absent is in front
of the text, or behind it at a negative z-index). Shapes without
position:absolute stay inline. Saving writes the boxes as DrawingML anchors
with the same placement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Chinese interface: embed the Chinese UI font in releases, fall back to an installed one (#241)

The release workflow pinned storytold/craft-fonts at abb8331, the commit that added the
Japanese fonts. Noto Sans CJK SC came one commit later (8dcdacd), so release builds embedded
only the Japanese faces and the Simplified Chinese interface fell back to BIZ UDPGothic, which
lacks about 157 simplified-only hanzi (页, 选, 删 …) and drew them as boxes. Document text was
unaffected because it uses the system font fallback.

- Bump the craft-fonts pin in the release and FreeBSD workflows to 8dcdacd.
- With CRAFT_FONTS_REQUIRED, a native build now fails when the craft-fonts manifest has no
  Jpan or Hans font, so an outdated pin can't ship silently again.
- When no embedded face covers the interface language, the UI font families get an installed
  CJK font (desktop only): a short per-platform list of files in the system font folders
  (Windows Deng/msyh/simhei/simsun, macOS PingFang/STHeiti, Linux Noto Sans CJK/WenQuanYi),
  header-checked, size-capped, and the face chosen by coverage (a SC face in a pan-CJK
  collection), so egui never sees a file it can't parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add a System interface theme that follows the OS appearance (#115)

The interface theme is now a three-way choice: Use system setting,
Light or Dark, in File > Options > General and in a new Interface Theme
menu in View > Dark Mode. System follows the light/dark appearance egui
reports (winit on macOS, Windows and Linux; prefers-color-scheme on the
web) at launch and live, and falls back to light when the OS reports
none. egui's own theme is now pinned to ours, so an OS change no longer
swaps in egui's unstyled style.

Saved preferences migrate: the old "dark" switch becomes Dark or Light
and is never written again. Light stays the default for new installs.
New ui.theme command (light/dark/system); ui.dark still sets Light/Dark.
The dark page (View > Switch Modes) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #248: read the installed CJK UI font only when CJK text is shown

A build without craft-fonts read a large system CJK font on the first frame even
for an English interface. Now it is loaded only for a CJK interface language, or
once File > Options shows the language list (names in their own scripts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 13:04:18 -04:00
Valentin Klein f4a694ecf0
Font list offers every installed weight (Light, Medium, SemiBold, Black…), as Word does (#239)
* Tests: shapes and pictures can be clicked, resized, dragged and nudged

Fixed by #46 but only text boxes were covered. These walk the editor's
path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test,
select, resize with arrange.bounds, drag to float with Square wrap,
nudge, undo. Covers #142 and #82.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Font list offers every weight a family installs, named as Word names them (#219)

Fonts were listed and matched only by their typographic family name
(name ID 16, e.g. "Roboto"), so a face other than Regular, Bold or Italic
(Light, Medium, SemiBold, Black, Condensed...) never appeared in the font
list, and a document asking for "Roboto Light" fell back to a substitute.

Each face now also carries its legacy family name, the one Word lists and
stores in documents: name ID 1 for static fonts ("Source Sans 3
Semibold"), or the family plus the style words other than Regular, Bold
and Italic for a variable font's named instances ("Inter Light"). The
font list includes these names, has_family and face() resolve them (bold
and italic still choose or synthesise within the faces the name covers),
and asking for a legacy name loads the whole installed family. The
system font scan now also reads fvar, so a variable font's instances are
listed before it is loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Absolutely positioned VML text boxes float instead of sitting inline (#149)

A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its
style was read as an inline object, so a tall text box in a header made the
header as tall as the box and pushed the body down the page.

The reader now maps the VML style to the same float model DrawingML anchors
use: left/margin-left and top/margin-top offsets, mso-position-*-relative
reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-*
and w10:wrap (square, tight, through, topAndBottom; none or absent is in front
of the text, or behind it at a negative z-index). Shapes without
position:absolute stay inline. Saving writes the boxes as DrawingML anchors
with the same placement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add a System interface theme that follows the OS appearance (#115)

The interface theme is now a three-way choice: Use system setting,
Light or Dark, in File > Options > General and in a new Interface Theme
menu in View > Dark Mode. System follows the light/dark appearance egui
reports (winit on macOS, Windows and Linux; prefers-color-scheme on the
web) at launch and live, and falls back to light when the OS reports
none. egui's own theme is now pinned to ours, so an OS change no longer
swaps in egui's unstyled style.

Saved preferences migrate: the old "dark" switch becomes Dark or Light
and is never written again. Light stays the default for new installs.
New ui.theme command (light/dark/system); ui.dark still sets Light/Dark.
The dark page (View > Switch Modes) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 13:02:40 -04:00
Dmitrii Khlapov 9643dcb621
Font picker: build only the visible rows, cache font fallbacks (#121, #59) (#233)
Opening the font menu built a row, and so rendered a preview, for every
installed family: each preview loads that family's font files, so the menu
kept repainting until every font on the system was read into memory (1.1 GB
for 305 families here; far more on large collections), which froze the app.
The combo list now uses ScrollArea::show_rows, so only rows in view are built.

Layout asked FontDb for a fallback face once per character the font lacks,
and each lookup sorted every loaded face, so text in a script the font lacks
(Thai) slowed down with every font loaded. Fallback lookups are now cached
per (character, excluded face) until more faces load: 5000 Thai characters
lay out in 5 ms instead of 96 ms with all fonts loaded.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:24:54 -04:00
Saeed Azarehgar 5d86dec250
Right-to-left and Persian text support (bidi, shaping, RTL paragraphs, DOCX) (#207)
* feat(doc,docx): complex-script run properties and RTL OOXML round-trip

Model the formatting Word applies to Persian, Arabic and Hebrew text:
cs, font_cs, size_cs, bold_cs, italic_cs and lang_bidi on CharProps,
resolved with Word's fallbacks (ResolvedChar::complex). Size, bold and
italic pair with their complex-script values so a run's size or bold
also covers its Persian characters. Add doc::bidi script helpers, and
keep ZWNJ and Arabic marks inside words for word selection.

DOCX: read and write w:rFonts/@w:cs, w:szCs, w:bCs, w:iCs, w:cs and
w:lang/@w:bidi in schema order; stop using the cs font as the Latin font.
Tests round-trip Persian text with ZWNJ, digits and mixed Latin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(fonts): directional shaping and Persian font fallback

Add shape_run to shape a run in a known bidi direction (Arabic joining,
mirrored brackets). Prefer Persian-capable fonts (Vazirmatn, Noto Naskh/
Sans Arabic…) for right-to-left fallback, pick bold/italic fallback faces,
and map common Persian document fonts (B Nazanin, B Lotus, B Titr,
IRANSans…) to open substitutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(layout): Unicode Bidirectional Algorithm and right-to-left paragraphs

Run UAX #9 per paragraph (unicode-bidi), shape each level run in its
direction, keep marks and ZWNJ with their letter and a word in one
fallback face. After line breaking, apply rules L1/L2 per line and give
each cluster a visual box; right-to-left paragraphs are laid out from
their start edge and mirrored, so indents, tabs, alignment, list labels,
drop caps, floats and borders sit on the right.

Caret, hit testing and selection use visual geometry (split selections,
caret at bidi boundaries, DocLayout::visual_step for arrow keys). Display
items carry glyph-to-text ranges, which the PDF writer now uses. Plain
left-to-right lines are unchanged. No hyphenation of RTL words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(engine,ui): paragraph direction commands and RTL editing

Add para.ltr / para.rtl (Word's text direction buttons) that change only
the reading order, and make the alignment buttons visual in right-to-left
paragraphs (para.align also takes start/end). Arrow keys move visually in
bidirectional text, Ctrl+arrows follow reading order, and Bold/Italic/
Size/Font cover complex-script text. Ribbon buttons with code-drawn
icons, a mirrored ruler for RTL paragraphs, translations, catalog,
parity and roadmap updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(contributors): add Saeed Azarehgar to people.toml

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(engine): para.align with an explicit value sets it, without toggling

Only the alignment buttons toggle back to the start edge when pressed
again. para.align (used by the Paragraph dialog, scripts and agents) now
sets the requested alignment, as seen on the page, in each selected
paragraph's own direction. Previously pressing OK in the dialog with
Center or Right selected reset the paragraph to the start edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ui): Direction option in the Paragraph dialog

Add Word's Direction (Right-to-left / Left-to-right) to the Paragraph
dialog's General section. The dialog now shows alignment and Left/Right
indents as seen on the page, mapping them to the paragraph's logical
start/end values on OK, so an unchanged OK no longer flips a
right-to-left paragraph. OK logic moved to apply_paragraph, with tests;
ja/zh translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(layout): allow hanging trailing spaces in the RTL display test

A wrapped right-to-left line's trailing spaces hang past its end edge
(the left margin), as left-to-right lines hang them past the right. The
test failed whenever the loaded fallback fonts made a line end there;
it now checks only visible glyphs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:26:41 -04:00
Dr. Gled Guri 10eb8798cd
Equations: Word (OMML) import, layout and save, plus an equation editor like Word's (#191)
* M5.5: Word equations (OMML) import, lay out and save like Word

Equations were flattened to plain text on import. Now OMML is read into a
math tree (fractions, scripts, radicals, n-ary operators, delimiters,
functions, limits, accents, bars, boxes, equation arrays, matrices,
phantoms), laid out with the OpenType MATH table (Cambria Math when
installed, else STIX Two Math and other open math fonts), drawn to screen
and PDF, and written back as the original OMML. Display equations sit on
their own centred line. insert.equation parses the linear format into
structure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M5.5: equation editor like Word's — Equation tab, in-place editing, # numbers

Insert › Equation now opens an equation for in-place editing, with the
Equation tab: ready-made equations, Unicode/LaTeX input and conversion
(professional ↔ linear), Normal Text, eight symbol sets, and the eleven
structure galleries (fraction, script, radical, integral, large operator,
bracket, function, accent, limit and log, operator, matrix) with tiles
rendered by the math engine. Typing builds up like Word (a/b, x^2, √, ∑…
then space), Math AutoCorrect turns \alpha, \sqrt… into symbols, Tab moves
between empty slots, arrows/Backspace work through structures, and the
right-click menu has structure commands (fraction type, radical degree,
limit position, matrix rows/columns, remove brackets…). A trailing #(n)
numbers a display equation with the number at the right margin; # alone
numbers it automatically. DOCX output keeps numbers Word-compatible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: equation editor strings in Spanish, Brazilian Portuguese and Ukrainian

The catalogs that landed on main after #191 was opened lacked the Equation
tab and equation command labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Equations: our own names and order for the built-in equations

Clean room: the ready-made equation list used another product's gallery entry
names and order. Same formulas (and ids), with our own descriptive labels,
grouped by subject (geometry, algebra, calculus, trigonometry, probability).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ROADMAP: equations row matches the landed equation editor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fmt after merge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 09:04:52 -04:00
Weeb Labs e4456efb84
Text boxes and floating objects: editing, drag/resize, wrapping, overflow, footnotes (#46)
* fix text boxes: type into a new box, click in and out, Escape leaves it

Insert › Text Box left the caret in the body after the box and clicks inside a
box never reached its story, so its text could not be edited.

- insert.textBox puts the caret in the new box (when anchored in a top-level
  body paragraph, the only place layout shows box text today).
- Layout emits a hit-test-only Placed::TextBox area; story_at prefers a box
  under the point (body text wins over boxes behind it) and ignores box lines
  outside their box.
- Canvas: click enters a box, clicking outside goes back to the body (not while
  editing a header/footer); double-click in a box selects a word there.
- Escape in a box puts the caret just after it (Document::text_box_anchor).
- Editing a box doesn't dim the body or show "Editing header/footer".
- Tests: engine (caret in box, typing, undo, table cell unchanged), layout
  (story_at inside/outside/behind), doc (anchor lookup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix pasted text boxes sharing the original's text

A copied fragment only carried the text box's part id, so a pasted box showed
the original's story: editing either changed both, edits made after copying
leaked into the paste, and pasting into another document (or Insert › Text
from File) pointed at whatever part had that id there, e.g. a header.

- Fragment carries copies of the text box stories it shows (nested boxes too).
- insert_fragment gives every pasted box a new story of its own; a box whose
  story can't be found keeps none rather than showing an unrelated part.
  Nesting is depth-limited and one paste creates at most 4096 stories.
- Insert › Text from File uses Document::body_fragment, which brings the
  file's text boxes along.
- Tests: doc (own story, copy-time snapshot, other document with clashing
  ids, box in a table, fragment without parts, self-nested box), engine
  (copy/paste/type/undo through commands).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* word count includes text boxes, footnotes and endnotes (Word's default)

The status bar and Word Count dialog counted only the body, so words typed
into text boxes (and notes) were missing.

- Document::counted_stories / word_count_including_notes: the body plus the
  text boxes, footnotes and endnotes it actually shows (nested too); orphaned
  stories (deleted or cut boxes), headers, footers and comments don't count.
- Session::count_notes (default on) with Session::word_count for the status
  bar; review.wordCount takes {"includeTextBoxes"?: bool} and reports it.
- Word Count dialog: "Include text boxes, footnotes and endnotes" checkbox.
- Document::word_count stays body-only (docx Words property, NUMWORDS, stats).
- Tests: doc (used vs orphaned stories, nesting, self-nested box), engine
  (default on, toggle sticks, deleting a box drops its words).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* canvas: select, drag to move and resize pictures, shapes and text boxes

Click a picture or shape (or a text box's border) to select it: a frame with
eight handles. Drag it to move it, drag a handle to resize it (pictures keep
their proportions from a corner, Shift toggles), arrows nudge a floating one
(Ctrl/Alt: 1 pt), Escape cancels a drag. While a text box's text is edited its
frame shows dashed with handles. Typing or Enter with a text box selected
goes into it.

Reusable pieces, no per-kind code:
- layout: Placed::Object, one hit area for every picture/shape/text box
  (replaces the text-box-only item); DocLayout::object_at / object /
  text_box / find_object (page-hinted, no whole-document scans per frame).
- engine: arrange.bounds {width,height,x,y,page} (floating objects keep their
  anchors on the same page; inline ones float where dropped; another page
  re-anchors to the nearest line) and arrange.nudge; objects::min_size shared
  with the UI.
- ui: frame.rs, a document-agnostic transform frame (handles, hit testing,
  drag/resize maths, painting); objects.rs wires it to document objects.
- geom: Rect::expand. control: ui.press / ui.release for half-way drags.

Rendering: nothing is laid out or rasterised while dragging. The preview is
the object's own pixels from the cached page texture (moves, picture
resizes) or a light outline (text box/shape resizes); the drop is one
command, one relayout, one undo step.

Tests: frame maths, layout hit areas, engine move/resize/nudge across
anchors and pages, hostile values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* moving an object anchors it to the paragraph it's dropped on, so text wraps

A floating object only pushes aside text from its anchor paragraph down, and a
move kept the old anchor: drag a text box above its paragraph and the text
there ran straight under it.

- arrange.bounds now anchors the object to the paragraph under its top edge
  (the last one starting at or above it on that page) and positions it
  relative to that paragraph's column and top, as Word does: the text it
  lands on wraps, and it moves with that text. On a page where no paragraph
  starts it anchors to the nearest line, positioned on the page.
- layout: Placed::Object / ObjectHit carry the paragraph origin that column-
  and paragraph-relative offsets start from.
- engine: edit_obj / edit_float change an object at a position (with_obj
  uses it).
- Test: a box dropped over an earlier paragraph anchors there and the lines
  beside it stop short of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* text boxes hide text that doesn't fit, as in Word

Text past a text box's bottom spilled out over the page below it.

- layout: fit_box keeps what fits in a box: whole lines that fit (a line that
  only partly fits is hidden too) and other items starting inside; the first
  line always stays so even a tiny box shows something. Done in layout, so the
  canvas, PNG and PDF agree and hidden text can't be clicked; no renderer
  changes. Reusable for anything laid out into a fixed height.
- BOX_INSET_X / BOX_INSET_Y name the text box internal margins (were 7.2/3.6
  literals).
- Test: overflow hidden, every shown line inside the box, a tall box shows
  everything, a minimum-size box shows its first line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* square wrap flows text on both sides of a floating object

Text only used the wider side of a floating picture, shape or text box; Word's
Square wrap fills both sides when there's room.

- para: row_spans gives the spans of a row around the objects in its way (each
  at least 0.5" wide, else the line goes below); a row cut in two is two Lines
  sharing top and height, the second marked `beside`. Everything per line
  (alignment and justification per side, hit testing, caret, selection,
  drawing, PDF) works unchanged; a row is as tall as its tallest part.
- layout: a row stays on one page; line numbers count rows, not parts.
- Exclusions that don't overlap a line horizontally no longer narrow it.
- Test: text both sides of a mid-column box (same row, left then right,
  hittable), a too-narrow gap stays empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* drop a deleted or cut text box's text from the document

Deleting or cutting a text box left its story in Document::parts: never shown
or saved to .docx, but listed by document.inspect and kept in memory.

- doc: prune_text_boxes removes the text box stories no box shows (nested
  ones too); free without text boxes, one paragraph pass otherwise. Session
  runs it after every editing command. Undo snapshots and the clipboard hold
  their own copies, so undo and cut/paste are unaffected.
- doc: reachable() walks the stories objects lead to; counted_stories (word
  count) and prune_text_boxes share it, and it walks blocks directly instead of
  building paragraph paths.
- Tests: orphaned and nested-orphan boxes go, shown ones (body, nested, in a
  header) stay; delete then undo, cut then paste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* word count's include-text-boxes setting survives a restart

The Word Count dialog's "Include text boxes, footnotes and endnotes" was a
session field, so it reset to on every launch.

- engine: Prefs, the editing preferences the front end saves between runs
  (count_notes moves there; Session::prefs is the one source of truth).
- ui: SavedPrefs pairs UiState with the engine Prefs; WordApp::saved_prefs /
  restore_prefs. UI fields stay at the top level of ui.json, so existing files
  still load (the new `editing` section defaults).
- wordcraft app: saves and restores SavedPrefs instead of UiState alone.
- Tests: an old prefs file loads with Word's default; the setting round-trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* text boxes and floating objects outside the body; footnotes in text boxes

Text boxes in table cells, headers, footers, footnotes and other text boxes
showed an empty frame (or nothing), and floating pictures and shapes there
weren't drawn. Footnotes inserted inside a text box were never numbered or
placed.

- layout: place_objects, one routine for the pictures, shapes and text boxes
  in a paragraph's lines (floating ones drawn, every one's area, text boxes'
  text), used by body paragraphs and by layout_box (cells, headers, footers,
  notes, box contents). Floats are positioned against an AnchorFrame (column,
  paragraph, margins, page); headers and footers pass where the page is, so
  page/margin-anchored objects land right (a footer is laid out again only
  when it has floating objects); elsewhere they're placed in their container.
  Nested boxes show MAX_TEXT_BOX_DEPTH (doc) levels deep.
- Placed::Object / ObjectHit carry their story; DocLayout::object(&Pos),
  object lookups include headers and footers, header_footer_text_box_at.
  A part's lines are looked up in headers and footers too (caret, typing).
- Footnotes: numbered in reading order with text boxes where they are
  (Document::objects_in_reading_order); a box's footnotes go on the page of
  its line (Document::notes_in_text_box), sharing the body's reservation.
- engine: insert.textBox always puts the caret in the new box; disabled
  inside a text box (as in Word).
- canvas: click into a header/footer's text boxes while editing it; leaving a
  box returns to where it is; body dimming computed once per frame and used
  for both the render and its cache key. objects: only body objects are
  selectable, only body paragraphs' objects movable; frames off the body
  show without handles.
- Tests: boxes in header (inline and page-placed), footer, cell and note show
  text and take the caret; footnotes in a box numbered and placed in order;
  a box in a table cell takes the caret and its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* word count: a selected object counts the whole document, in the dialog too

With a picture, shape or text box selected the status bar showed the
document's total but Review > Word Count counted the selection (0 words).

- engine: cmd::objects::object_selection, "the selection is exactly one
  object"; review.wordCount counts the document then. The UI's
  objects::selected now uses it (one definition).
- Test: an object selection counts the document; a text selection itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* review: bound nested text box expansion; tidy-ups

Blocker: laying out a text box lays out the boxes inside it, which could fan
out unboundedly (a box whose shapes show itself, or chains of boxes each
showing the next many times) - 30 shapes made 1.7M layout items. Footnote
numbering's walk fanned out the same way.

- doc: BoxBudget, one bound for both: a box isn't expanded inside itself,
  nesting stops at MAX_TEXT_BOX_DEPTH, an outermost box expands at most 64
  boxes inside it (so one hostile box can't starve the next page's header
  boxes) and a pass at most 20,000.
- layout: Ctx carries a BoxBudget for the whole layout; place_objects asks it
  before laying out a box's text. DocLayout::text_boxes reports how many were.
- doc: walk_objects (objects_in_reading_order, notes_in_text_box) uses it too.
- Tests (crafted documents): a box showing itself 30 times lays out once; a
  five-level chain fanning out 30 ways stays within the budget, for layout and
  for the walk; a box inside a box still shows its text. Weakening the budget
  makes them fail (20,000 boxes / 600,001 visits).

Tidy-ups:
- para: row spans in a VecDeque (pop_front, not remove(0)).
- fit_box: a text box's text area excludes both the top and bottom margins.
- theme: Tokens::handle / handle_shadow for the frame handles (light, dark);
  the drag preview's opacity is a named constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* box budget: no overall cap; bound the .docx writer too; tests

An audit of the previous commit found:

- The 20,000-box overall cap starved ordinary long documents: one budget per
  layout, spent by body boxes first, then one per page for header boxes (two
  for footers with floats), so a long document's header boxes went blank on
  every page. It wasn't needed: only nesting grows exponentially, and
  MAX_NESTED caps that per outermost box, keeping the work linear. Removed.
- The .docx writer still expanded a shape's story only depth-limited (a box
  showing itself 30 times saved as ~28,000 nested copies, ~7 MB zipped). It
  now uses the same BoxBudget, and only for text box parts.
- Layout expanded a shape's story whatever its part kind (a shape pointing
  at a header showed the header's text); now text box parts only, as the
  walk does. The story's blocks are cloned only once the budget agrees.
- Translations of "Count words in text boxes and notes" said footnotes only;
  now footnotes and endnotes, in each catalog's own terms.
- Tests: no timing assert or leftovers; a long document with 400 boxes and a
  header box shows all of them on every page; notes_in_text_box follows
  nested boxes once; saving a self-showing box stays small (fails at 7 MB
  without the bound).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Arrange: nudging or dragging an aligned float keeps it in place first

main (#136) reads w:align into Float.h_align/v_align, which overrides the offset; the PR's
nudge only changed the offset and drag only reset the anchors. Nudge now converts an
aligned axis into a column/paragraph offset that keeps the object where it is, and a drag
clears the alignment. Test included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fonts: a lookup that races another thread's load of the same family no longer falls back

FontDb::face fell back to Source Sans 3 when its load_cataloged added nothing, which happens
when another thread loaded the family between the miss and the load. word::resolve memoises
that, so the wrong font stuck for the process; it made layout tests on main flaky
(justified_lines_shrink_spaces_in_word_2013_mode, space_shrinking_is_only_for_justified_text
failed 6 of 15 runs on origin/main). Look again after loading whatever it returns. Regression
test races 8 threads on fresh databases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout tests: text box and two-sided wrap tests measure what they mean

square_wrap_flows_text_on_both_sides compared xs.last() (the end of a trailing space, which
may hang past the edge) with the edge; compare the last non-space cluster's right edge.
text_box_hides_text_that_does_not_fit compared a tall box's lines with another document's
line count; compare with its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Weeb Labs <weeblabs@VFIEVOX3.Router>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:51:38 -04:00
Brandon Thomas ed5a29f1b9 Fonts: load an installed family's faces all at once, one load at a time
load_cataloged added a family's files one by one, so a lookup on another thread could find the family half-loaded, resolve the closest style it saw (say Bold for Regular) and word::resolve cached that face for the rest of the process. Faces from a family's files are now added under one write lock, family loads are serialised (re-checking once the lock is held), and add_font no longer checks and inserts under separate locks.

This made layout tests flaky under parallel test threads (space_shrinking_is_only_for_justified_text failed 4/20 runs on main; 0/40 with this change). In the app it could give a document opened during the first font lookups a wrong face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:09:52 -04:00
DWAA1660 d7d9702bda
Interface languages: follow the system, or pick one in Options (#8) (#12)
WordCraft's interface can now be shown in Simplified Chinese, Traditional
Chinese or Japanese as well as English. The new `language` setting is
`auto` by default: the UI follows the operating system's preferred
languages (Windows display language, macOS preferred languages,
LANG/LC_* on Unix, via sys-locale; WORDCRAFT_LANGUAGE overrides) when
there is a catalog for one of them, and English otherwise. File > Options
> Interface language picks one by hand, and the `ui.language` command does
the same for scripts and agents. The setting persists with the other UI
preferences.

The i18n module is PdfCraft's (catalog format, parser, locale matching),
trimmed to plain entries. English strings in the code are the keys; the
ribbon helpers (group, big, small, split, menu_button, menu items,
tooltips and ribbon locations), tabs, Backstage, Options, dialogs, panes,
the status bar, the context menu and command search translate at draw
time. Command ids, documents, the CLI, MCP and the control channel stay
English. Catalogs hold ~780 entries per language, written clean-room from
the English meaning.

Chinese interfaces put the craft-fonts Chinese face before the Japanese
one so a line never mixes glyph styles; fonts are reinstalled when the
language changes.

Fixes #8.


Claude-Session: https://claude.ai/code/session_019FczntrDNYd6DupyBdpbL5

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
2026-10-09 09:22:42 -04:00
MarekSpodBiedry 0edb87e3da
draw Symbol and Wingdings bullets as Unicode when the font is missing (#29) (#37) 2026-10-09 08:48:57 -04:00
Brandon Thomas f3dbc31a57 README with screenshots, Crafting Apps table, community links; proofing ignores deletions/objects; comment shading; hhea line metrics; watermark tuning
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:18:42 -04:00
Brandon Thomas d95e09ba25 Clippy/fmt clean workspace, ROADMAP with honest parity estimate; xtask ci green
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:11:08 -04:00
Brandon Thomas d1e80df4da M0.1–M0.6: workspace, geom, document model, fonts, layout, render
Clean-room foundations for WordCraft: Arc-shared document model with
paragraph/run editing, styles, lists, sections and tables; Word-style
font substitution; first-fit line breaking with tabs, lists, alignment,
pagination, tables, headers/footers and hit testing; vello_cpu page
rasteriser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:30:16 -04:00