Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2cf9b37
KM1: add shared Khmer composition and native proofreading
sungkhum Nov 3, 2026
79521fb
KM2: preserve Khmer subscript shaping with ligatures disabled
sungkhum Nov 3, 2026
c00dfa5
Merge remote-tracking branch 'origin/main' into feat/khmer-writing-su…
sungkhum Nov 3, 2026
f264bf3
KM3: justify Khmer at real word spaces with glyph scaling, as InDesign
sungkhum Nov 3, 2026
d13a091
KM4: apply the WordCraft and craft-libs review findings
sungkhum Nov 3, 2026
50fb910
KM4: credit only what the Khmer composition still takes from #516
sungkhum Nov 3, 2026
1173dcb
Merge remote-tracking branch 'origin/main' into feat/khmer-writing-su…
sungkhum Nov 3, 2026
3bdbf53
KM5: one Khmer script definition; record the tagger's training data
sungkhum Nov 3, 2026
1ec8ccc
KM5: record the Linux and Windows validation of Khmer support
sungkhum Nov 3, 2026
ecce77e
Merge remote-tracking branch 'origin/main' into feat/khmer-writing-su…
sungkhum Nov 3, 2026
28d0fba
KM6: count Khmer words by their word boundaries in the Story Editor
sungkhum Nov 3, 2026
4baa1d3
KM6: do not count detached Khmer vowel signs as words without the data
sungkhum Nov 3, 2026
bee98e3
Merge remote-tracking branch 'origin/main' into feat/khmer-writing-su…
sungkhum Nov 3, 2026
49dcae9
KM7: share the spelling cache entry and tidy the Khmer docs
sungkhum Nov 3, 2026
91154a8
Merge remote-tracking branch 'origin/main' into feat/khmer-writing-su…
sungkhum Nov 3, 2026
d997f6d
KM8: keep large books' Khmer boundaries cached between passes
sungkhum Nov 3, 2026
454d7ca
KM9: move proofreading out of the Khmer composition change
sungkhum Nov 3, 2026
224faf5
Merge remote-tracking branch 'origin/main' into feat/khmer-writing-su…
sungkhum Nov 5, 2026
ee4c0d4
Merge remote-tracking branch 'origin/main' into feat/khmer-writing-su…
sungkhum Nov 5, 2026
46fac03
KM10: pin craft-khmer to the current craft-libs#551
sungkhum Nov 5, 2026
8b28482
KM11: link the Khmer data docs at the pinned commit
sungkhum Nov 5, 2026
77c47e5
Merge remote-tracking branch 'origin/main' into feat/khmer-writing-su…
sungkhum Nov 5, 2026
d75e05d
Merge remote-tracking branch 'origin/main' into feat/khmer-writing-su…
sungkhum Nov 5, 2026
63c212e
KM: name skipped Khmer data tests and complete the ASSETS note
sungkhum Nov 8, 2026
498d4d8
KM: drop Khmer layout tests that need the absent data pack
sungkhum Nov 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ASSETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra

| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| Optional Khmer model, spelling/frequency dictionaries and preferred-form rules (embedded by `craft-khmer`) | SBBIC Khmer; sources include SEAlang (Doug Cooper) and SIL NRSI khmerlbdict | https://github.com/sbbic-khmer/aksara-khmer-word-processor/tree/31740e9d51d38171ae6119fc2d18a4a18fbfb2f1 | MIT (full notices in NOTICE) | Explicit `CRAFT_KHMER_DIR` input; source and canonical hashes verified by the pinned shared crate; no dictionaries stored in this repository |
| German hyphenation patterns (embedded by `hypher` 0.1.8, feature `german`) | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr | https://github.com/typst/hypher/blob/d81cc506416bffef2a75ee2dc969d4b41bc36613/patterns/hyph-de-1996.tex | MIT (copyright 2013–2024; full text in NOTICE) | Version 2024-02-28; reformed German spelling (2006), despite the `de-1996` filename; no separate old-spelling or Swiss patterns |
| `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts |
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts |
Expand Down
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ rust-version = "1.95"
repository = "https://github.com/storytold/designcraft"

[workspace.dependencies]
# Shared Khmer segmentation and proofreading; data is an explicit, hash-verified build input.
craft-khmer = { git = "https://github.com/storytold/craft-libs", rev = "2322f68f2e24bebb6e89e8333de3bbb7d02d1704", version = "=0.1.0" }
# Shared reader and writer; publish the immutable revision before this consumer.
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false }
# Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog.
Expand Down
37 changes: 37 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,40 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Khmer language engine and optional language data
-----------------------------------------------
Shared craft-khmer is adapted from SBBIC Khmer’s Aksara at
31740e9d51d38171ae6119fc2d18a4a18fbfb2f1. The unchanged data is an optional,
hash-verified build input; no publisher documents or additional fonts are bundled.
SBBIC identifies internal work, the SEAlang frequency lists
(http://sealang.net/project/list/) and SIL NRSI’s Khmer line-breaking dictionary
(https://github.com/silnrsi/khmerlbdict) as sources. These materials are MIT
licensed; their copyright and permission notices follow. Builds without
CRAFT_KHMER_DIR omit this data. See docs/khmer.md for provenance and its limits.
The attached-glyph protection in composition was informed by visarutforthaipbs’s
DesignCraft #516. Shared-core contributions are credited in craft-khmer.

MIT License

Copyright (c) 2025 SBBIC Khmer
Copyright (c) 2015 SIL NRSI
Copyright (c) 2010, Doug Cooper

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
8 changes: 7 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
- 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md).
- 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.

> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** minor (optional Khmer composition and proofreading; placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
- KM2: Khmer shaping keeps required conjunct/subscript substitutions when imported ligature or tracking settings disable optional ligatures; Latin controls remain unchanged.
- 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.
Expand Down Expand Up @@ -383,6 +384,11 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m

## Progress log

- **2026-11-03 · KM6:** The Story Editor counts Khmer words by their word boundaries, not real spaces. No readiness percentage change.
- **2026-11-03 · KM4:** Dynamic Spelling proofs a story again only when its text, its styles or the user dictionary change, instead of every story on every edit. No readiness percentage change.
- **2026-11-03 · KM3:** Justified Khmer fills lines through real word spaces and Glyph Scaling, as in InDesign: word boundaries open no gaps, and a line without a real space keeps its natural width. No readiness percentage change.
- **2026-11-03 · KM1:** Shared Khmer segmentation, authored ZWSP, cluster-safe composition in both composers, and native spelling/preferred-form review with undo. Data is an explicit optional build input; see [Khmer support](docs/khmer.md). No readiness percentage change.

- **2026-11-02** · EPUB Export Options names a cover image file (PNG or JPEG, typed or chosen with Choose…) for the reflowable export; left empty, the first page stays the cover (#1934). No readiness percentage change.
- **2026-11-02** · The Pen shows the segment a dragged smooth point shapes while it is dragged, and its rubber band curves out of a smooth point's direction line (#1438, first item). No readiness percentage change.
- **2026-11-02** · A Custom text variable defined with a number (a typed `2026` reaches the command as one) shows its digits instead of nothing (#2093, first item). No readiness percentage change.
Expand Down
1 change: 1 addition & 0 deletions crates/compose/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ description = "DesignCraft text composition: paragraph composer (Knuth–Plass),
workspace = true

[dependencies]
craft-khmer = { workspace = true }
unicode-bidi = { workspace = true }
unicode-segmentation = { workspace = true }
unicode-bidi-mirroring = { workspace = true }
Expand Down
14 changes: 10 additions & 4 deletions crates/compose/src/breaker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,11 @@ impl Spacing {
return ([moji_stretch + st, 0.0, 0.0], [moji_shrink + sh, 0.0, 0.0]);
}
let natural = g.adv / self.glyph_desired.max(0.01);
// Khmer letters take glyph scaling but no letter spacing (see `khmer::letter_gap`).
let letter = if crate::khmer::character(g.ch) { 0.0 } else { g.space };
(
[moji_stretch, g.space * (self.letter_max - self.letter_desired).max(0.0), natural * (self.glyph_max - self.glyph_desired).max(0.0)],
[moji_shrink, g.space * (self.letter_desired - self.letter_min).max(0.0), natural * (self.glyph_desired - self.glyph_min).max(0.0)],
[moji_stretch, letter * (self.letter_max - self.letter_desired).max(0.0), natural * (self.glyph_max - self.glyph_desired).max(0.0)],
[moji_shrink, letter * (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` (see [`Glyph::is_word_space`]); none
Expand Down Expand Up @@ -187,7 +189,8 @@ struct Item {
zone_from: usize,
/// An emergency break inside a word wider than the measure.
emergency: bool,
/// Glue: stretch past the maximum word spacing ([`Spacing::space_overflow`]).
/// Glue: stretch past the maximum word spacing ([`Spacing::space_overflow`]). A justified
/// Khmer box: its share of the line's slack rating ([`crate::khmer::slack_unit`]).
over: f64,
}

Expand Down Expand Up @@ -456,6 +459,9 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S
let mut bx = Item::boxed(g.adv, st, sh);
bx.hang = hang_left(g, sp) - g.moji.start;
bx.start_elastic = crate::mojikumi::start_elastic(g, sp.justify, sp.kinsoku_priority != 3);
if sp.justify {
bx.over = crate::khmer::slack_unit(g);
}
it.push(bx);
ig.push(i);
let next_is_space = glyphs.get(i + 1).is_none_or(|n| n.is_space());
Expand Down Expand Up @@ -890,7 +896,7 @@ fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain:
/// split: before `i`, or earlier where kinsoku forbids that (no line starts with closing
/// punctuation).
fn emergency_split(glyphs: &[Glyph], start: usize, i: usize) -> usize {
let allowed = |j: usize| matches!((glyphs.get(j.wrapping_sub(1)), glyphs.get(j)), (Some(a), Some(b)) if kinsoku_allows(a.ch, b.ch));
let allowed = |j: usize| matches!((glyphs.get(j.wrapping_sub(1)), glyphs.get(j)), (Some(a), Some(b)) if a.break_after != Some(false) && !a.no_break && kinsoku_allows(a.ch, b.ch));
(start + 1..=i).rev().find(|&j| allowed(j)).unwrap_or(i)
}

Expand Down
127 changes: 127 additions & 0 deletions crates/compose/src/khmer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//! Khmer lexical boundaries from the shared Aksara-derived core. The composer owns shaped
//! cluster safety and justification. The attached-glyph protection adapts ideas from
//! @visarutforthaipbs's DesignCraft PR #516:
//! https://github.com/storytold/designcraft/pull/516. Khmer lexical decisions use craft-khmer,
//! as in WordCraft, rather than the ICU4X segmenters proposed in that contribution.
//!
//! A boundary only permits a line break: it has no width, so justification never opens a gap
//! there. As in InDesign, a line fills through its real word spaces and the paragraph's glyph
//! scaling; a line without a real space keeps its natural width until the author adds one.
use std::{
collections::HashMap,
ops::Range,
sync::{Arc, Mutex, OnceLock},
};

use crate::shape::Glyph;
use unicode_segmentation::UnicodeSegmentation;

/// A Khmer-script character: the one definition composition, proofing and the UI share.
pub fn character(c: char) -> bool {
matches!(c, '\u{1780}'..='\u{17ff}' | '\u{19e0}'..='\u{19ff}')
}
fn mark(c: char) -> bool {
matches!(c, '\u{17b4}'..='\u{17d3}' | '\u{17dd}' | '\u{200c}' | '\u{200d}')
}
fn closing(c: char) -> bool {
matches!(c, 'ៗ' | '។' | '៕' | '៖' | '៘' | '៚' | ',' | '.' | ':' | ';' | '!' | '?' | ')' | ']' | '}' | '»' | '”')
}

#[derive(Default)]
struct Cache {
values: HashMap<Box<str>, Arc<[usize]>>,
bytes: usize,
}
// A bounded cache avoids recomputing unchanged paragraphs during whole-story recomposition.
// Scoring happens outside the lock; oversized inputs are computed but never retained.
fn boundaries(text: &str) -> Arc<[usize]> {
const MAX_BYTES: usize = 4 << 20;
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
if let Some(found) = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).values.get(text) {
return found.clone();
}
let breaks: Arc<[usize]> = if craft_khmer::data_available() {
craft_khmer::line_breaks(text).into()
} else {
text.char_indices().filter_map(|(i, c)| (c == '\u{200b}').then_some(i + c.len_utf8())).collect()
};
let size = text.len().saturating_add(std::mem::size_of_val(breaks.as_ref()));
if size <= MAX_BYTES {
let mut cache = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.bytes.saturating_add(size) > MAX_BYTES {
cache.values.clear();
cache.bytes = 0;
}
if !cache.values.contains_key(text) {
cache.values.insert(text.into(), breaks.clone());
cache.bytes += size;
}
}
breaks
}

/// Letter spacing never reaches Khmer letters or the glyphs of a shaped cluster, including across
/// an authored control between another script and Khmer. Other text is not affected.
pub(crate) fn letter_gap(line: &[Glyph], i: usize) -> bool {
let khmer_at = |j: usize| line.get(j).is_some_and(|g| character(g.ch));
let (Some(_), Some(next)) = (line.get(i), line.get(i + 1)) else { return false };
!khmer_at(i) && !khmer_at(i + 1) && !(matches!(next.ch, '\u{200b}' | '\u{2060}') && khmer_at(i + 2))
}
/// How the breaker rates slack on a justified Khmer line: lines without a real space stay short,
/// so a short line costs in proportion to its text rather than being impossible to rate. This is
/// never applied to a glyph's width.
pub(crate) fn slack_unit(g: &Glyph) -> f64 {
if character(g.ch) { g.adv * 0.05 } else { 0.0 }
}

/// Model and authored opportunities must also be source-grapheme and shaped-cluster edges.
/// Hard exclusions remain separate from dictionary decisions, allowing cluster-safe emergency
/// wrapping of overlong unknown words without overriding WJ, punctuation or No Break.
pub(crate) fn apply(glyphs: &mut [Glyph], text: &str, range: Range<usize>) {
let Some(span) = text.get(range.clone()) else { return };
if !span.chars().any(character) {
return;
}
let breaks = boundaries(span);
let clusters: Vec<_> = span.grapheme_indices(true).map(|(i, _)| i).collect();
for i in 0..glyphs.len().saturating_sub(1) {
let (left, right) = glyphs.split_at_mut(i + 1);
let (Some(g), Some(next)) = (left.last_mut(), right.first()) else { continue };
// An authored control has no width, so letter spacing has nothing to work with.
if matches!(g.ch, '\u{200b}' | '\u{2060}') {
g.space = 0.0;
}
if next.byte < range.start || next.byte >= range.end || g.list_label || next.list_label {
continue;
}
if next.byte == g.byte {
if character(g.ch) || character(next.ch) {
g.break_after = Some(false);
}
continue;
}
let at = next.byte - range.start;
let before = span.get(..at).and_then(|s| s.chars().next_back());
let after = span.get(at..).and_then(|s| s.chars().next());
let (Some(before), Some(after)) = (before, after) else { continue };
let khmer_edge =
character(before) || character(after) || matches!(before, '\u{200b}' | '\u{2060}') || matches!(after, '\u{200b}' | '\u{2060}');
if !khmer_edge {
continue;
}
let forbidden = clusters.binary_search(&at).is_err()
|| matches!(before, '\u{17d2}' | '័')
|| before == '\u{2060}'
|| after == '\u{2060}'
|| after == '\u{200b}'
|| mark(after)
|| closing(after)
|| matches!(before, '(' | '[' | '{' | '«' | '“');
if forbidden {
g.break_after = Some(false);
} else if g.break_after != Some(false) && !g.no_break && !next.no_break && !g.locked_advance && breaks.binary_search(&at).is_ok() {
g.break_after = Some(true);
}
}
}
Loading