Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 267af19e60 fix(geom): measure PNGs from their chunk headers, not the decoder's header read 2026-11-03 11:48:41 +00:00
Brandon ThomasandClaude Opus 5.5 ffb155a032 ci: the font-store sanitising test waits out an install another test's window moved onto the engine's thread
Another ui-egui test's frame runs language_packs::tick, which puts installs on the engine's
thread while this test's backend is set; start_install then returned Ok and the test failed
in the full suite (it passed alone). Red on main since the language packs merge (#5224).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:09:38 -05:00
dd2a651093 References: Remove and Replace Table of Contents treat a title above a section break as Word does (#5152)
Word's TOC over entries in columns keeps its TOC Heading title in a one-column
section above a blank paragraph that ends it; Update Table moves the field into
that blank paragraph. Replace (references.toc with settings) put the new title
under the break, inside the two-column section, and Remove kept the blank
paragraph and its break.

Observed black-box in Word for Mac 16.114.3 on a synthetic document: Insert
Table of Contents keeps the title, the blank paragraph and the break and only
changes the field; Remove Table of Contents takes the title, the blank
paragraph and its break with the entries.

Replace now keeps the title above the break (renamed when a title is given)
and rebuilds the field and entries in place; Remove takes the title and the
blank section-ending paragraph with the table. A paragraph of another style
above the break stays.

Co-authored-by: Harrison Smith <harrison@hsmith.dev>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 22:47:03 -05:00
ljkx bcbc8619e9 #5290: Page Up/Down move by a page in Outline, Draft and Web Layout (#5300)
* #5290: Page Up/Down move by a page in Outline, Draft and Web Layout

These views lay the document out as one long page, so caret.pageUp/pageDown looked for a previous or next page, found none and left the caret where it was. Move by the text area height of the caret's section instead, the same distance Print Layout moves.

* #5290: Page Up/Down pick the line past the caret, and page from a hidden outline selection end

Choose the destination among the lines past the caret by height first (DocLayout::line_toward), so narrow right-indented paragraphs can't keep the caret on its own line when the step is short. The above/below test is shared with vertical().

In Outline view, a selection that ends in text a collapsed heading hides pages from where the outline shows it (outline::shown_pos, pulled out of after_command), keeping the anchor. Avoid f32::clamp, which panics on a NaN page height.
2026-11-02 22:43:22 -05:00
Michael MuandClaude Sonnet 5.5 ab5ee42049 A command line file's Password box survives the startup document's replacement (#5334)
When a region is chosen in File › Options, start-up replaces the first blank document with one
on that region's paper through `session.set_document`. That changes the session's active
document, but the window still showed the old one, so on the first frame `sync_document` took
the change for a switch of document and cleared the dialog. A password-protected file named on
the command line (a double-click in the file manager) had just asked for its password with
`file.open`, so the Password box was dropped before it was drawn and a blank Document1 stayed.

`WordApp::replace_startup_document` swaps the document and lets the window follow at once; the
start-up code uses it. Before the file is opened, so nothing is open to lose.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:43:19 -05:00
Dylan M. Taylor e00ff04d4a Fonts: refuse WOFF2 collections whose rebuilt directories are huge or list a table twice (#5332)
The WOFF2 decoder rebuilds every collection font's table directory (16 bytes
per listed table) before checking its output size, while the file spends as
little as one byte per listed table. A small collection listing the same
tables over and over made it allocate hundreds of megabytes twice.

Preflight the collection directory before decoding: the rebuilt directories
must fit 16 MiB and no font may list a table twice.
2026-11-02 22:24:55 -05:00
Dylan M. Taylor 220994decf Fonts: cut names read from a font's name table to 128 characters (#5333)
A name record can hold 64K characters, and every face copies its family into
each localized name and every variable-font named instance (with their
localized names again), so a small font could claim hundreds of megabytes of
name strings before any later check.

name() now keeps at most MAX_NAME_CHARS characters, the limit localized names
already used, so every copy stays small.
2026-11-02 22:24:52 -05:00
Dylan M. Taylor ab0020d6f4 Pictures: refuse a TIFF whose first image names its compression twice (#5337)
The TIFF guard read the first Compression tag, but the tiff crate keeps the
last copy of a tag. An allowed first value (none, LZW...) could hide a JPEG
compression after it, and the JPEG strips are then decoded by a nested
decoder with its own limits, outside the caller's budget.
2026-11-02 21:30:33 -05:00
Dylan M. Taylor 5702fa08b2 Merge pull request #5224 from benandez/fu-packs-p3
Language packs 2: 75 packs and the merged craft-proof (compounding, en-AU); #5208 review follow-ups
2026-11-02 21:23:33 -05:00
Dylan M. Taylor 1a8932b3ed Read Aloud: look citations up by binary search, not a scan per character (#5335)
Building the queue checked every character of the document against every
citation field, so a document with many citations made Read Aloud do
characters x citations comparisons before playback started.

The citation spans are now sorted and merged once, and each position is
looked up by binary search.
2026-11-02 21:16:49 -05:00
Martin BallandClaude Opus 5.5 8865455cfb CP4.4: review — gap 8 and the localization parity rows say the compounding languages are packs (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 02:07:54 +00:00
Martin BallandClaude Opus 5.5 0842d4faec CP4.4: language-packs-2 pinned (75 packs, adds the compounding languages); craft-proof at craft-libs main's merge of #497 (with #496, en-AU)
- PACK_INDEX: language-packs-2 index.json, sha256 b35c7aef…, built from craft-i18n 20467b63
- craft-proof pinned at storytold/craft-libs 47c9f625 (flag-based compounding, #497; review follow-ups and en-AU, #496)
- Options › Language: ProofLang::EnAu has no Hunspell dictionary name of its own (English uses SCOWL)
- docs: Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto are packs; Mongolian and Thai stay hidden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:55:16 +00:00
Martin Ball 14513ae5f6 Merge remote-tracking branch 'origin/main' into fu-packs-p3 2026-11-03 01:50:29 +00:00
Martin Ball 7d9968fd98 Merge remote-tracking branch 'origin/main' into fu-packs-p3 2026-11-02 22:43:02 +00:00
Martin BallandClaude Opus 5.5 7aab0b202c FU.3: language packs — the status bar's pack lookup is cached by index generation, not copied every frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:52:21 +00:00
Martin BallandClaude Opus 5.5 c4d7948e68 FU.2: language packs — tag characters and line/paragraph separators are stripped from shown text; font store errors go through the same sanitiser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:52:21 +00:00
Martin BallandClaude Opus 5.5 d393e99879 FU.1: language packs — a user's cancel isn't an error; Install right after Cancel starts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:52:21 +00:00
30 changed files with 1164 additions and 131 deletions
Generated
+1 -1
View File
@@ -1103,7 +1103,7 @@ dependencies = [
[[package]]
name = "craft-proof"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=89fa1559e94378ecef85cd7e4681400f4df053e0#89fa1559e94378ecef85cd7e4681400f4df053e0"
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
dependencies = [
"log",
"miniz_oxide 0.9.1",
+4 -5
View File
@@ -24,11 +24,10 @@ wordcraft-pdf = { path = "crates/pdf" }
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
# craft-proof is the head of storytold/craft-libs#495 (review fixes), which contains #485 (CP4.2,
# Language packs, on #483 SCOWL English and #482); the font store's `language-pack` kind is #486
# (CP4.1). GitHub serves PR heads from storytold/craft-libs, and merged with merge commits they
# stay in its main.
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "89fa1559e94378ecef85cd7e4681400f4df053e0", version = "=0.1.0" }
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
# `language-pack` kind is #486 (CP4.1).
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
wordcraft-engine = { path = "crates/engine" }
+9 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -496,7 +496,7 @@ fn main() -> eframe::Result {
// document too (#838).
if !sample && app.session.prefs.region.is_some() {
let doc = app.session.blank_document();
app.session.set_document(doc);
app.replace_startup_document(doc);
}
app.watch_desktop_theme(&cc.egui_ctx);
if prefs_enabled()
+25
View File
@@ -2,6 +2,7 @@
use serde_json::Value;
use wordcraft_doc::{Pos, StoryRef};
use wordcraft_layout::ViewMode;
use super::{parse_pos, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -254,10 +255,24 @@ fn line(s: &mut Session, v: &Value, home: bool) -> CmdResult {
sel_result(s)
}
/// Page Up (`dir < 0`) / Page Down: the same place on the page before or after, or in the views
/// that are one long page, the line a printed page's text above or below.
fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult {
let ext = extend(v);
let l = s.layout();
let from = s.sel.focus.clone();
// Web Layout, Draft and Outline lay the document out as one long page (#5290).
if !s.view.read_mode && matches!(s.view.mode, ViewMode::Web | ViewMode::Draft | ViewMode::Outline) {
// A selection can end in text a collapsed outline heading hides: page from where it shows.
let at = if l.caret_on(&from, s.page_hint).is_none() { super::outline::shown_pos(s, &from, false) } else { None };
let at = at.unwrap_or_else(|| from.clone());
let Some(c) = l.caret_on(&at, s.page_hint) else { return sel_result(s) };
let page_h = l.pages.get(c.page).map_or(0.0, |p| p.h);
let y = (c.top + 1.0 + dir as f32 * page_step(s, &at)).min(page_h).max(0.0);
let to = l.line_toward(&at, s.page_hint, c.x, y, dir).unwrap_or(from);
apply(s, to, ext);
return sel_result(s);
}
let Some(c) = l.caret_on(&from, s.page_hint) else { return sel_result(s) };
let np = if dir < 0 { c.page.saturating_sub(1) } else { (c.page + 1).min(l.pages.len().saturating_sub(1)) };
let to = l.hit(np, c.x, c.top + 1.0, from.story).unwrap_or(from);
@@ -266,6 +281,16 @@ fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult {
sel_result(s)
}
/// How far Page Up/Down move in the continuous views: the height of the text area of the
/// section `at` is in (the first section when `at` isn't in the body), at least an inch.
fn page_step(s: &Session, at: &Pos) -> f32 {
let block = if at.story == StoryRef::Body { at.path.0.first().map_or(0, |&i| i as usize) } else { 0 };
let sections = s.doc.sections();
let sect = sections.iter().find(|(end, _)| *end >= block).or(sections.last()).map(|(_, p)| *p);
let body = sect.map_or(0.0, |p| p.page_h - p.margin_top.abs() - p.margin_bottom.abs());
if body.is_finite() { body.max(72.0) } else { 72.0 }
}
fn set(s: &mut Session, v: &Value) -> CmdResult {
let ext = extend(v);
let to = if let Some(pv) = v.get("pos") {
+16 -7
View File
@@ -520,13 +520,25 @@ pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool)
if s.layout().caret(&s.sel.focus).is_some() {
return;
}
let Some(&block) = s.sel.focus.path.0.first() else { return };
let block = block as usize;
let forward = before.focus <= s.sel.focus;
let focus = s.sel.focus.clone();
if let Some(t) = shown_pos(s, &focus, forward) {
s.sel = Selection::caret(t);
}
}
/// In Outline view, the nearest place the outline shows to body position `pos`, which it doesn't
/// show (in collapsed text, or past a body paragraph's first line), going on (`forward`) or back
/// from it. `None` outside Outline view and the body, or when no paragraph shows that way.
pub(crate) fn shown_pos(s: &mut Session, pos: &Pos, forward: bool) -> Option<Pos> {
if s.view.mode != ViewMode::Outline || pos.story != StoryRef::Body {
return None;
}
let block = *pos.path.0.first()? as usize;
let levels = ol::levels(&s.doc);
let hidden = ol::hidden(&levels, &s.view.outline.view());
let layout = s.layout();
let shown_para = |j: usize| !hidden.get(j).copied().unwrap_or(true) && matches!(s.doc.body.get(j).map(|b| &**b), Some(Block::Para(_)));
let forward = before.focus <= s.sel.focus;
let next = (block + 1..levels.len()).find(|j| shown_para(*j)).map(|j| Pos::body(j, 0));
let prev = || {
if shown_para(block) {
@@ -538,8 +550,5 @@ pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool)
Some(if layout.caret(&end).is_some() { end } else { Pos::body(j, 0) })
};
let target = if forward { next.or_else(prev) } else { prev().or(next) };
if let Some(t) = target {
let t = s.doc.clamp(&t);
s.sel = Selection::caret(t);
}
target.map(|t| s.doc.clamp(&t))
}
+67
View File
@@ -326,6 +326,73 @@ mod tests {
assert!(s.ui_requests.is_empty(), "no dialog opens");
}
/// Sets up [`Slow`] with downloading on and installs on a thread; returns its go-ahead.
fn slow_in_background() -> std::sync::mpsc::Sender<()> {
let (tx, rx) = std::sync::mpsc::channel();
let slow = Arc::new(Slow { inner: FixedBackend::sample(), go: std::sync::Mutex::new(rx) });
slow.inner.network.store(true, Ordering::Relaxed);
packs::set_backend(Some(slow as Arc<dyn Backend>));
packs::set_background(Some(Arc::new(|| {})));
tx
}
fn wait_for_finish(before: u64) {
for _ in 0..500 {
if packs::finished() != before {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_ne!(packs::finished(), before, "the install thread ended");
}
/// FU.1: the person's own Cancel isn't a failure. The install stops being listed at once and
/// ends without an error ("Couldn't install: cancelled" in red was wrong); removing the pack
/// while the cancelled download is still stopping says so.
#[test]
fn a_cancelled_install_ends_without_an_error() {
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = Reset;
let go = slow_in_background();
let before = packs::finished();
assert!(packs::start_install("pl").unwrap());
assert_eq!(packs::installing().len(), 1);
packs::cancel("pl");
assert!(packs::installing().is_empty(), "Install shows again at once");
let e = packs::remove("pl").unwrap_err();
assert!(e.contains("still stopping"), "{e}");
go.send(()).unwrap();
wait_for_finish(before);
assert!(packs::installing().is_empty());
assert_eq!(packs::error("pl"), None, "a cancel is no error");
assert!(packs::errors().is_empty(), "{:?}", packs::errors());
assert!(!packs::is_installed("pl"));
}
/// FU.1: Install right after Cancel, before the cancelled download has stopped, isn't
/// refused as still running: it shows as installing and the pack installs once the cancelled
/// run ends (never two downloads of a pack at once).
#[test]
fn install_right_after_cancel_starts_again() {
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = Reset;
let go = slow_in_background();
let before = packs::finished();
assert!(packs::start_install("pl").unwrap());
packs::cancel("pl");
assert!(packs::start_install("pl").unwrap(), "started again");
let running = packs::installing();
assert_eq!(running.len(), 1);
assert_eq!(running[0].locale, "pl");
// The cancelled run ends, then the new one.
go.send(()).unwrap();
go.send(()).unwrap();
wait_for_finish(before);
assert!(packs::is_installed("pl"), "the second Install installed it");
assert!(packs::installing().is_empty());
assert!(packs::errors().is_empty(), "{:?}", packs::errors());
}
/// A pack read from its folder proofs and hyphenates its language's runs, and leaves the
/// English runs to English: a tiny Lithuanian pack (`ProofLang::Pack`, no rules of its own).
#[test]
+70 -1
View File
@@ -182,14 +182,45 @@ pub(super) fn toc_range(s: &Session) -> Option<(usize, usize)> {
Some((start, end))
}
/// The TOC title Word's table keeps above a section break (#4041): the TOC field's paragraph at
/// `start` holds nothing but the field (Word's blank paragraph ending the title's one-column
/// section, which the field moves into), and a TOC Heading paragraph of its own sits just above
/// it. The title's block.
fn title_above_break(s: &Session, start: usize) -> Option<usize> {
let field = s.doc.body.get(start)?.as_para()?;
let blank = field.text.chars().all(|c| c == OBJ)
&& field.objects.iter().all(|o| match o {
InlineObject::Field { instr, .. } => {
let f = FieldCode::parse(instr);
f.name == "TOC" && !f.is_table_of_figures()
}
InlineObject::BookmarkStart { .. } | InlineObject::BookmarkEnd { .. } | InlineObject::ControlEnd => true,
_ => false,
});
if field.section.is_none() || !blank {
return None;
}
let i = start.checked_sub(1)?;
let title = s.doc.body.get(i)?.as_para()?;
(title.props.style.as_deref() == Some("TOCHeading") && title.section.is_none() && toc_field(title).is_none()).then_some(i)
}
/// Removes the TOC blocks `start..=end` and returns where the TOC was. A field paragraph that ends
/// a section stays, emptied, so its section break isn't lost (#3889). A heading paragraph of its
/// own just above the field's paragraph (Word's tables start the field after their heading) goes
/// with the table.
/// with the table. When that heading is above a section break that the field's otherwise blank
/// paragraph ends (Word's table over entries in columns), the blank paragraph and its break go
/// too, as Word's Remove Table of Contents takes everything in its control (#4041): the text
/// before then belongs to the section after.
pub(super) fn remove_toc(s: &mut Session, start: usize, end: usize) -> Result<usize, CmdError> {
for i in (start + 1..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
if let Some(title) = title_above_break(s, start) {
s.doc.remove_block(StoryRef::Body, &Path::top(start))?;
s.doc.remove_block(StoryRef::Body, &Path::top(title))?;
return Ok(title);
}
let at = match s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
Ok(p) if p.section.is_some() => {
*p = Paragraph { section: p.section.take(), ..Paragraph::new() };
@@ -355,6 +386,14 @@ pub(super) fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
regenerate_toc(s, None, &tabs)?;
return sel_result(s);
}
// Word's table with its title above a section break (entries in columns): replacing it
// keeps that layout, as Word's Insert Table of Contents does (#4041). The title stays
// above the break (retitled when a title is given), the field takes the new settings in
// its paragraph ending the title's section, and the new entries follow in their columns.
Some((start, _)) if title_above_break(s, start).is_some() => {
replace_toc_above_break(s, v, start, &tabs)?;
return sel_result(s);
}
Some((start, end)) => {
let start = remove_toc(s, start, end)?;
s.doc.insert_block(StoryRef::Body, &Path::top(start), Block::Para(Paragraph::new()))?;
@@ -390,6 +429,36 @@ pub(super) fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Replaces a TOC whose title sits above the section break its field's paragraph ends
/// (`title_above_break`): the field takes `v`'s settings, the title `v`'s `title` if one is given
/// (otherwise it is kept, as Word's Insert Table of Contents keeps it), and the entries are rebuilt.
fn replace_toc_above_break(s: &mut Session, v: &Value, start: usize, tabs: &[(String, Vec<TabStop>)]) -> Result<(), CmdError> {
let instr = toc_instr(v);
let field = s.doc.para_mut(StoryRef::Body, &Path::top(start))?;
for o in &mut field.objects {
if let InlineObject::Field { instr: old, result, dirty, .. } = o {
let f = FieldCode::parse(old);
if f.name == "TOC" && !f.is_table_of_figures() {
old.clone_from(&instr);
result.clear();
*dirty = false;
}
}
}
field.touch();
if let (Some(title), Some(i)) = (p::str(v, "title"), start.checked_sub(1)) {
let old = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
let mut head = Paragraph::with_text(title, old.props_of_char(0).clone());
head.props = old.props.clone();
head.mark = old.mark.clone();
*old = head;
}
let leader = leader(p::str(v, "tabLeader").unwrap_or("dot"));
regenerate_toc(s, Some(leader), tabs)?;
s.clamp_selection();
Ok(())
}
/// A hidden `_Toc` bookmark around the heading at body block `i` (after its leading page breaks),
/// reusing one already there; its name.
pub(crate) fn toc_bookmark(s: &mut Session, i: usize, lead: usize) -> Result<String, CmdError> {
+139 -30
View File
@@ -293,8 +293,12 @@ pub fn install(locale: &str, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)
/// Remove the installed pack for `locale`.
pub fn remove(locale: &str) -> Result<(), String> {
let b = backend().ok_or_else(no_store)?;
if jobs().installs.iter().any(|(l, ..)| l == locale) {
return Err(format!("the {locale} pack is being installed: cancel it first"));
if let Some(job) = jobs().installs.iter().find(|j| j.locale == locale) {
return Err(if job.stopping() {
format!("the {locale} pack's cancelled install is still stopping: try again in a moment")
} else {
format!("the {locale} pack is being installed: cancel it first")
});
}
if !b.installed().iter().any(|p| p.locale == locale) {
return Err(format!("no language pack is installed for {locale}"));
@@ -315,11 +319,33 @@ pub type Notify = Arc<dyn Fn() + Send + Sync>;
static BACKGROUND: RwLock<Option<Notify>> = RwLock::new(None);
/// An install running on a thread.
struct InstallJob {
locale: String,
/// Bytes done.
done: u64,
/// Bytes in all.
total: u64,
/// Stops this run. A cancelled job stays listed (hidden from [`installing`]) until its thread
/// notices and ends, so two downloads of a locale never write its folder at once.
cancel: Cancel,
/// Install was asked again after a cancel, before the cancelled run ended: the thread starts
/// over (with the fresh `cancel`) instead of ending.
restart: bool,
}
impl InstallJob {
/// Cancelled, and its thread hasn't ended yet.
fn stopping(&self) -> bool {
self.cancel.is_cancelled()
}
}
/// Installs and fetches running on a thread, and how the finished ones ended.
#[derive(Default)]
struct Jobs {
/// Running installs by locale: (bytes done, bytes in all, its Cancel).
installs: Vec<(String, u64, u64, Cancel)>,
/// Running installs, one per locale.
installs: Vec<InstallJob>,
fetching: bool,
/// The last failure of each locale's install ("" for the list), until it is tried again.
errors: Vec<(String, String)>,
@@ -358,9 +384,9 @@ pub struct Progress {
pub total: u64,
}
/// The installs running now.
/// The installs running now (not the cancelled ones still stopping).
pub fn installing() -> Vec<Progress> {
jobs().installs.iter().map(|(l, d, t, _)| Progress { locale: l.clone(), done: *d, total: *t }).collect()
jobs().installs.iter().filter(|j| !j.stopping()).map(|j| Progress { locale: j.locale.clone(), done: j.done, total: j.total }).collect()
}
/// Whether the list is being fetched.
@@ -383,10 +409,12 @@ pub fn finished() -> u64 {
jobs().finished
}
/// Stop `locale`'s running install.
/// Stop `locale`'s running install. The person's own choice: it ends without an error, and
/// [`installing`] stops listing it at once (Install shows again).
pub fn cancel(locale: &str) {
if let Some((_, _, _, c)) = jobs().installs.iter().find(|(l, ..)| l == locale) {
c.cancel();
if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) {
job.restart = false;
job.cancel.cancel();
}
}
@@ -400,7 +428,9 @@ fn set_error(locale: &str, e: Option<String>) {
/// Start installing `locale` ([`install`]): on a thread when [`runs_in_background`], else now.
/// What's wrong up front (no pack, downloading off, outside the write root) is an error at once.
/// Returns whether it is still running (`false`: done, or it already was running).
/// Returns whether it is still running (`false`: done). Asked again while it runs, it carries on;
/// right after a cancel, before the cancelled download has stopped, it starts over once that
/// one ends (never two downloads of a pack at once).
pub fn start_install(locale: &str) -> Result<bool, String> {
let b = backend().ok_or_else(no_store)?;
let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?;
@@ -418,34 +448,55 @@ pub fn start_install(locale: &str) -> Result<bool, String> {
let cancel = Cancel::new();
{
let mut j = jobs();
if j.installs.iter().any(|(l, ..)| l == locale) {
j.errors.retain(|(l, _)| l != locale);
if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale) {
if job.stopping() {
// Cancelled and not ended yet: its thread starts over when it does.
job.done = 0;
job.total = pack.size;
job.cancel = cancel;
job.restart = true;
}
return Ok(true);
}
j.installs.push((locale.to_string(), 0, pack.size, cancel.clone()));
j.errors.retain(|(l, _)| l != locale);
j.installs.push(InstallJob { locale: locale.to_string(), done: 0, total: pack.size, cancel: cancel.clone(), restart: false });
}
let key = locale.to_string();
let locale = locale.to_string();
let started = spawn(move || {
let progress_notify = notify.clone();
let r = install(&locale, &cancel, &mut |done, total| {
if let Some(job) = jobs().installs.iter_mut().find(|(l, ..)| *l == locale) {
job.1 = done;
job.2 = total;
let mut cancel = cancel;
loop {
let progress_notify = notify.clone();
let r = install(&locale, &cancel, &mut |done, total| {
if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) {
job.done = done;
job.total = total;
}
progress_notify();
});
let mut j = jobs();
if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale && j.restart) {
job.restart = false;
cancel = job.cancel.clone();
drop(j);
notify();
continue;
}
progress_notify();
});
let mut j = jobs();
j.installs.retain(|(l, ..)| *l != locale);
if let Err(e) = r {
j.errors.push((locale, e));
j.installs.retain(|j| j.locale != locale);
// The person's own cancel isn't a failure: nothing to show.
if let Err(e) = r
&& !cancel.is_cancelled()
{
j.errors.push((locale, e));
}
j.finished = j.finished.wrapping_add(1);
drop(j);
notify();
break;
}
j.finished = j.finished.wrapping_add(1);
drop(j);
notify();
});
if let Err(e) = started {
jobs().installs.retain(|(l, ..)| *l != key);
jobs().installs.retain(|j| j.locale != key);
return Err(e);
}
Ok(true)
@@ -579,12 +630,47 @@ pub fn proofable(tag: &str) -> bool {
pub fn missing_dictionary(tag: Option<&str>) -> Option<&'static str> {
let name = wordcraft_proof::missing_dictionary(tag)?;
let lang = wordcraft_proof::ProofLang::from_tag(tag);
if lang.pack_locale().is_some() && pack_for_tag(tag.unwrap_or(""), &available()).is_none() {
if lang.pack_locale().is_some() && !has_pack_for_tag(tag.unwrap_or("")) {
return None;
}
Some(name)
}
/// The last answer of whether the index has a pack for a tag, for the index generation it was
/// worked out for: the status bar asks every frame while the caret is in a pack language, and
/// working it out copies the whole index.
#[derive(Default)]
struct PackLookup {
/// (index generation, tag, has a pack).
entry: Option<(u64, String, bool)>,
}
impl PackLookup {
/// The remembered answer for `tag` at `generation`, else `compute`'s (then remembered).
fn get(&mut self, generation: u64, tag: &str, compute: impl FnOnce() -> bool) -> bool {
if let Some((g, t, has)) = &self.entry
&& *g == generation
&& t == tag
{
return *has;
}
let has = compute();
self.entry = Some((generation, tag.to_string(), has));
has
}
}
static PACK_LOOKUP: Mutex<PackLookup> = Mutex::new(PackLookup { entry: None });
/// Whether the index has a pack for `tag` ([`pack_for_tag`]), worked out again only when the
/// index changes ([`index_generation`]) or the tag does.
fn has_pack_for_tag(tag: &str) -> bool {
// Read before [`available`], which may fill the index and bump it: the answer is then worked
// out once more, never kept for a list it wasn't worked out from.
let generation = index_generation();
PACK_LOOKUP.lock().unwrap_or_else(PoisonError::into_inner).get(generation, tag, || pack_for_tag(tag, &available()).is_some())
}
/// The packs the document's text asks for that are available and not installed: what the
/// missing-pack bar offers. Never a language without a pack, nor one proofing can't use.
pub fn missing(s: &Session) -> Vec<Pack> {
@@ -630,7 +716,7 @@ impl FixedBackend {
locale: locale.into(),
name: name.into(),
version: "0000000".into(),
url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-1/{locale}.tar.zst"),
url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-2/{locale}.tar.zst"),
source: Some("https://github.com/LibreOffice/dictionaries".into()),
size,
licence: licence.into(),
@@ -713,6 +799,29 @@ mod tests {
}
}
/// FU.3: the status bar's "(no dictionary)" asks every frame whether the index has a pack
/// for the caret's language; the answer is reused until the index changes (a new
/// generation) or the tag does, so the index isn't copied every frame.
#[test]
fn the_pack_lookup_is_reused_until_the_index_changes() {
let mut lookup = super::PackLookup::default();
let mut computed = 0;
let mut ask = |lookup: &mut super::PackLookup, generation: u64, tag: &str, answer: bool| {
lookup.get(generation, tag, || {
computed += 1;
answer
})
};
assert!(ask(&mut lookup, 1, "ja-JP", true));
// Same generation and tag: the remembered answer, even where the list would now say no.
for _ in 0..10 {
assert!(ask(&mut lookup, 1, "ja-JP", false));
}
assert!(!ask(&mut lookup, 2, "ja-JP", false), "a new generation works it out again");
assert!(ask(&mut lookup, 2, "th-TH", true), "another tag too");
assert_eq!(computed, 3);
}
#[test]
fn the_default_build_embeds_only_english() {
const CARRY_OVER: [&str; 5] = ["de", "pl", "pt", "it", "ru"];
+4 -4
View File
@@ -9,13 +9,13 @@ use craft_font_store::{FontStore, Network, PackEntry, PackIndex, PackIndexPin};
use super::{Cancel, Pack};
/// The pack index this build trusts: craft-i18n's first Language pack release, language-packs-1
/// (61 packs, built from craft-i18n `4504e288`), pinned by the sha256 of its `index.json`.
/// The pack index this build trusts: craft-i18n's second Language pack release,
/// language-packs-2 (75 packs, built from craft-i18n `20467b63`), pinned by the sha256 of its `index.json`.
/// `CRAFT_LANGUAGE_PACK_INDEX` (read by the font store itself) points WordCraft at a local or test
/// index instead, for trying packs before they are released.
pub const PACK_INDEX: PackIndexPin = PackIndexPin {
url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-1/index.json",
sha256: "531465b50126d0594bb89a9c4512f7ae67d581259636040a410190641ec1428c",
url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-2/index.json",
sha256: "b35c7aef0591177f18233b551da16035dcd615549ff874e169ffd19f16fea74b",
};
pub(super) struct FontStoreBackend {
+33 -3
View File
@@ -80,12 +80,12 @@ impl Utterance {
pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec<Utterance> {
let story = from.story;
// Spans to leave out: whole range fields, both markers included.
let skipped: Vec<(Pos, Pos)> = if skip_citations {
let skipped = Spans::new(if skip_citations {
doc.field_ranges(story).into_iter().filter(|r| is_citation_code(&r.instr)).map(|r| (r.start, r.end)).collect()
} else {
Vec::new()
};
let in_skipped = |p: &Pos| skipped.iter().any(|(a, b)| a <= p && p <= b);
});
let in_skipped = |p: &Pos| skipped.contains(p);
let mut out = Vec::new();
for path in doc.para_paths(story) {
if path < from.path {
@@ -156,6 +156,36 @@ pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: b
out
}
/// Spans of the document (both ends included), sorted and merged so a position is looked up by
/// binary search: checking every character against every citation was quadratic in a document
/// with many of them.
struct Spans(Vec<(Pos, Pos)>);
impl Spans {
fn new(mut spans: Vec<(Pos, Pos)>) -> Self {
spans.retain(|(a, b)| a <= b);
spans.sort();
let mut merged: Vec<(Pos, Pos)> = Vec::with_capacity(spans.len());
for (a, b) in spans {
match merged.last_mut() {
Some(last) if a <= last.1 => {
if b > last.1 {
last.1 = b;
}
}
_ => merged.push((a, b)),
}
}
Spans(merged)
}
/// Is `p` inside one of the spans?
fn contains(&self, p: &Pos) -> bool {
let after = self.0.partition_point(|(a, _)| a <= p);
after.checked_sub(1).and_then(|i| self.0.get(i)).is_some_and(|(_, b)| p <= b)
}
}
/// The language of `text` from its script, as a BCP 47 primary tag: Cyrillic is Russian (Ukrainian
/// with one of its letters `іїєґ`), Greek, Arabic, Hebrew, Thai and Hangul map to `el`, `ar`, `he`, `th`
/// and `ko`, kana to Japanese, Han letters alone to Chinese. `None` for Latin text (the script
+32
View File
@@ -364,3 +364,35 @@ fn macos_voice_matches_the_language() {
assert_eq!(mac_voice("ru", "", "en"), None);
assert_eq!(mac_voice("ru", "garbage line\n\n \n", "en"), None);
}
#[test]
fn skipped_spans_match_a_scan_of_every_span() {
// Overlapping, nested, touching, empty-range and reversed spans.
let raw = vec![
(Pos::body(0, 5), Pos::body(0, 9)),
(Pos::body(0, 7), Pos::body(0, 12)),
(Pos::body(0, 20), Pos::body(0, 20)),
(Pos::body(0, 30), Pos::body(2, 3)),
(Pos::body(1, 4), Pos::body(1, 8)),
(Pos::body(3, 9), Pos::body(3, 2)),
(Pos::body(4, 0), Pos::body(4, 1)),
(Pos::body(4, 2), Pos::body(4, 3)),
];
let spans = Spans::new(raw.clone());
for block in 0..6 {
for off in 0..40 {
let p = Pos::body(block, off);
let scan = raw.iter().any(|(a, b)| *a <= p && p <= *b);
assert_eq!(spans.contains(&p), scan, "{p:?}");
}
}
}
#[test]
fn many_citations_are_looked_up_quickly() {
// 50,000 disjoint spans and 200,000 lookups: a scan of every span per position would take
// ten billion comparisons.
let spans = Spans::new((0..50_000).map(|i| (Pos::body(0, i * 4), Pos::body(0, i * 4 + 1))).collect());
let hits = (0..200_000).filter(|&off| spans.contains(&Pos::body(0, off))).count();
assert_eq!(hits, 100_000);
}
+222 -2
View File
@@ -4043,7 +4043,8 @@ fn toc_in_columns_with_localized_styles_keeps_its_layout_through_update_table()
assert!(blocks.is_none_or(|n| n == s.doc.body.len()), "round {round}: the document grew: {}", text(&s));
blocks = Some(s.doc.body.len());
}
// The field's paragraph ends the one-column section: replacing or removing the TOC keeps it.
// The field's paragraph ends the one-column section: replacing the TOC keeps it, and
// removing it takes the break with the title, as Word does (#4041).
let columns = |s: &Session| s.doc.sections().iter().map(|(_, x)| x.columns.count).collect::<Vec<_>>();
assert_eq!(columns(&s), [1, 2, 1]);
// The heading goes with the table: one "Contents" after Replace, none after Remove.
@@ -4052,10 +4053,130 @@ fn toc_in_columns_with_localized_styles_keeps_its_layout_through_update_table()
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
assert_eq!(titles(&s), 1, "{}", text(&s));
run(&mut s, "references.removeToc", json!({}));
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
assert_eq!(columns(&s), [2, 1], "{}", text(&s));
assert_eq!(titles(&s), 0, "{}", text(&s));
}
/// #4041: Word's TOC over entries in columns (#3889): its control holds the TOC Heading title in a
/// one-column section, a blank paragraph ending that section, and the entries in a continuous
/// two-column section. `title_style` is the title paragraph's style. Update Table has run.
fn word_toc_above_a_section_break(title_style: &str) -> Session {
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
let sect = |cols: &str, kind: &str| format!(r#"<w:sectPr>{kind}<w:pgSz w:w="11906" w:h="16838"/><w:cols {cols} w:space="708"/></w:sectPr>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-2" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let toc = [
p(title_style, &t("Contents")),
format!("<w:p><w:pPr>{}</w:pPr></w:p>", sect("", "")),
p("TOC1", &format!("{begin}{}", t("Chapter One\t2"))),
p("TOC2", &t("Section\t2")),
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
]
.concat();
let body = [
format!(
r#"<w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent>{toc}</w:sdtContent></w:sdt>"#
),
format!(r#"<w:p><w:pPr>{}</w:pPr></w:p>"#, sect(r#"w:num="2""#, r#"<w:type w:val="continuous"/>"#)),
p("Heading1", &t("Chapter One")),
p("Heading2", &t("Section")),
sect("", ""),
]
.concat();
let styles = [
style_xml("Heading1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
style_xml("Heading2", "heading 2", r#"<w:outlineLvl w:val="1"/>"#, ""),
style_xml("TOCHeading", "TOC Heading", "", ""),
style_xml("TOC1", "toc 1", "", ""),
style_xml("TOC2", "toc 2", r#"<w:ind w:left="220"/>"#, ""),
]
.concat();
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
run(&mut s, "references.updateToc", json!({}));
s
}
/// The body's paragraphs as (style, ends a section, text), and each section's column count.
fn toc_layout(s: &Session) -> (Vec<(String, bool, String)>, Vec<u32>) {
let paras = s
.doc
.body
.iter()
.filter_map(|b| b.as_para())
.map(|p| (p.props.style.clone().unwrap_or_default(), p.section.is_some(), p.plain_text()))
.collect();
(paras, s.doc.sections().iter().map(|(_, x)| x.columns.count).collect())
}
/// #4041: replacing Word's TOC whose title sits above a section break keeps the title above the
/// break and the entries in their columns, as Word's Insert Table of Contents does (observed in
/// Word for Mac: the field takes the new settings; the title, the blank paragraph and its break
/// stay). Replacing again finds the same title; a given title renames it; undo restores the table.
#[test]
fn replacing_a_toc_keeps_its_title_above_the_section_break() {
let row = |st: &str, sect: bool, t: &str| (st.to_string(), sect, t.to_string());
let laid_out = |title: &str| {
vec![
row("TOCHeading", false, title),
row("", true, ""),
row("TOC1", false, "Chapter One\t2"),
row("TOC2", false, "Section\t2"),
row("", true, ""),
row("Heading1", false, "Chapter One"),
row("Heading2", false, "Section"),
]
};
let mut s = word_toc_above_a_section_break("TOCHeading");
let before = toc_layout(&s);
assert_eq!(before, (laid_out("Contents"), vec![1, 2, 1]), "{}", text(&s));
run(&mut s, "references.toc", json!({"levels": 1}));
let mut one_level = laid_out("Contents");
one_level.remove(3);
assert_eq!(toc_layout(&s), (one_level, vec![1, 2, 1]), "{}", text(&s));
// The field took the new settings.
let instr = s.doc.body.get(1).and_then(|b| b.as_para()).and_then(|p| {
p.objects.iter().find_map(|o| match o {
wordcraft_doc::para::InlineObject::Field { instr, .. } => Some(instr.clone()),
_ => None,
})
});
assert!(instr.as_deref().is_some_and(|i| i.contains(r#"\o "1-1""#)), "{instr:?}");
run(&mut s, "references.toc", json!({"levels": 2, "title": "Table of Contents"}));
assert_eq!(toc_layout(&s), (laid_out("Table of Contents"), vec![1, 2, 1]), "{}", text(&s));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(toc_layout(&s), before, "{}", text(&s));
}
/// #4041: removing Word's TOC whose title sits above a section break takes the title, the blank
/// paragraph ending its section and that break with the entries, as Word's Remove Table of
/// Contents does (observed in Word for Mac): the empty paragraph after the table, ending the
/// two-column section, stays, so the headings keep their own section. A Normal paragraph above
/// the break isn't the TOC's title: it and the break stay.
#[test]
fn removing_a_toc_takes_its_title_and_the_section_break_under_it() {
let row = |st: &str, sect: bool, t: &str| (st.to_string(), sect, t.to_string());
let mut s = word_toc_above_a_section_break("TOCHeading");
let before = toc_layout(&s);
run(&mut s, "references.removeToc", json!({}));
let headings = vec![row("", true, ""), row("Heading1", false, "Chapter One"), row("Heading2", false, "Section")];
assert_eq!(toc_layout(&s), (headings, vec![2, 1]), "{}", text(&s));
run(&mut s, "edit.undo", json!({}));
assert_eq!(toc_layout(&s), before, "{}", text(&s));
// Replace, then Remove: the same.
run(&mut s, "references.toc", json!({"levels": 2}));
run(&mut s, "references.removeToc", json!({}));
let (paras, columns) = toc_layout(&s);
assert_eq!(columns, vec![2, 1], "{}", text(&s));
assert!(paras.iter().all(|(st, _, t)| !st.starts_with("TOC") && t != "Contents"), "{paras:?}");
let mut s = word_toc_above_a_section_break("Normal");
run(&mut s, "references.removeToc", json!({}));
let (paras, columns) = toc_layout(&s);
assert_eq!(columns, vec![1, 2, 1], "{}", text(&s));
assert!(paras.first().is_some_and(|(_, _, t)| t == "Contents"), "{paras:?}");
}
/// #2182: with two TOCs only the one Update Table would refresh is offered Update Field; the other
/// range TOC doesn't claim the menu, so it never refreshes a different table.
#[test]
@@ -11761,3 +11882,102 @@ fn text_from_file_brings_the_files_pictures() {
assert_ne!(listed[0], our_list);
assert!(s.doc.numbering.level(listed[0], 0).is_some_and(|l| l.format == wordcraft_doc::section::NumFormat::Bullet));
}
#[test]
fn page_down_and_up_move_by_a_page_in_the_continuous_views() {
// Web Layout, Draft and Outline are one long page: Page Down/Up move as far as they do
// between the pages of Print Layout (#5290).
let after_page_down = |view: &str| {
let mut s = s();
for i in 0..200 {
run(&mut s, "text.insert", json!({"text": format!("Paragraph {i}")}));
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, view, json!({}));
run(&mut s, "caret.docStart", json!({}));
let start = s.sel.focus.clone();
run(&mut s, "caret.pageDown", json!({}));
let down = s.sel.focus.clone();
run(&mut s, "caret.pageUp", json!({}));
assert_eq!(s.sel.focus, start, "{view}: Page Up goes back");
run(&mut s, "caret.docEnd", json!({}));
let end = s.sel.focus.clone();
run(&mut s, "caret.pageDown", json!({}));
assert_eq!(s.sel.focus, end, "{view}: Page Down stops at the end");
down
};
let print = after_page_down("view.printLayout");
assert!(print.path.0[0] > 10, "Print Layout: Page Down goes to the next page: {print:?}");
for view in ["view.draft", "view.outline", "view.webLayout"] {
assert_eq!(after_page_down(view), print, "{view}");
}
}
#[test]
fn page_down_and_up_never_stay_on_a_line_in_the_continuous_views() {
// A page with less than an inch of text area: the views move by the one-inch minimum. The
// caret ends a long line and the paragraphs after it are narrow (a large right indent): the
// line a page away, not the one the caret is on, is where Page Down goes, however far right
// of it the caret is.
for view in ["view.draft", "view.outline", "view.webLayout"] {
let mut s = s();
run(&mut s, "layout.margins", json!({"top": 370, "bottom": 370}));
let long = "Lorem ipsum dolor sit amet ".repeat(4);
run(&mut s, "text.insert", json!({"text": long}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "para.indents", json!({"right": 700}));
for i in 0..60 {
if i > 0 {
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, "text.insert", json!({"text": "a"}));
}
run(&mut s, view, json!({}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.end", json!({}));
assert_eq!(s.sel.focus.off, long.chars().count(), "{view}: the first paragraph is one line");
let mut at = s.sel.focus.clone();
for step in 0..3 {
run(&mut s, "caret.pageDown", json!({}));
assert!(s.sel.focus > at, "{view}: Page Down {step} moves on from {at:?}");
at = s.sel.focus.clone();
}
for step in 0..3 {
run(&mut s, "caret.pageUp", json!({}));
assert!(s.sel.focus < at, "{view}: Page Up {step} moves back from {at:?}");
at = s.sel.focus.clone();
}
}
}
#[test]
fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
// Selecting a collapsed heading and its content ends the selection in hidden text; Page Down
// still extends it from where it shows (the heading), keeping its anchor.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "First"}));
for i in 0..5 {
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": format!("Hidden {i}")}));
}
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Second"}));
for i in 0..200 {
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": format!("Shown {i}")}));
}
run(&mut s, "view.outline", json!({}));
for block in [0, 6] {
run(&mut s, "caret.set", json!({"pos": {"block": block}}));
run(&mut s, "outline.promoteToHeading1", json!({}));
}
run(&mut s, "outline.collapse", json!({"block": 0}));
run(&mut s, "outline.selectSubtree", json!({"block": 0}));
let anchor = s.sel.anchor.clone();
assert_eq!(s.sel.focus.path.0, [5], "the selection ends in the last hidden paragraph");
assert!(s.layout().caret(&s.sel.focus).is_none(), "the outline doesn't show its end");
run(&mut s, "caret.pageDown", json!({"extend": true}));
assert_eq!(s.sel.anchor, anchor, "the anchor stays");
assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus);
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
}
+1 -1
View File
@@ -35,7 +35,7 @@ pub(crate) use names::face_names;
pub use names::{LocalName, base_style, name_language_matches, system_font_dirs};
use names::{MAX_FILE_FACES, enumerate_faces, make_face};
#[cfg(test)]
use names::{file_face_names, font_faces, mini_font, name};
use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name};
#[cfg(test)]
pub(crate) use names::{last_resort_face, legacy_family};
+10 -3
View File
@@ -61,8 +61,16 @@ pub(super) struct CatalogEntry {
/// The longest name a document's font table gives an embedded font that faces take on.
pub(super) const MAX_FAMILY_NAME: usize = 128;
/// The longest name read from a font's `name` table, in characters. A record can hold 64K
/// characters, and each face copies its family into its localized names and into every named
/// instance, so a small font could claim hundreds of megabytes of names. Real names run to a
/// few dozen characters.
pub(super) const MAX_NAME_CHARS: usize = 128;
pub(super) fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
ids.iter().find_map(|id| {
font.localized_strings(*id).english_or_first().map(|s| s.chars().take(MAX_NAME_CHARS).collect::<String>()).filter(|s| !s.is_empty())
})
}
/// The family's names in other languages: localized typographic family names (name ID 16) stand
@@ -70,7 +78,6 @@ pub(super) fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<Strin
/// font has no typographic names). Bounded: a damaged font can list any number of records.
fn local_names(f: &skrifa::FontRef<'_>, family: &str, id1: Option<&str>) -> Vec<LocalName> {
const MAX_NAMES: usize = 64;
const MAX_CHARS: usize = 128;
let typographic = f.localized_strings(StringId::TYPOGRAPHIC_FAMILY_NAME).next().is_some();
let legacy = if typographic { id1 } else { Some(family) };
let mut out: Vec<LocalName> = Vec::new();
@@ -81,7 +88,7 @@ fn local_names(f: &skrifa::FontRef<'_>, family: &str, id1: Option<&str>) -> Vec<
if lang == "en" || lang.starts_with("en-") {
continue;
}
let name: String = s.chars().take(MAX_CHARS).collect::<String>().trim().to_string();
let name: String = s.chars().take(MAX_NAME_CHARS).collect::<String>().trim().to_string();
if name.is_empty() || name.eq_ignore_ascii_case(english) || out.iter().any(|n| n.lang == lang && n.name == name) {
continue;
}
+34 -4
View File
@@ -245,15 +245,24 @@ fn postscript_names_select_faces() {
/// A variable font holding just `name` and `fvar` tables: family "Var Test", one `wght` axis
/// and `instances` named instances, each with its own style name.
fn variable_font(instances: u16) -> Vec<u8> {
let mut names: Vec<(u16, String)> = vec![(1, "Var Test".into()), (2, "Regular".into())];
names.extend((0..instances).map(|i| (256 + i, format!("Style {i}"))));
variable_font_named("Var Test", &[], instances)
}
/// [`variable_font`] with family `family`, and family names in other languages `local`
/// (Windows language ids).
fn variable_font_named(family: &str, local: &[(u16, &str)], instances: u16) -> Vec<u8> {
let mut names: Vec<(u16, u16, String)> = vec![(0x409, 1, family.into()), (0x409, 2, "Regular".into())];
names.extend(local.iter().map(|(lang, n)| (*lang, 1, n.to_string())));
names.extend((0..instances).map(|i| (0x409, 256 + i, format!("Style {i}"))));
// Records sorted by language, then name id.
names.sort_by_key(|(lang, id, _)| (*lang, *id));
let mut name = vec![0, 0];
name.extend_from_slice(&(names.len() as u16).to_be_bytes());
name.extend_from_slice(&(6 + 12 * names.len() as u16).to_be_bytes());
let mut strings = Vec::new();
for (id, text) in &names {
for (lang, id, text) in &names {
let utf16: Vec<u8> = text.encode_utf16().flat_map(u16::to_be_bytes).collect();
for v in [3, 1, 0x409, *id, utf16.len() as u16, strings.len() as u16] {
for v in [3, 1, *lang, *id, utf16.len() as u16, strings.len() as u16] {
name.extend_from_slice(&v.to_be_bytes());
}
strings.extend(utf16);
@@ -357,6 +366,27 @@ fn the_draw_that_turns_hinting_off_is_redrawn_unhinted_for_the_outline_cache() {
assert_eq!(db.outline(&face, gid).elements(), tripping.path.elements());
}
#[test]
fn long_names_are_cut_before_faces_copy_them() {
// A name record can hold 64K characters, and every named instance copies the family and its
// localized names: the names are cut when read, so the copies stay small.
let long = "W".repeat(14_000);
let local = "Ж".repeat(14_000);
let font = variable_font_named(&long, &[(0x419, &local)], 32);
let faces = font_faces(&skrifa::FontRef::new(&font).unwrap(), 0);
assert_eq!(faces.len(), 32);
for f in &faces {
assert_eq!(f.family.chars().count(), MAX_NAME_CHARS);
assert!(f.legacy.chars().count() <= MAX_NAME_CHARS + " Style 31".len(), "{}", f.legacy.len());
assert_eq!(f.local.len(), 1);
assert_eq!(f.local[0].name.chars().count(), MAX_NAME_CHARS);
assert_eq!(f.local[0].english, f.family);
}
// Short names are kept whole.
let faces = font_faces(&skrifa::FontRef::new(&variable_font_named("Var Test", &[(0x419, "Вар Тест")], 2)).unwrap(), 0);
assert_eq!((faces[0].family.as_str(), faces[0].local[0].name.as_str()), ("Var Test", "Вар Тест"));
}
#[test]
fn named_instances_and_collection_members_are_capped() {
// #3716: a file's faces are capped before they are built, however the file multiplies them.
+98 -20
View File
@@ -154,6 +154,7 @@ pub fn decode_font_file(bytes: &[u8]) -> Result<Vec<u8>, UserFontError> {
unwrap_web_font(|| wuff::decompress_woff1(bytes))?
}
FontFormat::Woff2 => {
check_woff2_collection(bytes)?;
let mut too_many_points = false;
let unwrapped = unwrap_web_font(|| {
wuff::decompress_woff2_with_custom_brotli(bytes, &mut |data, size| {
@@ -221,8 +222,29 @@ fn woff2_points_fit(file: &[u8], stream: &[u8]) -> bool {
/// The transformed `glyf` tables in `stream`, found from the WOFF2 table directory in `file`
/// (tables follow each other in directory order).
fn woff2_transformed_glyf<'a>(file: &[u8], stream: &'a [u8]) -> Option<Vec<&'a [u8]>> {
let (tables, _) = woff2_directory(file)?;
let (mut offset, mut glyf) = (0usize, Vec::new());
for t in tables {
let end = offset.checked_add(t.len)?;
if t.transformed_glyf {
glyf.push(stream.get(offset..end)?);
}
offset = end;
}
Some(glyf)
}
/// A WOFF2 table directory entry: whether it is a transformed `glyf` table, and its length in
/// the decompressed stream.
struct Woff2Table {
transformed_glyf: bool,
len: usize,
}
/// The WOFF2 table directory in `file`, and where it ends (the collection directory follows).
fn woff2_directory(file: &[u8]) -> Option<(Vec<Woff2Table>, usize)> {
let tables = u16::from_be_bytes(file.get(12..14)?.try_into().ok()?);
let (mut at, mut offset, mut glyf) = (48usize, 0usize, Vec::new());
let (mut at, mut out) = (48usize, Vec::with_capacity(usize::from(tables)));
for _ in 0..tables {
let flags = *file.get(at)?;
at += 1;
@@ -238,13 +260,81 @@ fn woff2_transformed_glyf<'a>(file: &[u8], stream: &'a [u8]) -> Option<Vec<&'a [
// glyf and loca are transformed at version 0, other tables at any other version.
let transformed = if is_glyf || is_loca { flags >> 6 == 0 } else { flags >> 6 != 0 };
let len = if transformed { base128(file, &mut at)? } else { orig };
let end = offset.checked_add(len)?;
if is_glyf && transformed {
glyf.push(stream.get(offset..end)?);
}
offset = end;
out.push(Woff2Table { transformed_glyf: is_glyf && transformed, len });
}
Some(glyf)
Some((out, at))
}
/// Largest table directories a WOFF2 collection may rebuild, in bytes. Each font's directory is
/// 16 bytes per table it lists, while the file spends as little as one byte per listed table, so
/// a collection listing the same tables over and over rebuilt hundreds of megabytes of
/// directories (twice: built, then copied into the font) before the decoder's output cap was
/// checked. Real collections need kilobytes.
const MAX_WOFF2_COLLECTION_HEADER: usize = 16 << 20;
/// Why a WOFF2 collection whose directories are too large, or list a table twice, is refused.
const BAD_COLLECTION: &str = "the collection's font directories are too large or list a table twice";
/// WOFF2 collections: the table directories the decoder rebuilds for its fonts must fit
/// [`MAX_WOFF2_COLLECTION_HEADER`], and no font may list a table twice (OpenType allows one
/// table per tag in a font). Checked before decoding, since the decoder builds them before it
/// checks its output size. A directory that doesn't parse is left to the decoder to report.
fn check_woff2_collection(file: &[u8]) -> Result<(), UserFontError> {
if file.get(4..8) != Some(b"ttcf".as_slice()) {
return Ok(());
}
match woff2_collection_fits(file) {
Some(false) => Err(UserFontError::BadWebFont(BAD_COLLECTION.into())),
_ => Ok(()),
}
}
/// Do the collection's rebuilt table directories fit, with no table listed twice in one font?
/// None when the directories don't parse.
fn woff2_collection_fits(file: &[u8]) -> Option<bool> {
let (tables, mut at) = woff2_directory(file)?;
let version = u32::from_be_bytes(file.get(at..at.checked_add(4)?)?.try_into().ok()?);
at += 4;
let fonts = read_255_u16(file, &mut at)?;
// TTC header (with the DSIG fields of version 2) and one offset per font.
let dsig = if version == 0x0002_0000 { 12 } else { 0 };
let mut size = 12 + dsig + 4 * fonts;
let mut indices: Vec<usize> = Vec::new();
for _ in 0..fonts {
let n = read_255_u16(file, &mut at)?;
size = size.saturating_add(12 + 16 * n);
if size > MAX_WOFF2_COLLECTION_HEADER {
return Some(false);
}
at = at.checked_add(4)?; // flavor
indices.clear();
for _ in 0..n {
let i = read_255_u16(file, &mut at)?;
if i >= tables.len() {
return None;
}
indices.push(i);
}
indices.sort_unstable();
if indices.windows(2).any(|w| w[0] == w[1]) {
return Some(false);
}
}
Some(true)
}
/// WOFF2's 255UInt16.
fn read_255_u16(b: &[u8], at: &mut usize) -> Option<usize> {
let mut byte = || {
*at += 1;
b.get(*at - 1).copied().map(usize::from)
};
Some(match byte()? {
253 => byte()? << 8 | byte()?,
254 => byte()? + 253 * 2,
255 => byte()? + 253,
v => v,
})
}
/// WOFF2's UIntBase128.
@@ -278,19 +368,7 @@ fn glyf_points_fit(t: &[u8]) -> Option<bool> {
let n = u16::from_be_bytes(contours.get(2 * g..2 * g + 2)?.try_into().ok()?);
let mut total = 0usize;
for _ in 0..if n == 0xFFFF { 0 } else { n } {
// 255UInt16
let code = *points.get(at)?;
at += 1;
let mut byte = || {
at += 1;
points.get(at - 1).copied().map(usize::from)
};
total += match code {
253 => byte()? << 8 | byte()?,
254 => byte()? + 253 * 2,
255 => byte()? + 253,
v => usize::from(v),
};
total += read_255_u16(points, &mut at)?;
if total > 1 << 16 {
return Some(false);
}
+47
View File
@@ -455,3 +455,50 @@ fn fonts_stored_at_once_under_one_name_each_get_their_own_file() {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A WOFF2 collection of `tables` untransformed tables whose fonts list the tables in `fonts`.
fn woff2_collection(tables: usize, fonts: &[Vec<u16>]) -> Vec<u8> {
fn u255(v: u16, out: &mut Vec<u8>) {
match v {
0..=252 => out.push(v as u8),
_ => {
out.push(253);
out.extend_from_slice(&v.to_be_bytes());
}
}
}
let mut dir = Vec::new();
for i in 0..tables {
dir.push(63);
dir.extend_from_slice(&(i as u32).to_be_bytes()); // a made-up tag per table
base128(4, &mut dir);
}
dir.extend_from_slice(&0x0001_0000u32.to_be_bytes());
u255(fonts.len() as u16, &mut dir);
for font in fonts {
u255(font.len() as u16, &mut dir);
dir.extend_from_slice(&[0, 1, 0, 0]);
for &i in font {
u255(i, &mut dir);
}
}
woff2_file(b"ttcf", tables, &dir, &vec![0; 4 * tables], 4 * tables + 1024)
}
#[test]
fn a_woff2_collection_rebuilding_huge_directories_is_refused_before_decoding() {
// Each font's rebuilt directory takes 16 bytes per table it lists, the file about two: 600
// fonts listing 2,000 tables each come to 19 MB of directories from a 2.4 MB file.
let all: Vec<u16> = (0..2_000).collect();
let file = woff2_collection(2_000, &vec![all; 600]);
assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(BAD_COLLECTION.into())));
}
#[test]
fn a_woff2_collection_font_listing_a_table_twice_is_refused() {
let file = woff2_collection(3, &[vec![0, 1, 2], vec![0, 1, 1]]);
assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(BAD_COLLECTION.into())));
// Fonts sharing tables are what collections are for: passed on to the decoder.
let file = woff2_collection(3, &[vec![0, 1, 2], vec![0, 1, 2]]);
assert_ne!(decode_font_file(&file), Err(UserFontError::BadWebFont(BAD_COLLECTION.into())));
}
+116 -11
View File
@@ -51,6 +51,8 @@ pub struct Probe<'a> {
pub width: u32,
pub height: u32,
peak: u64,
/// Whether a PNG has transparency, read from its chunk headers; `None` for other formats.
png_alpha: Option<bool>,
}
/// TIFF compressions decoded within the caller's limits: none, CCITT Group 4, LZW, Deflate (both
@@ -72,9 +74,48 @@ pub fn probe(bytes: &[u8]) -> Result<Probe<'_>, Refusal> {
}
_ => return Err(Refusal::NotAPicture),
};
if format == ImageFormat::Png {
let (width, height, native, alpha) = png_chunks(bytes).ok_or_else(|| Refusal::Broken("its PNG header can't be read".into()))?;
let (width, height, peak) = sized(width, height, native, 0)?;
return Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: Some(alpha) });
}
let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?;
let (width, height, peak) = measure(&decoder, held)?;
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak })
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: None })
}
/// A PNG's width, height, the bytes a pixel its decoder gives (samples under 8 bits widened to 8,
/// a palette to RGB, `tRNS` to an alpha channel) and whether it has transparency, from `IHDR` and
/// the chunk headers before the image data. The decoder's own header read inflates an ICC profile
/// (`iCCP`) up to its 512 MiB default limit, for a picture that may only be measured.
fn png_chunks(bytes: &[u8]) -> Option<(u32, u32, u64, bool)> {
let be = |at: usize| bytes.get(at..at.checked_add(4)?).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_be_bytes);
// The 8-byte signature, then IHDR: length, type, width, height, bit depth, colour type.
if bytes.get(12..16) != Some(b"IHDR".as_slice()) {
return None;
}
let (width, height) = (be(16)?, be(20)?);
let (depth, color) = (*bytes.get(24)?, *bytes.get(25)?);
let mut trns = false;
let mut at = 8usize;
loop {
let len = usize::try_from(be(at)?).ok()?;
match bytes.get(at.checked_add(4)?..at.checked_add(8)?)? {
b"IDAT" => break,
b"IEND" => return None,
b"tRNS" => trns = true,
_ => {}
}
at = at.checked_add(12)?.checked_add(len)?;
}
let (channels, alpha) = match color {
0 => (1 + u64::from(trns), trns),
2 | 3 => (3 + u64::from(trns), trns),
4 => (2, true),
6 => (4, true),
_ => return None,
};
Some((width, height, channels * if depth == 16 { 2 } else { 1 }, alpha))
}
/// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds
@@ -86,16 +127,20 @@ pub fn probe_dib(dib: Vec<u8>) -> Result<Probe<'static>, Refusal> {
let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?;
measure(&decoder, 0)?
};
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak })
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak, png_alpha: None })
}
/// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output.
fn measure(decoder: &impl ImageDecoder, held: u64) -> Result<(u32, u32, u64), Refusal> {
let (width, height) = decoder.dimensions();
sized(width, height, u64::from(decoder.color_type().bytes_per_pixel()), held)
}
/// A `width` × `height` picture of `native` bytes a pixel, with its peak (see [`peak_bytes`]).
fn sized(width: u32, height: u32, native: u64, held: u64) -> Result<(u32, u32, u64), Refusal> {
if width == 0 || height == 0 {
return Err(Refusal::Broken("it has no pixels".into()));
}
let native = u64::from(decoder.color_type().bytes_per_pixel());
let peak = peak_bytes(width, height, native, held).ok_or(Refusal::TooLarge { width, height })?;
Ok((width, height, peak))
}
@@ -106,6 +151,18 @@ impl Probe<'_> {
self.peak
}
/// Whether the picture has transparency, from its header: a PNG's from its chunk headers
/// (an ICC profile isn't inflated to say), another format's from its decoder.
pub fn has_alpha(&self) -> bool {
if let Some(alpha) = self.png_alpha {
return alpha;
}
if self.dib {
return image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).is_ok_and(|d| d.color_type().has_alpha());
}
image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format).into_decoder().is_ok_and(|d| d.color_type().has_alpha())
}
/// Whether decoding fits `budget` bytes.
pub fn fits(&self, budget: u64) -> bool {
self.peak <= budget
@@ -207,7 +264,8 @@ fn worst_coefficients(bytes: &[u8]) -> u64 {
}
/// The compression of a classic TIFF's first image (the one the image crate decodes): 1 when the
/// tag is missing. None when the header can't be read.
/// tag is missing. None when the header can't be read, or when the tag appears more than once:
/// the decoder keeps the last copy, so a harmless first one must not hide a JPEG one after it.
fn tiff_compression(bytes: &[u8]) -> Option<u16> {
let le = match bytes.get(..4)? {
b"II*\0" => true,
@@ -223,21 +281,22 @@ fn tiff_compression(bytes: &[u8]) -> Option<u16> {
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
};
let ifd = usize::try_from(u32_at(4)?).ok()?;
let mut found = None;
for i in 0..usize::from(u16_at(ifd)?) {
let entry = ifd.checked_add(2)?.checked_add(i.checked_mul(12)?)?;
if u16_at(entry)? != 259 {
continue;
}
if u32_at(entry.checked_add(4)?)? != 1 {
if found.is_some() || u32_at(entry.checked_add(4)?)? != 1 {
return None;
}
return match u16_at(entry.checked_add(2)?)? {
3 => u16_at(entry.checked_add(8)?),
4 => u16::try_from(u32_at(entry.checked_add(8)?)?).ok(),
_ => None,
};
found = Some(match u16_at(entry.checked_add(2)?)? {
3 => u16_at(entry.checked_add(8)?)?,
4 => u16::try_from(u32_at(entry.checked_add(8)?)?).ok()?,
_ => return None,
});
}
Some(1)
Some(found.unwrap_or(1))
}
/// A DIB with a V4 or V5 header in the layout the decoder reads. A 32-bit one whose alpha mask
@@ -303,6 +362,39 @@ mod tests {
!c
}
/// `png` with a chunk of `kind` holding `data` placed after IHDR.
fn with_chunk(png: &[u8], kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut chunk = (data.len() as u32).to_be_bytes().to_vec();
chunk.extend_from_slice(kind);
chunk.extend_from_slice(data);
let crc = crc32(&chunk[4..]);
chunk.extend_from_slice(&crc.to_be_bytes());
let mut out = png.get(..33).unwrap().to_vec();
out.extend(chunk);
out.extend_from_slice(&png[33..]);
out
}
#[test]
fn a_png_is_measured_from_its_chunk_headers() {
// An ICC profile isn't inflated to measure a picture: one whose deflate stream is junk
// (the decoder refused it while reading the header) still measures, and isn't decoded.
let rgb = encode(image::RgbImage::new(4, 2), ImageFormat::Png);
let icc = with_chunk(&rgb, b"iCCP", b"p\0\0\xFF\xFF\xFF\xFF");
let p = probe(&icc).unwrap();
assert_eq!((p.width, p.height, p.peak_bytes()), (4, 2, 4 * 2 * 8));
assert!(!p.has_alpha());
assert_eq!(probe(&rgb).unwrap().peak_bytes(), p.peak_bytes(), "as the decoder measures it");
// Transparency: an alpha channel, or tRNS before the image data.
assert!(probe(&encode(image::GrayAlphaImage::new(2, 2), ImageFormat::Png)).unwrap().has_alpha());
let trns = with_chunk(&rgb, b"tRNS", &[0, 0, 0, 0, 0, 0]);
let p = probe(&trns).unwrap();
assert!(p.has_alpha());
assert_eq!(p.decode(1 << 20).map(|i| i.color().has_alpha()), Ok(true), "the decoder agrees");
// No image data: broken.
assert!(probe(&rgb[..33]).is_err());
}
#[test]
fn small_pictures_decode_and_peaks_count_every_buffer() {
let png = encode(image::RgbaImage::from_pixel(16, 8, image::Rgba([1, 2, 3, 4])), ImageFormat::Png);
@@ -410,6 +502,19 @@ mod tests {
assert_eq!(tiff_compression(&be), Some(7), "big-endian: the short is left-justified");
be.truncate(12);
assert_eq!(tiff_compression(&be), None, "cut short");
// A second compression tag (the one the decoder keeps) can't hide behind an allowed first.
let mut twice = b"II*\0".to_vec();
twice.extend(8u32.to_le_bytes());
twice.extend(2u16.to_le_bytes());
for compression in [1u32, 7] {
twice.extend(259u16.to_le_bytes());
twice.extend(3u16.to_le_bytes());
twice.extend(1u32.to_le_bytes());
twice.extend(compression.to_le_bytes());
}
twice.extend(0u32.to_le_bytes());
assert_eq!(tiff_compression(&twice), None, "duplicate compression tags");
assert!(matches!(probe(&twice), Err(Refusal::Unsupported(_))));
// An uncompressed TIFF reads as before.
let tiff = encode(image::RgbImage::from_pixel(5, 3, image::Rgb([9, 8, 7])), ImageFormat::Tiff);
assert_eq!(tiff_compression(&tiff), Some(1));
+1 -4
View File
@@ -239,16 +239,13 @@ impl Alphas {
/// without transparency or that don't decode; [`OverBudget`] for one that would take `decoded`
/// past [`MAX_DECODED_PIXELS`]. Pictures whose header says they're opaque cost nothing.
fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result<Option<Alpha>, OverBudget> {
use image::ImageDecoder;
let Ok(pic) = wordcraft_geom::picture::probe(bytes) else { return Ok(None) };
// Formats that can't be transparent aren't decoded.
if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE {
return Ok(None);
}
// Only the header is read here (a PNG's `tRNS` counts as transparency).
let has_alpha =
image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format).into_decoder().ok().is_some_and(|d| d.color_type().has_alpha());
if !has_alpha {
if !pic.has_alpha() {
return Ok(None);
}
let pixels = u64::from(pic.width) * u64::from(pic.height);
+23 -12
View File
@@ -285,6 +285,13 @@ pub fn page_lines(page: &Page, story: StoryRef) -> Vec<LineHit<'_>> {
v
}
/// Whether line `l` is above (`dir < 0`) or below the caret `c`: past the caret's middle, and
/// not the caret's own line.
fn beyond(l: &LineHit, c: &Caret, dir: i32) -> bool {
let (mid, cur_mid) = ((l.top + l.bottom) / 2.0, c.top + c.height / 2.0);
if dir < 0 { mid < cur_mid - 1.0 && l.bottom <= cur_mid } else { mid > cur_mid + 1.0 && l.top >= cur_mid }
}
/// Of `lines`, the one nearest the caret moving up (`dir < 0`) or down: the lowest above it or
/// the highest below it.
fn nearest<'a, 'b>(lines: &[&'b LineHit<'a>], dir: i32) -> Option<&'b LineHit<'a>> {
@@ -635,18 +642,7 @@ impl DocLayout {
let gx = goal_x.unwrap_or(c.x);
let page = self.pages.get(c.page)?;
let lines = page_lines(page, pos.story);
let cur_mid = c.top + c.height / 2.0;
let cands: Vec<&LineHit> = lines
.iter()
.filter(|l| {
let mid = (l.top + l.bottom) / 2.0;
if dir < 0 {
mid < cur_mid - 1.0 && l.bottom <= c.top + c.height * 0.5
} else {
mid > cur_mid + 1.0 && l.top >= c.top + c.height * 0.5
}
})
.collect();
let cands: Vec<&LineHit> = lines.iter().filter(|l| beyond(l, &c, dir)).collect();
let across: Vec<&LineHit> = cands.iter().copied().filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0).collect();
let target = match nearest(&across, dir) {
// The nearest line across `gx`, unless lines between the caret's paragraph and that
@@ -676,6 +672,21 @@ impl DocLayout {
Some((Pos { story: l.story, path: l.path.clone(), off }, gx))
}
/// Page Up (`dir < 0`) or Page Down within the caret's page: of the lines above (or below)
/// the caret at `pos`, the one nearest page height `y`, on a tie the one horizontally nearest
/// page x `x`; the position at `x` on it. Unlike [`DocLayout::hit`], how far the lines are
/// sideways never keeps the caret on its own line. `None` when no line is that way.
pub fn line_toward(&self, pos: &Pos, page_hint: usize, x: f32, y: f32, dir: i32) -> Option<Pos> {
let c = self.caret_on(pos, page_hint)?;
let page = self.pages.get(c.page)?;
let lines = page_lines(page, pos.story);
let dy = |l: &LineHit| band_dist(y, l.top, l.bottom - l.top);
let dx = |l: &LineHit| (l.left - x).max(x - l.right).max(0.0);
let l = lines.iter().filter(|l| beyond(l, &c, dir)).min_by(|a, b| dy(a).total_cmp(&dy(b)).then(dx(a).total_cmp(&dx(b))))?;
let off = l.para.off_at_x(l.li, x - l.x);
Some(Pos { story: l.story, path: l.path.clone(), off })
}
/// Start and end of the visual line holding `pos` (Home / End).
pub fn line_bounds(&self, pos: &Pos, page_hint: usize) -> Option<(Pos, Pos)> {
for (_, it) in self.pieces(pos.story, &pos.path, page_hint) {
+11
View File
@@ -22,6 +22,17 @@ pub(crate) struct Parked {
}
impl WordApp {
/// Replace the document the window opened with before anything else happens (the first blank
/// document, made again on the region File › Options chose): the session's document is
/// swapped and the window follows it at once. Replacing it with `session.set_document`
/// alone leaves the window showing the old document, so the first frame took the change for
/// a switch and dropped the dialog a command line file had just asked for, a password box
/// among them (#5299).
pub fn replace_startup_document(&mut self, doc: wordcraft_doc::Document) {
self.session.set_document(doc);
self.sync_document();
}
/// Follow the session's active document. When it isn't the one the window last showed, the
/// shown document's own state is put aside (or dropped, if it was closed or replaced), the
/// new one's comes back, and the window's state about the page that was shown starts afresh
+130 -7
View File
@@ -32,10 +32,33 @@ fn shown_text(text: &str) -> String {
shown_keeping(text, usize::MAX, true)
}
/// Most characters of a failure the window shows: the font store's errors can quote the index
/// URL or the server's answer, which aren't ours to trust either.
const MAX_ERROR: usize = 500;
/// A failure from the engine or the font store as shown: [`shown`], at most [`MAX_ERROR`]
/// characters.
fn shown_error(e: &str) -> String {
shown(e, MAX_ERROR)
}
fn shown_keeping(text: &str, max: usize, lines: bool) -> String {
// Line and paragraph separators (U+2028/9) break lines even where `lines` is off, and tag
// characters (U+E0000–U+E007F) are invisible text an index could hide words in.
let format = |c: char| {
(c.is_control() && !(lines && matches!(c, '\n' | '\t')))
|| matches!(c, '\u{061C}' | '\u{00AD}' | '\u{180E}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{206F}' | '\u{FEFF}' | '\u{FFF9}'..='\u{FFFB}')
|| matches!(
c,
'\u{061C}'
| '\u{00AD}'
| '\u{180E}'
| '\u{200B}'..='\u{200F}'
| '\u{2028}'..='\u{202E}'
| '\u{2060}'..='\u{206F}'
| '\u{FEFF}'
| '\u{FFF9}'..='\u{FFFB}'
| '\u{E0000}'..='\u{E007F}'
)
};
let clean: String = text.chars().filter(|c| !format(*c)).collect();
if clean.chars().count() <= max {
@@ -165,9 +188,9 @@ fn error_of(app: &WordApp, locale: &str) -> Option<String> {
}
let e = packs::error(locale)?;
Some(if locale.is_empty() {
crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &e)])
crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &shown_error(&e))])
} else {
crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &e)])
crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &shown_error(&e))])
})
}
@@ -184,7 +207,7 @@ fn consent(app: &mut WordApp, ctx: &egui::Context, key: &str) -> bool {
return false;
}
if let Err(e) = backend.enable_network() {
app.packs_rt.errors.insert(key.to_string(), crate::i18n::fmt(tl!("Couldn't turn on downloading: {error}"), &[("error", &e)]));
app.packs_rt.errors.insert(key.to_string(), crate::i18n::fmt(tl!("Couldn't turn on downloading: {error}"), &[("error", &shown_error(&e))]));
return false;
}
true
@@ -197,7 +220,7 @@ fn start_install(app: &mut WordApp, ctx: &egui::Context, pack: &Pack) {
return;
}
if let Err(e) = packs::start_install(&pack.locale) {
app.packs_rt.errors.insert(pack.locale.clone(), crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &e)]));
app.packs_rt.errors.insert(pack.locale.clone(), crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &shown_error(&e))]));
}
app.packs_rt.missing = None;
}
@@ -210,7 +233,9 @@ fn start_fetch(app: &mut WordApp, ctx: &egui::Context) {
return;
}
if let Err(e) = packs::start_refresh() {
app.packs_rt.errors.insert(String::new(), crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &e)]));
app.packs_rt
.errors
.insert(String::new(), crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &shown_error(&e))]));
}
}
@@ -221,7 +246,7 @@ fn remove(app: &mut WordApp, locale: &str) {
app.packs_rt.missing = None;
}
Err(e) => {
app.packs_rt.errors.insert(locale.to_string(), crate::i18n::fmt(tl!("Couldn't remove: {error}"), &[("error", &e)]));
app.packs_rt.errors.insert(locale.to_string(), crate::i18n::fmt(tl!("Couldn't remove: {error}"), &[("error", &shown_error(&e))]));
}
}
}
@@ -556,11 +581,109 @@ mod tests {
assert_eq!(super::language_of("ja", "日本語"), "Japanese");
}
/// Tests that set the process-wide pack backend take turns.
static BACKEND_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Characters a pack index or a server's answer could use to hide, reorder or break text.
fn hostile(c: char) -> bool {
matches!(c, '\u{2028}' | '\u{2029}' | '\u{202E}' | '\u{E0000}'..='\u{E007F}') || (c.is_control() && c != '\n' && c != '\t')
}
/// FU.2: tag characters (U+E0000–U+E007F) and line/paragraph separators (U+2028/9) are
/// stripped like the other format characters, from names and from licence texts alike.
#[test]
fn shown_strips_tag_characters_and_separators() {
let name = "Ned\u{E0001}\u{E0041}\u{E007F}erlands\u{2028}\u{2029}";
assert_eq!(super::shown(name, 100), "Nederlands");
assert_eq!(super::shown_text("one\u{2028}two\nthree\u{2029}\tfour\u{E0020}"), "onetwo\nthree\tfour");
// A cut never splits what's left, and counts characters, not bytes.
assert_eq!(super::shown("\u{E0041}ab\u{2028}cd", 3), "ab…");
}
/// A store that fails with a hostile message: the index URL with a right-to-left override,
/// separators, tag characters, and far too long.
struct Hostile {
inner: FixedBackend,
message: String,
}
impl Backend for Hostile {
fn index(&self) -> Result<Vec<packs::Pack>, String> {
self.inner.index()
}
fn fetch_index(&self, _: &packs::Cancel) -> Result<Vec<packs::Pack>, String> {
Err(self.message.clone())
}
fn network_on(&self) -> bool {
self.inner.network_on()
}
fn enable_network(&self) -> Result<(), String> {
Err(self.message.clone())
}
fn install(&self, _: &packs::Pack, _: &packs::Cancel, _: &mut dyn FnMut(u64, u64)) -> Result<(), String> {
Err(self.message.clone())
}
fn remove(&self, _: &str) -> Result<(), String> {
Err(self.message.clone())
}
fn packs_dir(&self) -> Option<std::path::PathBuf> {
None
}
}
/// FU.2: the font store's failures (which can quote the index URL and the server's answer)
/// are shown through the same sanitiser as the index's text, whether the window had them
/// (turning downloading on) or the engine did (an install, the list).
#[test]
fn font_store_errors_are_sanitised() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
packs::set_background(None);
packs::set_backend(None);
}
}
let _reset = Reset;
let message = format!("https://evil.example/\u{202E}gnp.exe\u{2028}second line\u{E0041}\u{E0042}{}", "x".repeat(5000));
let backend = std::sync::Arc::new(Hostile { inner: FixedBackend::sample(), message });
packs::set_backend(Some(backend.clone() as std::sync::Arc<dyn Backend>));
let ctx = egui::Context::default();
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let clean = |shown: &str| {
assert!(!shown.chars().any(hostile), "{shown:?}");
assert!(shown.contains("https://evil.example/gnp.exe"), "{shown:?}");
assert!(shown.chars().count() < super::MAX_ERROR + 100, "capped: {} characters", shown.chars().count());
};
// Turning downloading on fails: the window's own failure.
assert!(!super::consent(&mut a, &ctx, "nl"));
clean(&super::error_of(&a, "nl").expect("a failure is shown"));
// An install and the list fail in the engine. Another test's window, drawn meanwhile,
// can move them onto the engine's thread (`tick`): the call then starts the job, and its
// failure is recorded when the job ends.
let failed = |r: Result<bool, String>| {
if r == Ok(true) {
let until = std::time::Instant::now() + std::time::Duration::from_secs(10);
while super::busy() && std::time::Instant::now() < until {
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
r != Ok(false)
};
a.packs_rt.errors.clear();
backend.inner.network.store(true, Ordering::Relaxed);
assert!(failed(packs::start_install("nl")));
clean(&super::error_of(&a, "nl").expect("the install's failure is shown"));
assert!(failed(packs::start_refresh()));
clean(&super::error_of(&a, "").expect("the list's failure is shown"));
}
/// CP4.3 review: a click the control channel made (MCP's `click`, `ui.click`) on the bar's
/// Install never turns on downloading, which is the font store's one setting for fonts too;
/// it says why instead. The person's own click on the same button does, and installs.
#[test]
fn a_faked_click_never_turns_on_downloading() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
+8 -1
View File
@@ -396,7 +396,14 @@ fn language_name(lang: wordcraft_engine::proof::ProofLang) -> &'static str {
ProofLang::Es419 => tl!("Spanish (Latin America)"),
ProofLang::EsUy => tl!("Spanish (Uruguay)"),
// English uses its bundled word list; Bulgarian and German have no dictionary yet.
ProofLang::En | ProofLang::EnGb | ProofLang::EnCa | ProofLang::Bg | ProofLang::De | ProofLang::Pack(_) | ProofLang::Other => "",
ProofLang::En
| ProofLang::EnGb
| ProofLang::EnCa
| ProofLang::EnAu
| ProofLang::Bg
| ProofLang::De
| ProofLang::Pack(_)
| ProofLang::Other => "",
}
}
+30
View File
@@ -2777,6 +2777,36 @@ fn a_clean_document_is_replaced_without_asking() {
assert!(!body_text(&a).trim().is_empty());
}
/// #5299: a password-protected file named on the command line, opened after the first blank
/// document was replaced (a region chosen in File › Options), still asks for its password: the
/// window followed the replacement at once, so the first frame didn't take it for a switch of
/// document and drop the Password box.
#[test]
fn a_command_line_file_still_asks_for_its_password_after_the_startup_document_is_replaced() {
let dir = scratch("password-startup");
let path = dir.join("locked.docx");
let mut s = Session::new(wordcraft_doc::Document::from_text("Locked away"));
s.run("file.save", &json!({"path": path.to_string_lossy(), "password": "sesame"})).unwrap();
let mut a = app();
let blank = a.session.blank_document();
a.replace_startup_document(blank);
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(prompt(&a), Some("password"));
// The frames that follow keep it, and the password opens the file.
a.sync_document();
a.sync_document();
assert_eq!(prompt(&a), Some("password"), "the box survives the first frames");
let params = match &a.dialog {
Some(crate::dialogs::Dialog::Password { params, .. }) => params.clone(),
other => panic!("{:?}", other.as_ref().map(|d| d.name())),
};
let mut with = params;
with["password"] = json!("sesame");
a.run("file.open", with).unwrap();
assert!(body_text(&a).contains("Locked away"), "{}", body_text(&a));
}
/// #55: a password-protected document asks for its password (after Save Changes) instead of
/// opening blank; scripts get the error instead of a dialog; Encrypt with Password asks for one.
#[test]
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long
+7 -4
View File
@@ -1,6 +1,6 @@
# Localization parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only); previously minor (English spelling reads SCOWL 2026 per English, CP2; previously trivial: proofing file paths point at craft-proof in craft-libs and craft-i18n `language-data/`, #5202; previously trivial: word count counts each Han ideograph and kana as a word, #4547; previously trivial: the Traditional Chinese interface draws hanzi to the Taiwan standard with an installed Traditional Chinese font first: PingFang TC, Microsoft JhengHei, Noto Sans CJK TC; previously minor: the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only); previously minor (English spelling reads SCOWL 2026 per English, CP2; previously trivial: proofing file paths point at craft-proof in craft-libs and craft-i18n `language-data/`, #5202; previously trivial: word count counts each Han ideograph and kana as a word, #4547; previously trivial: the Traditional Chinese interface draws hanzi to the Taiwan standard with an installed Traditional Chinese font first: PingFang TC, Microsoft JhengHei, Noto Sans CJK TC; previously minor: the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
Interface languages, script support and proofing per language. How translation works (adding a
language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and
@@ -135,7 +135,7 @@ Set Proofing Language › Set As Default keeps the chosen language in user prefe
| Czech (`cs`, `cs-CZ`, `ces`, `cze`) | The system Hunspell `cs_CZ` dictionary, read at runtime (Debian/Ubuntu: `hunspell-cs`; the same system, LibreOffice and `WORDCRAFT_DICTIONARIES` search paths as Polish). No dictionary data is bundled; without an installed dictionary spelling stays off | suggestions from the loaded dictionary's MAP/REP/TRY/KEY tables, missing spaces and a stem scan; short-word splits rank after deleting an extra letter; NOSUGGEST stems and their derived forms are accepted when typed but excluded from suggestions | Czech messages (including Czech quotation marks) for repeated words, space before punctuation and double spaces only; digit-space-colon-space-digit ratios keep their spaces; no Czech-specific grammar rules | existing English fallback, as for `Other`; no Czech patterns bundled (the TeX/LibreOffice Czech patterns are GPL-2) |
| Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`, `dov'è`, `senz'altro`), truncations keep their apostrophe (`po'`, `mo'`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation, `un pò` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'altro` → `un altro` and `un altra` → `un'altra` (gender from the ending, masculine `-ista`/`-ma`/`-cida`… left alone); these work without a dictionary; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words |
| Turkish (`tr`, any region) | The Hunspell `tr_TR` dictionary installed on the system (same places as Polish; not bundled), read at runtime; proper nouns capitalized, apostrophe suffixes (`Ankara'da`, `WordCraft'ta`) and hyphenated compounds | missing diacritics / ASCII restoration first (`turkce` → `Türkçe`, `isik` → `ışık`), common confusion table (`yalnış` → `yanlış`, `herşey` → `her şey`), detached suffixes (`bende` → `ben de`, `geldinmi` → `geldi mi`), one typo | Turkish messages: repeated word (idiomatic reduplications allowed: `yavaş yavaş`), space before punctuation, double space, sentence capital (knows `vb.`, `vs.`, `prof.`, `dr.`), no comma before/after conjunctions (`ve`, `veya`, `de`), doubled punctuation | Turkish syllable rules (TDK rules); no external pattern file or word list needed |
| German (`de`, any region) | — (no marks yet, #1564) | — | — | TeX German patterns for the reformed spelling (Trennmustermannschaft; MIT), no exception words; Swiss text uses them too. The patterns leave ambiguous compound joints unbroken on purpose (`Staubecken`, `Urinstinkt`), so German text can hyphenate a little less than in Word |
| German (`de`, any region) | Spelling from the German Language pack (language-packs-2): `de-CH` text reads the Swiss pack, other German text the German (Germany) one, Austrian text too for now; no grammar marks yet (#1564) | — | — | TeX German patterns for the reformed spelling (Trennmustermannschaft; MIT), no exception words; Swiss text uses them too. The patterns leave ambiguous compound joints unbroken on purpose (`Staubecken`, `Urinstinkt`), so German text can hyphenate a little less than in Word |
| Indonesian (`id`, `id-ID`, `ind`, legacy `in`) | The system Hunspell `id_ID` dictionary, read at runtime (Debian/Ubuntu: `hunspell-id`; LibreOffice's `dict-id`; the same system, LibreOffice, Dictionaries folder and `WORDCRAFT_DICTIONARIES` search paths as Polish). No dictionary data is bundled; without an installed dictionary spelling stays off. Hyphenated reduplication is accepted whole (`kupu-kupu`) or by parts (`anak-anak`); hyphenated clitics after capitals and abbreviations (`KTP-nya`, `se-Indonesia`) are accepted | the same dictionary-driven ranking as Czech: MAP/REP/TRY/KEY tables of the loaded dictionary, missing spaces and a stem scan; nothing Indonesian hard-coded | Indonesian messages for repeated words (offering the hyphenated reduplication `anak-anak` first, then the single word), space before punctuation and double spaces only; no Indonesian-specific grammar rules | existing English fallback, as for `Other`; no Indonesian patterns bundled |
| Spanish (`es`, `spa`; by region: Spain and no region, `es-MX`, `es-UY`, and `es-419` with the other countries of the Americas) | The system Hunspell dictionary of each variety, read at runtime, the first file name found in this order: Spain `es_ES`, `es_ANY`, `es`; Mexico `es_MX`, `es_419`, `es_ANY`, `es`; Latin America `es_419`, `es_ANY`, `es`, then the ones LibreOffice ships (it has no `es_419`): `es_US`, `es_MX`, `es_AR`; Uruguay `es_UY`, the Rioplatense `es_AR`, `es_419`, `es_ANY`, `es` (each regional name also with a hyphen, `es-MX`); LibreOffice's `dict-es` and Debian/Ubuntu's `hunspell-es` have them (RLA-ES). No dictionary data is bundled; without one the status bar says the variety has no dictionary. The RLA-ES files load since their `☎️` flag is read (#2164). Hyphenated compounds are checked part by part (`teórico-práctico`). Uruguayan Spanish also accepts the `vos` forms of every verb in the dictionary: imperatives (`vení`, `tené`, `andá`), with pronouns (`decime`, `fijate`, `decímelo`) and the present (`tenés`, `querés`) | the same dictionary-driven ranking as Czech, from the variety's dictionary (its MAP restores accents and `ñ` first: `tambien` → `también`; the regional REP tables carry `vosotros` → `ustedes`); in Uruguay a `vos` form typed without its accent gets it back first (`veni` → `vení`) | Spanish messages for repeated words, space before punctuation and double spaces; a closing `?` or `!` without its opening `¿` or `¡` (the sentence with the mark is offered; `¡…?` pairs, `(?)` and `(!)` are allowed) and a space after an opening mark | existing English fallback, as for `Other`; no Spanish patterns bundled |
| Any other | — (no marks) | — | — | the English patterns, as before |
@@ -176,13 +176,15 @@ pinned pack index and installed through the font store into `<font store>/langua
offering it; it never shows for a language without a pack, and closes per document.
- Help › About › Language data shows the full licence texts of the embedded English data and the
installed packs.
- language-packs-1 is published and pinned (61 packs, craft-i18n `4504e288`); Install reads it,
- language-packs-2 is published and pinned (75 packs, craft-i18n `20467b63`), adding the
compounding languages (Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian,
Persian, Esperanto); Mongolian and Thai stay hidden. Install reads it,
or a test index named in `CRAFT_LANGUAGE_PACK_INDEX`. The default build still keeps the
carry-over data embedded (English, plus the German, Polish, Portuguese and Italian patterns and
`ru-extra`) until craft-proof's default embed becomes English only. A Language build embeds English plus the locales named in `CRAFT_PROOF_EMBED`
(`en` alone: English only); `cargo xtask language` checks nothing else is embedded. The web
build is English only.
- Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707), Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438), Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947), French [#1325](https://github.com/storytold/wordcraft/issues/1325), Greek [#2561](https://github.com/storytold/wordcraft/issues/2561), Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806), Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438) are available as Language packs in language-packs-1, with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian and the rest of its 61 packs; Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505), German, Danish, Persian (`COMPOUNDBEGIN`), Esperanto, Estonian, Croatian, Hungarian, Mongolian, Norwegian (`COMPOUNDFLAG`), Swedish (`COMPOUNDPERMITFLAG`) and Thai (word segmentation) wait on craft-proof engine support; Basque, Ukrainian and Uyghur are excluded on licence grounds. A language craft-proof has no rules of its own for
- Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707), Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438), Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947), French [#1325](https://github.com/storytold/wordcraft/issues/1325), Greek [#2561](https://github.com/storytold/wordcraft/issues/2561), Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806), Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438) are available as Language packs, with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian and the rest; language-packs-2 (75 packs) adds Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505), German (Germany, Austria, Switzerland), Danish, Persian, Esperanto, Estonian, Croatian, Hungarian, Norwegian and Swedish through craft-proof's flag-based compounding. Mongolian (slow suggestions) and Thai (word segmentation) wait on craft-proof; Basque, Ukrainian and Uyghur are excluded on licence grounds. A language craft-proof has no rules of its own for
(Lithuanian, Romanian, Dutch…) is spell-checked from its pack alone (`ProofLang::Pack`: the
shared affix engine with default tuning, the pack's hyphenation, no grammar rules).
@@ -252,6 +254,7 @@ WordCraft keeps 2,54 cm).
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | trivial | Language packs: language-packs-2 published and pinned, 75 packs, with Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto |
| 2026-11-02 | trivial | Language packs (CP4.4): language-packs-1 published and pinned, 61 packs; Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian available; Dutch, German and the other compounding languages wait on craft-proof; Basque, Ukrainian and Uyghur excluded |
| 2026-11-01 | minor | Language packs (CP4.3–CP4.5): install and remove a language's spelling and hyphenation from File › Options › Language or the missing-pack bar, About › Language data, the commands; the default build embeds English data only; the language requests point at their packs: Lithuanian and Romanian available once language-packs-1 is published, the others waiting on craft-proof engine support |
| 2026-11-01 | minor | English spelling reads SCOWL 2026 per English (en-US, en-GB and the British-spelling Englishes, en-CA) through the affix engine, ordinals included; `en-variants.txt` still sets each dialect's policy; Moby is the fallback and the hyphenation list (CP2, #40, #520, #1281, #1045) |
+1 -1
View File
@@ -137,7 +137,7 @@ web build has no packs.
A click or key sent with `click`/`key` on Install, Show available languages or Refresh never turns
downloading on either: only the person's own click does; the app shows why instead.
Installing reads the pinned index of craft-i18n's language-packs-1 release (61 languages). For
Installing reads the pinned index of craft-i18n's language-packs-2 release (75 languages). For
testing packs before they are released, set `CRAFT_LANGUAGE_PACK_INDEX` to another index (a URL or a path
to an `index.json`) when starting WordCraft or `wordcraft-cli`. That index isn't pinned, so the
sha256 of each archive only matches what that index says: it catches a broken download, not a
+6 -4
View File
@@ -1,6 +1,6 @@
# Installing languages and spelling dictionaries
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-02 · **Change:** trivial (Language packs: the first set, language-packs-1, is published and pinned: 61 languages, and which aren't packs yet); previously minor (Language packs: install a language's spelling and hyphenation from File › Options › Language, or from the bar a document in that language shows; they are searched after the dictionaries folder and before the system's dictionaries; previously trivial: the Flatpak's own dictionaries folder; previously trivial: WordCraft's own dictionaries folder is searched on macOS and Linux too, as on Windows, so a dictionary dropped there works without a setting; previously minor: ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824)
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-03 · **Change:** trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); Mongolian and Thai aren't packs yet); previously trivial (Language packs: the first set, language-packs-1, is published and pinned: 61 languages, and which aren't packs yet); previously minor (Language packs: install a language's spelling and hyphenation from File › Options › Language, or from the bar a document in that language shows; they are searched after the dictionaries folder and before the system's dictionaries; previously trivial: the Flatpak's own dictionaries folder; previously trivial: WordCraft's own dictionaries folder is searched on macOS and Linux too, as on Windows, so a dictionary dropped there works without a setting; previously minor: ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824)
English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian,
Turkish, Indonesian and Spanish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that
@@ -21,9 +21,10 @@ The easiest way to proof another language is its **Language pack**: the language
dictionary, hyphenation patterns and licence texts, installed and removed together. English is
built in; every other language is a pack.
> The first set of Language packs (language-packs-1) has 61 languages. Dutch, German, Danish,
> Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian, Mongolian, Esperanto and Thai aren't
> packs yet: their dictionaries need spell-checker features that aren't built yet. Basque,
> The current set of Language packs (language-packs-2) has 75 languages, among them Dutch,
> German, Danish, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto.
> Mongolian isn't a pack yet (its suggestions are too slow), nor is Thai (WordCraft can't yet
> split Thai text, which has no spaces, into words). Basque,
> Ukrainian and Uyghur aren't packs: their data's licence doesn't allow it, or isn't clear.
- **File › Options › Language › Language packs** lists the packs, each with its size, its licence
@@ -182,6 +183,7 @@ Never commit dictionary files to this repository: they stay on the user's system
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | trivial | Language packs: language-packs-2 is published and pinned (75 languages, adding the compounding languages); Mongolian and Thai aren't packs yet, and why |
| 2026-11-02 | trivial | Language packs: language-packs-1 is published and pinned (61 languages); which languages aren't packs yet and why (CP4.4) |
| 2026-11-02 | trivial | The Flatpak's own dictionaries folder (`~/.var/app/ai.storyteller.wordcraft/data/wordcraft/dictionaries`); the host's folders are outside its sandbox |
| 2026-11-01 | minor | Language packs (CP4.3): installing a language from File › Options › Language or the missing-pack bar, the download consent, About › Language data, the commands; an installed pack is searched after the dictionaries folders and before the system's |