Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
19c1231
PF1: spelling on the shared engine and the shared spelling review
sungkhum Nov 3, 2026
2adc1b4
Merge remote-tracking branch 'origin/main' into feat/khmer-proofreading
sungkhum Nov 3, 2026
ae317b0
PF1: name pack languages in spelling results, and report the panel's …
sungkhum Nov 4, 2026
b648167
Merge remote-tracking branch 'origin/main' into feat/khmer-proofreading
sungkhum Nov 4, 2026
d6a61be
text selection: draw every glyph of a selected cluster
sungkhum Nov 4, 2026
26d0f3d
Apply review fixes
aiongg Nov 4, 2026
a9efdaf
Merge branch 'fix/selected-cluster-marks' of https://github.com/sungk…
sungkhum Nov 4, 2026
a2cf738
PF1: a spelling panel with real buttons, and underlines on the page w…
sungkhum Nov 4, 2026
d57d4e6
Merge remote-tracking branch 'origin/main' into feat/khmer-proofreading
sungkhum Nov 4, 2026
be9bbf4
PF1: compare the review marks' test by where they start
sungkhum Nov 4, 2026
c1b8929
PF1: size the Spelling panel to its six suggestions
sungkhum Nov 4, 2026
f35a63a
PF1: spelling corrections that keep the selection, and reviews that f…
sungkhum Nov 4, 2026
206b31c
Merge remote-tracking branch 'origin/main' into feat/khmer-proofreading
sungkhum Nov 5, 2026
0b0ce79
Merge remote-tracking branch 'origin/main' into feat/khmer-proofreading
sungkhum Nov 5, 2026
3ade74f
PF2: pin craft-khmer and craft-proof to the current craft-libs#551
sungkhum Nov 5, 2026
e0f19be
PF3: keep the view on a word a review scrolled to
sungkhum Nov 5, 2026
ad4f537
Merge remote-tracking branch 'origin/main' into feat/khmer-proofreading
sungkhum Nov 5, 2026
122d90f
PF4: a style edit asks a spelling review to check again
sungkhum Nov 5, 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) | Not in any build yet: the pinned shared crate refuses `CRAFT_KHMER_DIR` until maintainers classify the data. Then an explicit input with source and canonical hashes verified by that 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
38 changes: 38 additions & 0 deletions Cargo.lock

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

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ 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 = "0b478944aa27a5bef1f51508c57bd236f272326d", version = "=0.1.0" }
# Shared proofing: spelling, suggestions and Khmer preferred forms for each language's dictionary.
craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "0b478944aa27a5bef1f51508c57bd236f272326d", version = "=0.1.0" }
# The shared spelling review: the Spelling panel and its right-click section.
craft-proof-ui = { git = "https://github.com/storytold/craft-libs", rev = "12c2eb7e9db0bc4215ff034f7ad53ea83cd35b39", 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
40 changes: 40 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,43 @@ 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, and craft-khmer currently refuses that setting, pending
maintainer classification of the data; no DesignCraft build contains it until then.
See the craft-khmer README and PROVENANCE.md for provenance and its limits, including the
historical Unicode notice for the CLDR-derived names in the frequency input.
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.
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m

- **2026-11-04** · Striped strokes retain out-and-back cubic segments whose second control point is away from the coincident endpoints and first control point. A geometry regression checks the offset extent and both stripe bands. No readiness percentage change.
- **2026-11-04** · Esperanto is a text language (Character › Language, `type.char {language: "Esperanto"}`, IDML `$ID/Esperanto`): tag `eo`, English-style quotes; not hyphenated yet (#1201). No readiness percentage change.
- **2026-11-03 · PF1:** Spelling moves to the shared `craft-proof`, with the shared Spelling panel, underlines on the page while it shows and right-click suggestions; Khmer is checked with its optional data. Dynamic Spelling proofs a story again only when its text, styles or the user dictionary change. See [spelling.md](docs/spelling.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.
Expand Down
32 changes: 30 additions & 2 deletions crates/compose/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5623,6 +5623,23 @@ fn band_quad(x0: f64, x1: f64, top: f64, bot: f64) -> [Point; 4] {
[Point::new(x0, top), Point::new(x1, top), Point::new(x1, bot), Point::new(x0, bot)]
}

/// The glyphs of line `l` in the selected range `s..e`, by index. A cluster's other glyphs (subscripts,
/// marks, and in right-to-left text the base that follows its first mark) carry no length of their
/// own but belong to it; generated ones (a list label, a line-end hyphen) are never selected.
fn selected_cluster_glyphs(l: &Line, s: usize, e: usize) -> impl Iterator<Item = usize> {
// The byte of the cluster being walked, once its first glyph has been found.
let mut cluster = None;
l.glyphs.iter().enumerate().filter_map(move |(i, g)| {
cluster = if g.len > 0 { Some(g.byte) } else { cluster.filter(|byte| *byte == g.byte) };
(g.byte >= s && g.byte < e && cluster == Some(g.byte)).then_some(i)
})
}

/// The glyphs to draw as selected for the source range `s..e`, by index into `l.glyphs`.
pub fn selected_glyphs(l: &Line, s: usize, e: usize) -> Vec<usize> {
selected_cluster_glyphs(l, s, e).filter(|i| l.glyphs.get(*i).is_some_and(|g| g.visible)).collect()
}

/// Highlight rectangles for bytes `s..e` on `l`. `past_end` extends the line-end caret when the
/// selection continues onto the next line. Stacked rows (warichu) each get their own band.
pub fn highlight_quads(l: &Line, s: usize, e: usize, past_end: bool) -> Vec<[Point; 4]> {
Expand Down Expand Up @@ -5664,12 +5681,23 @@ pub fn highlight_quads(l: &Line, s: usize, e: usize, past_end: bool) -> Vec<[Poi
let top = l.baseline - l.ascent;
let bot = l.baseline + l.descent;
if l.glyphs.iter().any(|g| g.rtl) {
let mut spans: Vec<(f64, f64)> = selected.iter().map(|g| (g.x, g.x + g.adv)).collect();
// By every glyph of a cluster: the first may be a mark with no advance of its own. Selected
// text is one band unless other text lies between: joined letters needn't touch, and a
// selection across a change of direction lands in separate places.
let chosen: std::collections::HashSet<usize> = selected_cluster_glyphs(l, s, e).collect();
let others: Vec<f64> = l
.glyphs
.iter()
.enumerate()
.filter(|(i, g)| !chosen.contains(i) && g.visible && g.adv > 0.0 && g.byte < l.range.end)
.map(|(_, g)| g.x + g.adv / 2.0)
.collect();
let mut spans: Vec<(f64, f64)> = chosen.iter().filter_map(|i| l.glyphs.get(*i)).map(|g| (g.x, g.x + g.adv)).collect();
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut merged: Vec<(f64, f64)> = Vec::new();
for (a, b) in spans {
match merged.last_mut() {
Some(m) if a <= m.1 + 0.5 => m.1 = m.1.max(b),
Some(m) if !others.iter().any(|x| *x > m.1 && *x < a) => m.1 = m.1.max(b),
_ => merged.push((a, b)),
}
}
Expand Down
81 changes: 81 additions & 0 deletions crates/compose/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8647,3 +8647,84 @@ fn cross_reference_text_breaks_at_its_spaces() {
}
}
}

/// Selected text is redrawn inverted glyph by glyph, so a mark left out vanishes into the highlight.
#[test]
fn a_selected_cluster_includes_its_marks_and_nothing_generated() {
use designcraft_fonts::testing::font_mapping;
const FAMILY: &str = "DC Test Selected Clusters";
static ADDED: std::sync::Once = std::sync::Once::new();
ADDED.call_once(|| {
let glyphs = [('ក', 'a'), ('្', 'b'), ('ត', 'c'), ('ី', 'd'), ('ខ', 'e'), (' ', ' '), ('•', 'f'), ('-', '-'), ('x', 'x')];
designcraft_fonts::FontDb::global().add_font(font_mapping(FAMILY, &glyphs).unwrap());
});
let compose = |text: &str, para: ParaAttrs, width: f64| {
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width, 400.0), para);
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(FAMILY.into()));
compose_story(&d, sid, &ComposeOptions::default())
};
// One cluster of several glyphs, then a separate one.
let text = "ក្តី ខ";
let space = text.find(' ').unwrap();
let cs = compose(text, ParaAttrs::default(), 400.0);
let line = all_lines(&cs)[0];
let cluster: Vec<_> = line.glyphs.iter().enumerate().filter(|(_, g)| g.byte == 0).map(|(i, _)| i).collect();
assert!(cluster.len() > 1, "exercise a cluster of several glyphs");
assert!(line.glyphs[cluster[1]].len == 0, "its later glyphs carry no length of their own");
let word: Vec<_> = line.glyphs.iter().enumerate().filter(|(_, g)| g.byte < space).map(|(i, _)| i).collect();
assert_eq!(selected_glyphs(line, 0, space), word, "every glyph of the word");
let first_len = line.glyphs[cluster[0]].len;
assert_eq!(selected_glyphs(line, 0, first_len), cluster, "the whole cluster, marks included");
assert!(selected_glyphs(line, space, text.len()).iter().all(|i| line.glyphs[*i].byte >= space), "another cluster's glyphs stay out");
assert!(selected_glyphs(line, space + 1, space + 1).is_empty(), "an empty range selects nothing");
assert_eq!(selected_glyphs(line, 0, text.len()).len(), line.glyphs.iter().filter(|g| g.visible).count(), "the whole line");
// A list label is generated: it isn't selected with the first letter it sits beside.
let bulleted = ParaAttrs { list_type: Some(designcraft_doc::ListType::Bullets), ..Default::default() };
let cs = compose("xx", bulleted, 400.0);
let line = all_lines(&cs)[0];
assert!(line.glyphs.iter().any(|g| g.len == 0), "exercise a generated label");
let selected = selected_glyphs(line, 0, 2);
assert_eq!(selected.len(), line.glyphs.iter().filter(|g| g.len > 0).count(), "only the text");
assert!(selected.iter().all(|i| line.glyphs[*i].len > 0));
}

/// In right-to-left text a cluster can start with a mark that has no advance: the highlight must
/// still cover its base, or the selected letters are drawn inverted on the bare page.
#[test]
fn the_highlight_covers_every_glyph_of_a_right_to_left_cluster() {
use designcraft_fonts::testing::font_mapping;
const FAMILY: &str = "DC Test Selected Arabic";
static ADDED: std::sync::Once = std::sync::Once::new();
ADDED.call_once(|| {
let glyphs = [('\u{0628}', 'a'), ('\u{064e}', 'b'), ('\u{0644}', 'c'), (' ', ' '), ('x', 'x'), ('y', 'y')];
designcraft_fonts::FontDb::global().add_font(font_mapping(FAMILY, &glyphs).unwrap());
});
let text = "\u{0628}\u{064e}\u{0644}\u{064e} \u{0628}\u{064e}\u{0644}\u{064e}";
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 600.0, 400.0), ParaAttrs::default());
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(FAMILY.into()));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let line = all_lines(&cs)[0];
assert!(line.glyphs.iter().any(|g| g.rtl), "exercise right-to-left text");
assert!(line.glyphs.iter().any(|g| g.len == 0 && g.byte < text.len() && g.adv > 0.0), "a cluster's later glyph has its own width");
let quads = highlight_quads(line, 0, text.len(), false);
assert_eq!(quads.len(), 1, "one line of one direction is one band: {quads:?}");
for g in line.glyphs.iter().filter(|g| g.byte < text.len() && g.adv > 0.0) {
let covered = quads.iter().any(|q| {
let (x0, x1) = (q[0].x.min(q[1].x), q[0].x.max(q[1].x));
x0 <= g.x + 1e-6 && g.x + g.adv <= x1 + 1e-6
});
assert!(covered, "glyph at byte {} ({:.2}+{:.2}) outside {quads:?}", g.byte, g.x, g.adv);
}
// A selection across a change of direction lands in two places, and not on what lies between.
let text = "xy \u{0628}\u{0644}\u{0628} yx";
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 600.0, 400.0), ParaAttrs::default());
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(FAMILY.into()));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let line = all_lines(&cs)[0];
let first_arabic_end = "xy \u{0628}".len();
let quads = highlight_quads(line, 1, first_arabic_end, false);
assert_eq!(quads.len(), 2, "{quads:?}");
let last = line.glyphs.iter().find(|g| g.byte == "xy \u{0628}\u{0644}".len()).unwrap();
let middle = last.x + last.adv / 2.0;
assert!(quads.iter().all(|q| middle < q[0].x.min(q[1].x) || middle > q[0].x.max(q[1].x)), "an unselected letter stays out");
}
1 change: 1 addition & 0 deletions crates/doc/src/attrs/language.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ pub fn language_tag(language: &str) -> Option<&'static str> {
"turkish" => "tr",
"japanese" => "ja",
"korean" => "ko",
"khmer" | "cambodian" => "km",
"arabic" => "ar",
"persian" | "farsi" => "fa",
"urdu" => "ur",
Expand Down
4 changes: 4 additions & 0 deletions crates/doc/src/attrs/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ fn language_names_map_to_bcp47_tags() {
assert_eq!(language_tag("Georgian"), Some("ka"));
assert_eq!(language_tag("Japanese"), Some("ja"));
assert_eq!(language_tag("Korean"), Some("ko"));
assert_eq!(language_tag("Khmer"), Some("km"));
assert_eq!(language_tag("$ID/Khmer"), Some("km"));
assert_eq!(language_tag("Cambodian"), Some("km"));
assert_eq!(language_tag("km_KH"), Some("km-KH"));
assert_eq!(language_tag("Chinese"), Some("zh"));
assert_eq!(language_tag("Chinese: Simplified"), Some("zh-Hans"));
assert_eq!(language_tag("Chinese: Traditional"), Some("zh-Hant"));
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ workspace = true
[dependencies]
craft-print = { workspace = true }
craft-docpath = { workspace = true }
craft-khmer = { workspace = true }
craft-proof = { workspace = true }
designcraft-images = { workspace = true }
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
Expand Down
Loading