Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 8ae631fd42 fonts: refuse WOFF2 collections whose rebuilt directories are huge or repeat a table
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 20:29:14 -05:00
29 changed files with 107 additions and 946 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=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
source = "git+https://github.com/storytold/craft-libs?rev=89fa1559e94378ecef85cd7e4681400f4df053e0#89fa1559e94378ecef85cd7e4681400f4df053e0"
dependencies = [
"log",
"miniz_oxide 0.9.1",
+5 -4
View File
@@ -24,10 +24,11 @@ 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 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-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-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" }
+2 -9
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.replace_startup_document(doc);
app.session.set_document(doc);
}
app.watch_desktop_theme(&cc.egui_ctx);
if prefs_enabled()
-25
View File
@@ -2,7 +2,6 @@
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};
@@ -255,24 +254,10 @@ 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);
@@ -281,16 +266,6 @@ 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") {
+7 -16
View File
@@ -520,25 +520,13 @@ pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool)
if s.layout().caret(&s.sel.focus).is_some() {
return;
}
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 Some(&block) = s.sel.focus.path.0.first() else { return };
let block = block 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) {
@@ -550,5 +538,8 @@ pub(crate) fn shown_pos(s: &mut Session, pos: &Pos, forward: bool) -> Option<Pos
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) };
target.map(|t| s.doc.clamp(&t))
if let Some(t) = target {
let t = s.doc.clamp(&t);
s.sel = Selection::caret(t);
}
}
-67
View File
@@ -326,73 +326,6 @@ 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]
+1 -70
View File
@@ -182,45 +182,14 @@ 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. 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.
/// with the table.
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() };
@@ -386,14 +355,6 @@ 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()))?;
@@ -429,36 +390,6 @@ 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> {
+30 -139
View File
@@ -293,12 +293,8 @@ 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 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 jobs().installs.iter().any(|(l, ..)| l == locale) {
return Err(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}"));
@@ -319,33 +315,11 @@ 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, one per locale.
installs: Vec<InstallJob>,
/// Running installs by locale: (bytes done, bytes in all, its Cancel).
installs: Vec<(String, u64, u64, Cancel)>,
fetching: bool,
/// The last failure of each locale's install ("" for the list), until it is tried again.
errors: Vec<(String, String)>,
@@ -384,9 +358,9 @@ pub struct Progress {
pub total: u64,
}
/// The installs running now (not the cancelled ones still stopping).
/// The installs running now.
pub fn installing() -> Vec<Progress> {
jobs().installs.iter().filter(|j| !j.stopping()).map(|j| Progress { locale: j.locale.clone(), done: j.done, total: j.total }).collect()
jobs().installs.iter().map(|(l, d, t, _)| Progress { locale: l.clone(), done: *d, total: *t }).collect()
}
/// Whether the list is being fetched.
@@ -409,12 +383,10 @@ pub fn finished() -> u64 {
jobs().finished
}
/// 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).
/// Stop `locale`'s running install.
pub fn cancel(locale: &str) {
if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) {
job.restart = false;
job.cancel.cancel();
if let Some((_, _, _, c)) = jobs().installs.iter().find(|(l, ..)| l == locale) {
c.cancel();
}
}
@@ -428,9 +400,7 @@ 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). 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).
/// Returns whether it is still running (`false`: done, or it already was running).
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}"))?;
@@ -448,55 +418,34 @@ pub fn start_install(locale: &str) -> Result<bool, String> {
let cancel = Cancel::new();
{
let mut j = jobs();
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;
}
if j.installs.iter().any(|(l, ..)| l == locale) {
return Ok(true);
}
j.installs.push(InstallJob { locale: locale.to_string(), done: 0, total: pack.size, cancel: cancel.clone(), restart: false });
j.installs.push((locale.to_string(), 0, pack.size, cancel.clone()));
j.errors.retain(|(l, _)| l != locale);
}
let key = locale.to_string();
let locale = locale.to_string();
let started = spawn(move || {
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;
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;
}
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;
progress_notify();
});
let mut j = jobs();
j.installs.retain(|(l, ..)| *l != locale);
if let Err(e) = r {
j.errors.push((locale, e));
}
j.finished = j.finished.wrapping_add(1);
drop(j);
notify();
});
if let Err(e) = started {
jobs().installs.retain(|j| j.locale != key);
jobs().installs.retain(|(l, ..)| *l != key);
return Err(e);
}
Ok(true)
@@ -630,47 +579,12 @@ 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() && !has_pack_for_tag(tag.unwrap_or("")) {
if lang.pack_locale().is_some() && pack_for_tag(tag.unwrap_or(""), &available()).is_none() {
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> {
@@ -716,7 +630,7 @@ impl FixedBackend {
locale: locale.into(),
name: name.into(),
version: "0000000".into(),
url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-2/{locale}.tar.zst"),
url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-1/{locale}.tar.zst"),
source: Some("https://github.com/LibreOffice/dictionaries".into()),
size,
licence: licence.into(),
@@ -799,29 +713,6 @@ 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 second Language pack release,
/// language-packs-2 (75 packs, built from craft-i18n `20467b63`), pinned by the sha256 of its `index.json`.
/// 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`.
/// `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-2/index.json",
sha256: "b35c7aef0591177f18233b551da16035dcd615549ff874e169ffd19f16fea74b",
url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-1/index.json",
sha256: "531465b50126d0594bb89a9c4512f7ae67d581259636040a410190641ec1428c",
};
pub(super) struct FontStoreBackend {
+3 -33
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 = Spans::new(if skip_citations {
let skipped: Vec<(Pos, Pos)> = 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.contains(p);
};
let in_skipped = |p: &Pos| skipped.iter().any(|(a, b)| a <= p && p <= b);
let mut out = Vec::new();
for path in doc.para_paths(story) {
if path < from.path {
@@ -156,36 +156,6 @@ 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,35 +364,3 @@ 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);
}
+2 -222
View File
@@ -4043,8 +4043,7 @@ 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 the TOC keeps it, and
// removing it takes the break with the title, as Word does (#4041).
// The field's paragraph ends the one-column section: replacing or removing the TOC keeps it.
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.
@@ -4053,130 +4052,10 @@ 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), [2, 1], "{}", text(&s));
assert_eq!(columns(&s), [1, 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]
@@ -11882,102 +11761,3 @@ 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::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name};
use names::{file_face_names, font_faces, mini_font, name};
#[cfg(test)]
pub(crate) use names::{last_resort_face, legacy_family};
+3 -10
View File
@@ -61,16 +61,8 @@ 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.chars().take(MAX_NAME_CHARS).collect::<String>()).filter(|s| !s.is_empty())
})
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
}
/// The family's names in other languages: localized typographic family names (name ID 16) stand
@@ -78,6 +70,7 @@ 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();
@@ -88,7 +81,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_NAME_CHARS).collect::<String>().trim().to_string();
let name: String = s.chars().take(MAX_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;
}
+4 -34
View File
@@ -245,24 +245,15 @@ 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> {
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 names: Vec<(u16, String)> = vec![(1, "Var Test".into()), (2, "Regular".into())];
names.extend((0..instances).map(|i| (256 + i, format!("Style {i}"))));
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 (lang, id, text) in &names {
for (id, text) in &names {
let utf16: Vec<u8> = text.encode_utf16().flat_map(u16::to_be_bytes).collect();
for v in [3, 1, *lang, *id, utf16.len() as u16, strings.len() as u16] {
for v in [3, 1, 0x409, *id, utf16.len() as u16, strings.len() as u16] {
name.extend_from_slice(&v.to_be_bytes());
}
strings.extend(utf16);
@@ -366,27 +357,6 @@ 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.
+3 -10
View File
@@ -118,10 +118,6 @@ struct Builder<'a> {
revs: std::collections::HashMap<(u8, RevMark), u32>,
/// Link and font bytes given to runs so far ([`MAX_RUN_STRINGS`]).
run_strings: usize,
/// The ids the next list's abstract and concrete numbering take: past every id in `doc`,
/// found once rather than by a scan of every numbering per list.
next_abstract: u32,
next_num: u32,
}
fn rev_key(kind: RevisionKind) -> u8 {
@@ -141,9 +137,7 @@ impl<'a> Builder<'a> {
revs.entry((rev_key(r.kind), mark)).or_insert(i as u32);
}
}
let next_abstract = doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0);
let next_num = doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
Builder { doc, revs, run_strings: 0, next_abstract, next_num }
Builder { doc, revs, run_strings: 0 }
}
/// The document revision for a tracked change, added the first time it is seen.
@@ -297,10 +291,9 @@ impl<'a> Builder<'a> {
})
.collect();
let numbering = &mut self.doc.numbering;
let (aid, nid) = (self.next_abstract, self.next_num);
self.next_abstract = aid.saturating_add(1);
self.next_num = nid.saturating_add(1);
let aid = numbering.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
numbering.abstracts.push(AbstractNum { id: aid, name: None, levels, ..Default::default() });
let nid = numbering.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
numbering.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
nid
}
-11
View File
@@ -1019,14 +1019,3 @@ fn latex_export_keeps_repeated_spaces() {
let d = crate::import("tex", tex.as_bytes()).unwrap().unwrap();
assert_eq!(para_texts(&d), vec!["a b c".to_string()]);
}
#[test]
fn many_imported_lists_take_fresh_numbering_ids_in_turn() {
// Each list found its ids by scanning every numbering made before it (quadratic in lists).
let md: String = (0..4000).map(|i| if i % 2 == 0 { "- a\n\n" } else { "1. b\n\n" }).collect();
let d = import("md", md.as_bytes()).expect("handled").expect("import");
let ids: Vec<u32> = d.numbering.nums.iter().map(|n| n.id).collect();
assert_eq!(ids, (1..=4000).collect::<Vec<u32>>());
let abstracts: Vec<u32> = d.numbering.abstracts.iter().map(|a| a.id).collect();
assert_eq!(abstracts, (0..4000).collect::<Vec<u32>>());
}
+8 -23
View File
@@ -207,8 +207,7 @@ 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, 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.
/// tag is missing. None when the header can't be read.
fn tiff_compression(bytes: &[u8]) -> Option<u16> {
let le = match bytes.get(..4)? {
b"II*\0" => true,
@@ -224,22 +223,21 @@ 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 found.is_some() || u32_at(entry.checked_add(4)?)? != 1 {
if u32_at(entry.checked_add(4)?)? != 1 {
return 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,
});
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,
};
}
Some(found.unwrap_or(1))
Some(1)
}
/// A DIB with a V4 or V5 header in the layout the decoder reads. A 32-bit one whose alpha mask
@@ -412,19 +410,6 @@ 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));
+12 -23
View File
@@ -285,13 +285,6 @@ 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>> {
@@ -642,7 +635,18 @@ 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 cands: Vec<&LineHit> = lines.iter().filter(|l| beyond(l, &c, dir)).collect();
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 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
@@ -672,21 +676,6 @@ 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,17 +22,6 @@ 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
+7 -130
View File
@@ -32,33 +32,10 @@ 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{2028}'..='\u{202E}'
| '\u{2060}'..='\u{206F}'
| '\u{FEFF}'
| '\u{FFF9}'..='\u{FFFB}'
| '\u{E0000}'..='\u{E007F}'
)
|| 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}')
};
let clean: String = text.chars().filter(|c| !format(*c)).collect();
if clean.chars().count() <= max {
@@ -188,9 +165,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", &shown_error(&e))])
crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &e)])
} else {
crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &shown_error(&e))])
crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &e)])
})
}
@@ -207,7 +184,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", &shown_error(&e))]));
app.packs_rt.errors.insert(key.to_string(), crate::i18n::fmt(tl!("Couldn't turn on downloading: {error}"), &[("error", &e)]));
return false;
}
true
@@ -220,7 +197,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", &shown_error(&e))]));
app.packs_rt.errors.insert(pack.locale.clone(), crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &e)]));
}
app.packs_rt.missing = None;
}
@@ -233,9 +210,7 @@ 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", &shown_error(&e))]));
app.packs_rt.errors.insert(String::new(), crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &e)]));
}
}
@@ -246,7 +221,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", &shown_error(&e))]));
app.packs_rt.errors.insert(locale.to_string(), crate::i18n::fmt(tl!("Couldn't remove: {error}"), &[("error", &e)]));
}
}
}
@@ -581,109 +556,11 @@ 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);
+1 -8
View File
@@ -396,14 +396,7 @@ 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::EnAu
| ProofLang::Bg
| ProofLang::De
| ProofLang::Pack(_)
| ProofLang::Other => "",
ProofLang::En | ProofLang::EnGb | ProofLang::EnCa | ProofLang::Bg | ProofLang::De | ProofLang::Pack(_) | ProofLang::Other => "",
}
}
-30
View File
@@ -2777,36 +2777,6 @@ 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
+2 -3
View File
File diff suppressed because one or more lines are too long
+4 -7
View File
@@ -1,6 +1,6 @@
# Localization parity with Microsoft Word
> **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
> **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
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) | 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 |
| 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 |
| 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,15 +176,13 @@ 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-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,
- language-packs-1 is published and pinned (61 packs, craft-i18n `4504e288`); 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, 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 [#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, 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).
@@ -254,7 +252,6 @@ 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-2 release (75 languages). For
Installing reads the pinned index of craft-i18n's language-packs-1 release (61 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
+4 -6
View File
@@ -1,6 +1,6 @@
# Installing languages and spelling dictionaries
> **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)
> **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)
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,10 +21,9 @@ 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 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,
> 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,
> 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
@@ -183,7 +182,6 @@ 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 |