mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-08 11:41:35 +00:00
* Arabic letters join across a change of formatting (#4093) Each run of a paragraph was shaped on its own, so a letter at a run's edge took the form of a word's start or end. A coloured letter stood apart from its word, and a word edited with Track Changes on read with a space in it (ال ذكاء). Runs are now shaped with the text shown on either side of them, through the shaper's pre- and post-context (fonts::ShapeContext). Runs left out of the layout (hidden text, deletions in a final view) are skipped, so letters join what is on the page. Only paragraphs with a joining script do the work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvG6E2QwHfTULQtWYgVEXk * #4093 review: no letter joins a drop cap formatted on its own A drop cap letter in a run of its own (coloured, say) was still passed to the next run as the text before it, so that run's first letter joined the drop cap, which is drawn apart from the word. The shown text now has a floor past the drop cap, and no run takes context from before it. The drop cap test also covers the letter in a run of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvG6E2QwHfTULQtWYgVEXk --------- Co-authored-by: Filip Strajnar <filip.strajnar@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
This commit is contained in:
9 files changed
+309
-14
No files matched your search
@@ -171,6 +171,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged.
|
||||
- 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged.
|
||||
- 2026-11-05: A Word template (`.dotx`, `.dotm`, `.dot`) opened from the file manager, the command line or the Finder starts a new, untitled document based on it, as Word's default verb for a template does (#3433). It opened the template itself, so Save (and AutoSave) wrote over it. The new document is attached to the template and Save asks where to write; a protected template asks for its password and still starts a new document. File › Open and a file dropped on the window still open a template itself, to edit it.
|
||||
- 2026-11-05: Arabic letters join across a change of formatting (gap 16, #4093). Each run was shaped on its own, so a letter at a run's edge took the form of a word's start or end: a coloured letter stood apart, and a word edited with Track Changes on read with a space in it (ال ذكاء). Each run is now shaped knowing the text shown on either side, past hidden or deleted text left out of the view, as for Syriac, Mongolian and the other joining scripts. Readiness estimates unchanged.
|
||||
- 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open.
|
||||
- 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged.
|
||||
- 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap.
|
||||
@@ -825,6 +826,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
|
||||
> 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged.
|
||||
> 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged.
|
||||
> 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093); recorded in the progress log, readiness unchanged.
|
||||
>
|
||||
> 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged.
|
||||
>
|
||||
|
||||
+55
-7
@@ -21,7 +21,7 @@ pub use fontdb::{
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::{Direction, ShapeOptions, ShapePlan, ShapePlanKey, Shaper, Tag, UnicodeBuffer};
|
||||
pub use kurbo::BezPath;
|
||||
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
|
||||
pub use script::{fallback_rank, is_emoji, joins_cursively, needs_layout_tables, script_fallbacks};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use ui_fallback::{
|
||||
@@ -185,13 +185,34 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
|
||||
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
|
||||
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
shape_in(face, text, features, map, ShapeContext::default())
|
||||
}
|
||||
|
||||
/// The text shown on either side of what is being shaped. Only a letter's form depends on it: the
|
||||
/// letters of an Arabic word join across a change of formatting, so each piece has to know what
|
||||
/// it continues (#4093). Nothing outside the shaped text is drawn or measured.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ShapeContext<'a> {
|
||||
pub before: &'a str,
|
||||
pub after: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> ShapeContext<'a> {
|
||||
/// This context for the part `r` of `text`: what's beyond `text` where `r` reaches its edge.
|
||||
fn at_edges_of(self, text: &str, r: &std::ops::Range<usize>) -> ShapeContext<'a> {
|
||||
ShapeContext { before: if r.start == 0 { self.before } else { "" }, after: if r.end == text.len() { self.after } else { "" } }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`shape`] of a piece of longer text (see [`ShapeContext`]).
|
||||
pub fn shape_in(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, around: ShapeContext<'_>) -> Vec<ShapedGlyph> {
|
||||
if !text.chars().any(is_rtl) {
|
||||
return shape_dir(face, text, features, &map, false);
|
||||
return shape_dir(face, text, features, &map, false, around);
|
||||
}
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
for (r, rtl) in direction_runs(text) {
|
||||
let Some(sub) = text.get(r.clone()) else { continue };
|
||||
let mut g = shape_run(face, sub, features, &map, rtl);
|
||||
let mut g = shape_run_in(face, sub, features, &map, rtl, around.at_edges_of(text, &r));
|
||||
for x in &mut g {
|
||||
x.cluster += r.start;
|
||||
}
|
||||
@@ -205,7 +226,19 @@ pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(cha
|
||||
/// mark positioning. Glyphs come back in logical order like [`shape`]: clusters in text order,
|
||||
/// each cluster's glyphs in the shaper's (visual) order.
|
||||
pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
||||
let g = shape_dir(face, text, features, &map, rtl);
|
||||
shape_run_in(face, text, features, map, rtl, ShapeContext::default())
|
||||
}
|
||||
|
||||
/// [`shape_run`] of a piece of longer text (see [`ShapeContext`]).
|
||||
pub fn shape_run_in(
|
||||
face: &FontFace,
|
||||
text: &str,
|
||||
features: &[Feature],
|
||||
map: impl Fn(char) -> char,
|
||||
rtl: bool,
|
||||
around: ShapeContext<'_>,
|
||||
) -> Vec<ShapedGlyph> {
|
||||
let g = shape_dir(face, text, features, &map, rtl, around);
|
||||
if !rtl {
|
||||
return g;
|
||||
}
|
||||
@@ -221,7 +254,14 @@ pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn
|
||||
groups.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
||||
fn shape_dir(
|
||||
face: &FontFace,
|
||||
text: &str,
|
||||
features: &[Feature],
|
||||
map: &impl Fn(char) -> char,
|
||||
rtl: bool,
|
||||
around: ShapeContext<'_>,
|
||||
) -> Vec<ShapedGlyph> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
@@ -234,6 +274,10 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch
|
||||
for (i, c) in sub.char_indices() {
|
||||
buf.add(map(c), (r.start + i) as u32);
|
||||
}
|
||||
// After the text: adding to a buffer clears what follows it.
|
||||
let around = around.at_edges_of(text, &r);
|
||||
buf.set_pre_context(around.before);
|
||||
buf.set_post_context(around.after);
|
||||
buf.guess_segment_properties();
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let plan = shape_plan(face, &shaper, &buf, features);
|
||||
@@ -386,7 +430,10 @@ mod tests {
|
||||
gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect()
|
||||
};
|
||||
let kept = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> {
|
||||
shape_dir(face, text, features, &|c| c, rtl).iter().map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)).collect()
|
||||
shape_dir(face, text, features, &|c| c, rtl, ShapeContext::default())
|
||||
.iter()
|
||||
.map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset))
|
||||
.collect()
|
||||
};
|
||||
let db = FontDb::global();
|
||||
let faces = [db.face(DEFAULT_FAMILY, "Regular"), db.face(DEFAULT_FAMILY, "Italic"), db.face("Source Sans 3", "Regular")];
|
||||
@@ -421,7 +468,8 @@ mod tests {
|
||||
for n in 0..3 * MAX_SHAPE_PLANS as u32 {
|
||||
let features = [feature_value(b"kern", n % 2), feature_value(b"liga", n)];
|
||||
let rtl = n % 3 == 0;
|
||||
let kept: Vec<(u32, i32)> = shape_dir(&face, "office", &features, &|c| c, rtl).iter().map(|g| (g.gid, g.x_advance)).collect();
|
||||
let kept: Vec<(u32, i32)> =
|
||||
shape_dir(&face, "office", &features, &|c| c, rtl, ShapeContext::default()).iter().map(|g| (g.gid, g.x_advance)).collect();
|
||||
assert_eq!(kept, fresh(&features, rtl), "{n}");
|
||||
assert!(kept_shape_plans() <= MAX_SHAPE_PLANS, "{}", kept_shape_plans());
|
||||
}
|
||||
|
||||
@@ -18,6 +18,28 @@ fn real_script(c: char) -> Option<Script> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `c` belongs to a script whose letters join their neighbours (Arabic, Syriac, Mongolian…),
|
||||
/// so a letter's form depends on the text on either side of it.
|
||||
pub fn joins_cursively(c: char) -> bool {
|
||||
c >= '\u{0600}'
|
||||
&& matches!(
|
||||
c.script(),
|
||||
Script::Adlam
|
||||
| Script::Arabic
|
||||
| Script::Chorasmian
|
||||
| Script::Hanifi_Rohingya
|
||||
| Script::Mandaic
|
||||
| Script::Manichaean
|
||||
| Script::Mongolian
|
||||
| Script::Nko
|
||||
| Script::Old_Uyghur
|
||||
| Script::Phags_Pa
|
||||
| Script::Psalter_Pahlavi
|
||||
| Script::Sogdian
|
||||
| Script::Syriac
|
||||
)
|
||||
}
|
||||
|
||||
/// Maximal single-script byte ranges of `text`. Characters of no particular script join the run
|
||||
/// before them (or, at the start, the first run), so the ranges cover `text` exactly.
|
||||
pub(crate) fn script_runs(text: &str) -> Vec<std::ops::Range<usize>> {
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::chars::{
|
||||
use super::grid::east_asian;
|
||||
use super::model::{ClKind, Cluster, Enclosed, Glyph, ParaEnv, RubyLayout, StyleRun, SymbolRun};
|
||||
use super::{Arc, Document, FaceRef, NumeralMode, ResolvedChar, RubyAlign, SOFT_HYPHEN};
|
||||
use wordcraft_fonts::ShapeContext;
|
||||
|
||||
pub(super) struct Builder<'a> {
|
||||
pub(super) env: &'a ParaEnv<'a>,
|
||||
@@ -226,6 +227,19 @@ impl<'a> Builder<'a> {
|
||||
|
||||
/// `dir`: the bidi levels `text` is shaped by (see [`Dir`]).
|
||||
pub(super) fn shape_with(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>, dir: Dir<'_>) {
|
||||
self.shape_around(text, base, rc, kind_override, dir, ShapeContext::default());
|
||||
}
|
||||
|
||||
/// `around`: the text shown beside `text`, which its first and last letters may join.
|
||||
pub(super) fn shape_around(
|
||||
&mut self,
|
||||
text: &str,
|
||||
base: usize,
|
||||
rc: &Arc<ResolvedChar>,
|
||||
kind_override: Option<ClKind>,
|
||||
dir: Dir<'_>,
|
||||
around: ShapeContext<'_>,
|
||||
) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -374,10 +388,15 @@ impl<'a> Builder<'a> {
|
||||
let from = usize::from(rc.kerned());
|
||||
let to = if ligatures.is_some() { all.len() } else { 1 };
|
||||
let features: &[wordcraft_fonts::Feature] = all.get(from..to).unwrap_or_default();
|
||||
// Letters join across a change of font or direction inside the run too.
|
||||
let around = ShapeContext {
|
||||
before: if a == 0 { around.before } else { text.get(..a).unwrap_or("") },
|
||||
after: if b == text.len() { around.after } else { text.get(b..).unwrap_or("") },
|
||||
};
|
||||
let shaped = if use_levels {
|
||||
wordcraft_fonts::shape_run(&st.face, shape_text, features, map, key.rtl)
|
||||
wordcraft_fonts::shape_run_in(&st.face, shape_text, features, map, key.rtl, around)
|
||||
} else {
|
||||
wordcraft_fonts::shape(&st.face, shape_text, features, map)
|
||||
wordcraft_fonts::shape_in(&st.face, shape_text, features, map, around)
|
||||
};
|
||||
let k = st.size / st.face.upem.max(1.0) as f32;
|
||||
let hscale = rc.scale / 100.0;
|
||||
|
||||
@@ -6,6 +6,8 @@ use super::justify::hyphenation_points;
|
||||
use super::lines::break_lines;
|
||||
use super::model::{ClKind, Cluster, Enclosed, Glyph, Label, LabelRun, ParaEnv, RubyLayout, undrawable_bullet};
|
||||
use super::proof::{ProofRun, is_list_item, proof_issues};
|
||||
use wordcraft_fonts::ShapeContext;
|
||||
|
||||
use super::{
|
||||
Arc, COLUMN_BREAK, CharProps, Document, FIELD_SHADING, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, PTabAlign, PTabBase, ParaLayout,
|
||||
Paragraph, ResolvedChar, ResolvedPara, TabLeader, field_code_text, field_text_on, reference_link,
|
||||
@@ -33,6 +35,8 @@ struct Cx<'a> {
|
||||
math_props: wordcraft_doc::math::MathProps,
|
||||
/// The first character, if it is to be a drop cap.
|
||||
drop_first: Option<char>,
|
||||
/// The text on the page, when its letters can join across runs (see [`shown_text`]).
|
||||
shown: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a cluster for an object or a break goes in the paragraph: its text, style and first glyph.
|
||||
@@ -83,6 +87,10 @@ struct Shaped<'a> {
|
||||
bare: Vec<std::ops::Range<usize>>,
|
||||
/// Each run's proofing language and whether it is checked (spelling, grammar, hyphenation).
|
||||
runs: Vec<ProofRun>,
|
||||
/// How far into the shown text ([`Cx::shown`]) the runs so far have come.
|
||||
shown_at: usize,
|
||||
/// Where in the shown text a letter's context may begin: past the drop cap, once one is drawn.
|
||||
shown_floor: usize,
|
||||
}
|
||||
|
||||
fn is_special(c: char) -> bool {
|
||||
@@ -186,6 +194,8 @@ impl<'a> Shaped<'a> {
|
||||
left: Vec::new(),
|
||||
bare: Vec::new(),
|
||||
runs: Vec::new(),
|
||||
shown_at: 0,
|
||||
shown_floor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,24 +246,44 @@ impl<'a> Shaped<'a> {
|
||||
|
||||
/// A run shown: its text, with the breaks and objects in it as clusters of their own.
|
||||
fn visible_run(&mut self, cx: &Cx, (rc, props): (&Arc<ResolvedChar>, &CharProps), range: &std::ops::Range<usize>, text: &str) {
|
||||
let shown_start = self.shown_at;
|
||||
let around = self.next_shown(cx, text.len());
|
||||
let mut seg = self.drop_cap_letter(cx, rc, text, range.start);
|
||||
if seg > 0 {
|
||||
// A drop cap is drawn apart from its word: nothing after it joins it, in this run or
|
||||
// in the runs after it (the letter can be formatted on its own).
|
||||
self.shown_floor = shown_start.saturating_add(seg);
|
||||
}
|
||||
// The first piece continues what came before the run. After a drop cap or a break nothing
|
||||
// joins: that letter is drawn apart, and a break is no letter. The same goes for the end.
|
||||
let mut before = if seg == 0 { around.before } else { "" };
|
||||
for (i, c) in text.char_indices() {
|
||||
if i < seg || !is_special(c) {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = text.get(seg..i) {
|
||||
self.b.shape(s, range.start + seg, rc, None);
|
||||
self.b.shape_around(s, range.start + seg, rc, None, Dir::Para, ShapeContext { before, after: "" });
|
||||
}
|
||||
before = "";
|
||||
seg = i + c.len_utf8();
|
||||
let style = self.b.style(rc, None, false);
|
||||
let slot = Slot { start: range.start + i, end: range.start + i + c.len_utf8(), style, g: self.b.glyphs.len() as u32 };
|
||||
self.special(cx, c, slot, (rc, props));
|
||||
}
|
||||
if let Some(s) = text.get(seg..) {
|
||||
self.b.shape(s, range.start + seg, rc, None);
|
||||
self.b.shape_around(s, range.start + seg, rc, None, Dir::Para, ShapeContext { before, after: around.after });
|
||||
}
|
||||
}
|
||||
|
||||
/// The text shown on either side of the next `len` bytes shown, and moves past them. What
|
||||
/// comes before starts after a drop cap ([`Shaped::shown_floor`]).
|
||||
fn next_shown<'c>(&mut self, cx: &'c Cx, len: usize) -> ShapeContext<'c> {
|
||||
let Some(shown) = &cx.shown else { return ShapeContext::default() };
|
||||
let start = self.shown_at;
|
||||
self.shown_at = start.saturating_add(len);
|
||||
ShapeContext { before: shown.get(self.shown_floor.min(start)..start).unwrap_or(""), after: shown.get(self.shown_at..).unwrap_or("") }
|
||||
}
|
||||
|
||||
/// Drop cap: the first character, sized so its capital spans `lines` lines. Returns how much
|
||||
/// of the run's text it took.
|
||||
fn drop_cap_letter(&mut self, cx: &Cx, rc: &Arc<ResolvedChar>, text: &str, start: usize) -> usize {
|
||||
@@ -593,6 +623,26 @@ fn joined_cluster_starts(p: &Paragraph, clusters: &[Cluster]) -> Vec<usize> {
|
||||
joined
|
||||
}
|
||||
|
||||
/// The paragraph's text without the runs left out of the layout, if it has letters that join their
|
||||
/// neighbours: a letter joins the next one on the page, across a change of formatting and past
|
||||
/// text that isn't shown (#4093). `None` for every other paragraph, which don't need it.
|
||||
fn shown_text(cx: &Cx) -> Option<String> {
|
||||
let text = &cx.p.text;
|
||||
if text.is_ascii() || !text.chars().any(wordcraft_fonts::joins_cursively) {
|
||||
return None;
|
||||
}
|
||||
let mut shown = String::with_capacity(text.len());
|
||||
for (range, props) in cx.p.run_ranges() {
|
||||
let rc = cx.res.char(props);
|
||||
if !left_out(cx.env.doc, &rc, cx.env.show_hidden, cx.env.hide_deleted)
|
||||
&& let Some(run) = text.get(range)
|
||||
{
|
||||
shown.push_str(run);
|
||||
}
|
||||
}
|
||||
Some(shown)
|
||||
}
|
||||
|
||||
/// Lay out one paragraph.
|
||||
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let doc = env.doc;
|
||||
@@ -609,7 +659,9 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
shown: Vec::new(),
|
||||
substituted: Vec::new(),
|
||||
};
|
||||
let cx = Cx { p, env, rp: &rp, res, math_props: doc.settings.math.clone().unwrap_or_default(), drop_first: drop_cap_char(p, &rp) };
|
||||
let mut cx =
|
||||
Cx { p, env, rp: &rp, res, math_props: doc.settings.math.clone().unwrap_or_default(), drop_first: drop_cap_char(p, &rp), shown: None };
|
||||
cx.shown = shown_text(&cx);
|
||||
let mut sh = Shaped::new(b, env.eq_number);
|
||||
let mark_rc = cx.res.char(&p.mark);
|
||||
let mark_style = sh.b.style(&mark_rc, None, false);
|
||||
|
||||
@@ -639,3 +639,149 @@ fn list_label_after_leading_page_break_uses_its_text_lines_alignment() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph ids of each letter of paragraph 0 as laid out (marks of runs left out have none).
|
||||
fn letters(l: &DocLayout) -> Vec<Vec<u32>> {
|
||||
let (pl, _) = first_line(l, 0);
|
||||
pl.clusters.iter().filter(|c| c.g1 > c.g0).map(|c| pl.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect()).collect()
|
||||
}
|
||||
|
||||
/// `text` as one right-to-left paragraph with `format` applied to each `(start, end)` byte range.
|
||||
fn formatted(text: &str, splits: &[(usize, usize)], format: &dyn Fn(&mut wordcraft_doc::props::CharProps)) -> Document {
|
||||
let mut d = doc_of(&[(text, true)]);
|
||||
for &(start, end) in splits {
|
||||
d.format_range(&Pos::body(0, start), &Pos::body(0, end), format).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
const BEH: usize = 'ب'.len_utf8();
|
||||
|
||||
fn red(c: &mut wordcraft_doc::props::CharProps) {
|
||||
c.color = Some(wordcraft_doc::props::TextColor::Rgb(wordcraft_doc::props::Rgb(0xC0, 0, 0)));
|
||||
}
|
||||
|
||||
/// The reporter's symptom: after a change of formatting inside a word, the letters on either side
|
||||
/// took the forms of the end and the start of a word, which reads as a space in it (#4093).
|
||||
#[test]
|
||||
fn letters_join_across_a_change_of_formatting() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let word = "بببب";
|
||||
let whole = letters(&lay(&doc_of(&[(word, true)])));
|
||||
assert_eq!(whole.len(), 4, "one cluster per letter: {whole:?}");
|
||||
assert!(whole.first() != whole.get(1) && whole.get(1) != whole.last(), "initial, medial and final forms: {whole:?}");
|
||||
// A letter of its own at each place, then the two middle letters, then every other letter.
|
||||
let pieces: [&[(usize, usize)]; 6] =
|
||||
[&[(0, BEH)], &[(BEH, 2 * BEH)], &[(2 * BEH, 3 * BEH)], &[(3 * BEH, 4 * BEH)], &[(BEH, 3 * BEH)], &[(0, BEH), (2 * BEH, 3 * BEH)]];
|
||||
for runs in pieces {
|
||||
let d = formatted(word, runs, &red);
|
||||
assert!(d.para_at(&Pos::body(0, 0)).unwrap().runs.len() > 1, "{runs:?} split the run");
|
||||
assert_eq!(letters(&lay(&d)), whole, "split at {runs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A letter joins what is on the page: with a tracked deletion shown, the deleted letter; with it
|
||||
/// left out, the letter past it. The same goes for hidden text.
|
||||
#[test]
|
||||
fn letters_join_past_text_left_out_of_the_layout() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let (three, two) = (letters(&lay(&doc_of(&[("ببب", true)]))), letters(&lay(&doc_of(&[("بب", true)]))));
|
||||
assert_eq!((three.len(), two.len()), (3, 2));
|
||||
let middle = [(BEH, 2 * BEH)];
|
||||
|
||||
let deleted = formatted("ببب", &middle, &|c| c.del = Some(0));
|
||||
assert_eq!(letters(&lay(&deleted)), three, "deletion shown: it joins both neighbours");
|
||||
let mut c = LayoutCache::new();
|
||||
let final_text = layout(&deleted, &mut c, &LayoutOptions { hide_deleted: true, ..LayoutOptions::default() });
|
||||
assert_eq!(letters(&final_text), two, "deletion left out: its neighbours join each other");
|
||||
|
||||
let hidden = formatted("ببب", &middle, &|c| c.hidden = Some(true));
|
||||
assert_eq!(letters(&lay(&hidden)), two, "hidden text left out: its neighbours join each other");
|
||||
let mut c = LayoutCache::new();
|
||||
let shown = layout(&hidden, &mut c, &LayoutOptions { show_hidden: true, ..LayoutOptions::default() });
|
||||
assert_eq!(letters(&shown), three, "hidden text shown: it joins both neighbours");
|
||||
}
|
||||
|
||||
/// A mark in a run of its own (a coloured vowel sign) doesn't stop the letters around it joining.
|
||||
#[test]
|
||||
fn letters_join_past_a_mark_in_a_run_of_its_own() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let word = "بَب";
|
||||
let whole = letters(&lay(&doc_of(&[(word, true)])));
|
||||
let split = letters(&lay(&formatted(word, &[(BEH, BEH + 'َ'.len_utf8())], &red)));
|
||||
assert_eq!(split.last(), whole.last(), "the letter after the mark stays joined");
|
||||
// Together with the mark, the letter before it has more glyphs, in the shaper's order.
|
||||
let (before, with_mark) = (split.first().unwrap(), whole.first().unwrap());
|
||||
assert!(before.iter().all(|g| with_mark.contains(g)), "and the letter before it: {before:?} {with_mark:?}");
|
||||
}
|
||||
|
||||
/// What can't join still doesn't: a tab between the letters, in one run or at the edge of a run, a
|
||||
/// Latin neighbour, and a letter beside a drop cap, which is drawn apart from the word.
|
||||
#[test]
|
||||
fn letters_stay_apart_where_nothing_joins_them() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let alone = letters(&lay(&doc_of(&[("ب", true)])));
|
||||
assert_eq!(alone.len(), 1);
|
||||
let beh = alone.first();
|
||||
let tabbed = "ب\tب";
|
||||
for d in [doc_of(&[(tabbed, true)]), formatted(tabbed, &[(0, BEH)], &red), formatted(tabbed, &[(BEH + 1, tabbed.len())], &red)] {
|
||||
let got = letters(&lay(&d));
|
||||
assert_eq!((got.first(), got.last()), (beh, beh), "across a tab: {got:?}");
|
||||
}
|
||||
let got = letters(&lay(&formatted("xب", &[(0, 1)], &red)));
|
||||
assert_eq!(got.last(), beh, "after a Latin letter: {got:?}");
|
||||
|
||||
// The drop cap in the word's run, and formatted on its own (a run of its own).
|
||||
let two = letters(&lay(&doc_of(&[("بب", true)])));
|
||||
for mut d in [doc_of(&[("ببب", true)]), formatted("ببب", &[(0, BEH)], &red)] {
|
||||
let at = Pos::body(0, 0);
|
||||
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.drop_cap = Some(2)).unwrap();
|
||||
let runs = d.para_at(&at).unwrap().runs.len();
|
||||
let got = letters(&lay(&d));
|
||||
assert_eq!(got.get(got.len().saturating_sub(2)..), two.get(..), "{runs} run(s): the letters after a drop cap start a word: {got:?} {two:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shaper_takes_a_letters_form_from_the_text_around_it() {
|
||||
use wordcraft_fonts::{ShapeContext, shape_run_in};
|
||||
let Some(face) = arabic_face() else {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
};
|
||||
let form = |around| cluster_glyphs(&shape_run_in(&face, "ب", &[], |c| c, true, around));
|
||||
let isolated = form(ShapeContext::default());
|
||||
let joined = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true));
|
||||
assert_eq!(form(ShapeContext { before: "", after: "ب" }).first(), joined.first(), "initial");
|
||||
assert_eq!(form(ShapeContext { before: "ب", after: "ب" }).first(), joined.get(1), "medial");
|
||||
assert_eq!(form(ShapeContext { before: "ب", after: "" }).first(), joined.get(2), "final");
|
||||
// A vowel sign is transparent, and ZWNJ and a Latin letter stop the join.
|
||||
assert_eq!(form(ShapeContext { before: "بَ", after: "" }).first(), joined.get(2), "past a mark");
|
||||
assert_eq!(form(ShapeContext { before: "ب\u{200C}", after: "" }), isolated, "after ZWNJ");
|
||||
assert_eq!(form(ShapeContext { before: "a", after: "b" }), isolated, "between Latin letters");
|
||||
// Only the letter's form depends on the context: nothing of it is drawn.
|
||||
assert_eq!(form(ShapeContext { before: "ببب", after: "ببب" }).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_that_join_their_letters_are_told_apart() {
|
||||
use wordcraft_fonts::joins_cursively;
|
||||
for c in ['ب', 'پ', 'ܐ', 'ᠠ', 'ߊ', '𞤀'] {
|
||||
assert!(joins_cursively(c), "{c}");
|
||||
}
|
||||
for c in ['a', 'é', '’', 'я', 'א', '中', ' ', '\t', '\u{FFFC}', '\u{200C}', 'َ'] {
|
||||
assert!(!joins_cursively(c), "{c:?}");
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -96,7 +96,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren
|
||||
| 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), radar charts are drawn (#1890); no combo/3D/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); mirrored Arabic UI landed (#1195); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 5–9 | [layout-parity.md](layout-parity.md) |
|
||||
| 16 | **Right-to-left completeness** (letters join across a change of formatting since #4093): kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); mirrored Arabic UI landed (#1195); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 5–9 | [layout-parity.md](layout-parity.md) |
|
||||
| 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
|
||||
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~90%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: turned characters (no Word equivalent), conditional text as fields (variables done, as SET/REF), control looks, drawing embedded spreadsheets' contents, dates in locales other than US English (`number:automatic-order`). Emphasis marks, fixed-width tables, tables starting a page, page background pictures, the default font size and styles turning things off are done. Checked against LibreOffice's 78 bundled templates as well as synthetic documents: all open and round-trip; placeholders, first-page styles, unsized pages, WMF/EMF logos and `.ott` templates done; what ODF leaves to the application is WordCraft's one choice for each case ([odf-interpretation.md](odf-interpretation.md)); WordCraft's own documents (templates, themes, every feature) saved as ODT open in LibreOffice with the same pages, valid against the ODF 1.3 schema, with table styles, SmartArt, watermarks and cell pictures kept; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259); an ODT saved without edits keeps what WordCraft doesn't model: unedited paragraphs, tables, named sections, indexes, forms, style definitions, page layouts and document properties are written back as read, checked by a no-edit loss test and a loss check over the corpus (left: paragraphs showing form controls are written from the model; every schema element audited in [odt-element-audit.md](odt-element-audit.md)) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) |
|
||||
@@ -174,6 +174,8 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
|
||||
|
||||
> 2026-11-03 · Section `w:chapStyle` values now preserve the complete unsigned-byte range, including 10; values above 255 are rejected.
|
||||
|
||||
> 2026-11-05 · Gap 16: Arabic letters join across a change of formatting and past hidden or deleted text (#4093); readiness unchanged.
|
||||
>
|
||||
> 2026-11-03 · Gap 11: the Arabic dialogs, tables and galleries read right to left (rtl::Table); readiness unchanged.
|
||||
>
|
||||
> 2026-10-27 · Technical synchronization: existing Proofing pane hooks and regression tests follow the extracted engine/UI modules on main `436d444`; compact context menus remain upstream. New-SHA validation is pending; readiness unchanged.
|
||||
|
||||
@@ -30,6 +30,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
|
||||
| Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109; spaces before a line's first character (paragraph indented with spaces, after a line break or a tab, a bookmark, hidden text or a floating object's anchor before them) keep their width in justified and distributed lines, `layout::tests::leading_spaces_keep_their_width_in_justified_lines` | — |
|
||||
| Spaces at the end of a line hang past the right margin instead of wrapping; the caret after them stays at the margin, in the body and in headers and footers | ✅ | `layout::tests::caret_after_hanging_spaces_stays_at_the_margin` (#2146): a letter typed after them wraps to the next line | — |
|
||||
| Complex scripts (Myanmar, Khmer, Indic): shaped per script run with OpenType fallback fonts, one cluster per syllable, Myanmar wraps between syllables | 🟡 | `layout::tests::complex` (#604). Khmer, Thai and Lao still wrap only at spaces (need a dictionary) | 4–8 |
|
||||
| Cursive joining (Arabic, Syriac, Mongolian, N'Ko…) across runs: a letter takes its form from the text shown beside it, across a change of formatting, past a vowel sign in a run of its own and past hidden or tracked-deleted text left out of the view; a tab, break, object or drop cap still stops the join | ✅ | `layout::tests::bidi::letters_join_across_a_change_of_formatting`, `letters_join_past_text_left_out_of_the_layout`, `letters_join_past_a_mark_in_a_run_of_its_own`, `letters_stay_apart_where_nothing_joins_them` (#4093); not compared with Word on this machine | 0 |
|
||||
| Kashida justification (Arabic) | ❌ | no kashida in `crates/layout` | 3–5 |
|
||||
| Missing Thai document fonts (TH Sarabun New, Angsana New, Cordia New, Browallia New, the UPC families, Leelawadee): one Thai substitute face for the whole run, Latin letters, digits and ฿ included, so the run keeps one font at one size as Word's Cordia New substitution does | ✅ | `fonts::word::tests::a_missing_thai_font_resolves_to_an_installed_thai_face`, `fonts::fontdb::tests::a_thai_run_with_latin_falls_back_to_one_face`, `layout::tests::complex::a_missing_thai_font_draws_the_whole_run_in_one_thai_face`, `layout::tests::complex::a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it`, `fonts::fontdb::tests::sysfonts::a_run_no_preferred_face_covers_whole_loads_an_installed_font_that_does` (#1549). Windows: Cordia New, then Leelawadee UI, Tahoma, Noto Sans Thai; macOS: Thonburi (Sathu for the serif families); Linux: Noto Sans Thai, then the TLWG fonts. The first listed face with Latin letters and digits as well as Thai wins. Other Thai names (`TH …`, `…UPC`, names with `Thai`) use the same lists. When no listed face is installed, or the one found lacks the run's Thai or Latin letters, the run moves to one installed face covering its Thai and Latin text if there is one, even when a preferred Thai family without Latin letters (Noto Sans Thai) is installed; that face's whole family is loaded, so regular text keeps the Regular face. Only Thai fonts: a missing Latin font keeps Latin text in its own substitute, and a run with its own complex-script font keeps it for the Thai letters, as Word does | — |
|
||||
| Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 |
|
||||
@@ -278,6 +279,8 @@ The front-of-text opt-out is retained from the earlier implementation; this patc
|
||||
|
||||
## Revision history
|
||||
|
||||
> 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093): each run is shaped with the text shown on either side of it.
|
||||
|
||||
> 2026-11-01 · Text in a turned text box is drawn turned with it, about its centre, on screen and in PNG and PDF export; flipped across, upside down; flipped side to side, still readable (as Word draws it). Clicks and the caret inside a turned box still go by its upright text (`layout::tests::a_turned_text_box_turns_its_text_with_it`).
|
||||
|
||||
> 2026-11-01 · An in-line picture, shape or chart is raised or lowered by its run's position (Word's `w:position`, Font › Advanced › Position): lowered, it reaches below the baseline and the line grows down to hold it (at most until its top is on the baseline); raised, it stands clear of the baseline. It used to stand on the baseline whatever the position (`layout::tests::an_inline_picture_moves_with_its_runs_position`).
|
||||
|
||||
@@ -62,7 +62,7 @@ shared items below.
|
||||
| Simplified Chinese (Mandarin) | zh-hans | 929 entries; 98% of extracted, ~95% of visible | most; newer panes English | CJK ✅, IME ✅; Windows UI font fixed (#248); interface font chain covers every catalog character, installed CJK font after the embedded ones when it doesn't, PingFang found on macOS 12 (#486); font list by Chinese names (宋体, 微软雅黑, #505); word count counts each Han ideograph and kana as a word, not punctuation (#4547); no vertical text | no | partial | 3–5 |
|
||||
| Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 |
|
||||
| Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 |
|
||||
| Arabic | ar | ~2,730 entries (craft-i18n `locales/ar/wordcraft.tsv`); 98% / ~95% | most; newer panes English | shaping, joining, lam-alef, bidi ✅ (#207); mirrored UI ✅ (#1195; dialogs, tables and galleries read right to left); Arabic UI font fallback; no kashida; no proofing | no | partial | 2–4 (review + kashida/proofing elsewhere) |
|
||||
| Arabic | ar | ~2,730 entries (craft-i18n `locales/ar/wordcraft.tsv`); 98% / ~95% | most; newer panes English | shaping, joining, lam-alef, bidi ✅ (#207); letters join across a change of formatting and past hidden or deleted text ✅ (#4093); mirrored UI ✅ (#1195; dialogs, tables and galleries read right to left); Arabic UI font fallback; no kashida; no proofing | no | partial | 2–4 (review + kashida/proofing elsewhere) |
|
||||
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (spelling and suggestions from the French Language pack, elided forms first: `Jadore` → `J'adore`; no grammar marks) | 1–2 (grammar) |
|
||||
| Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) |
|
||||
| Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 |
|
||||
@@ -276,6 +276,7 @@ WordCraft keeps 2,54 cm).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | trivial | Arabic: the Home tab's Styles gallery reads from the right: Normal is the rightmost tile and the scroll arrows and More sit at the gallery's left end, as the rest of the mirrored ribbon (#5566); left to right is unchanged; regression test `the_styles_gallery_reads_from_the_right_in_a_right_to_left_interface` |
|
||||
| 2026-11-05 | trivial | Arabic letters join across a change of formatting and past hidden or deleted text (#4093) |
|
||||
| 2026-11-04 | trivial | Table sort and formulas read the Panamanian balboa's `B/.` around a number (`B/. 1,250.50`, `1,250.50 B/.`), as Word does; `B/.1,250.50` and `B/ 1,250.50` stay text, matching Word 16.0, and the sign is matched only at the ends of the text, so `1/.5` is unchanged |
|
||||
| 2026-11-04 | trivial | craft-i18n.rev at 9d6d54d (craft-i18n#145): 69 region names and the `{language} ({region})` template (full-width in ja, ko, zh) in 35 locales, AI-assisted without native-speaker review |
|
||||
| 2026-11-04 | trivial | craft-i18n.rev at cde4900 (craft-i18n#136): translated rows for Options › Language (Your languages, Add a language…, Set as default, Dictionary files), the missing-pack bar and 28 Language pack language names in 35 locales, AI-assisted without native-speaker review |
|
||||
|
||||
Reference in new issue
Block a user