Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 4ee514b103 Layout: look at the text after a decimal tab once, not once per cluster
Text after a decimal tab is shifted after each of its clusters, and each
shift walked the text again from the tab to find its first '.'. A long run
without a '.' (soft hyphens take no room, so it stays on one line) made a
single paragraph's line breaking quadratic. The scan now carries on from the
cluster it reached.
2026-11-02 22:23:48 -05:00
31 changed files with 181 additions and 1080 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 -8
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.
+19 -97
View File
@@ -154,7 +154,6 @@ 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| {
@@ -222,29 +221,8 @@ 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 out) = (48usize, Vec::with_capacity(usize::from(tables)));
let (mut at, mut offset, mut glyf) = (48usize, 0usize, Vec::new());
for _ in 0..tables {
let flags = *file.get(at)?;
at += 1;
@@ -260,81 +238,13 @@ fn woff2_directory(file: &[u8]) -> Option<(Vec<Woff2Table>, usize)> {
// 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 };
out.push(Woff2Table { transformed_glyf: is_glyf && transformed, len });
}
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);
let end = offset.checked_add(len)?;
if is_glyf && transformed {
glyf.push(stream.get(offset..end)?);
}
offset = end;
}
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,
})
Some(glyf)
}
/// WOFF2's UIntBase128.
@@ -368,7 +278,19 @@ 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 } {
total += read_255_u16(points, &mut at)?;
// 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),
};
if total > 1 << 16 {
return Some(false);
}
-47
View File
@@ -455,50 +455,3 @@ 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())));
}
+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) {
+36 -12
View File
@@ -90,6 +90,8 @@ struct Scan {
j: usize,
/// Tab cluster, stop, and x where the tab started.
pending_tab: Option<(usize, TabStop, f32)>,
/// The text after a pending decimal tab looked at so far for its '.'.
decimal: Option<DecimalScan>,
/// The width of the spaces so far on this line.
space_w: f32,
has_tab: bool,
@@ -111,17 +113,15 @@ fn takes_room(c: &Cluster) -> bool {
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
}
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`.
fn width_to_decimal(pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
let mut w = 0.0;
for k in tj + 1..=j {
let cc = pl.clusters.get(k)?;
if pl_text_at(cc) {
return Some(w);
}
w += cc.adv;
}
None
/// How far the text after a decimal tab has been looked at for its first '.' (see
/// [`Scan::width_to_decimal`]): the tab, the next cluster to look at, the width before it, and
/// whether that cluster is the '.'.
#[derive(Clone, Copy, Default)]
struct DecimalScan {
tab: usize,
next: usize,
width: f32,
found: bool,
}
/// The objects' exclusions mirrored across a line of `width`, for a right-to-left paragraph.
@@ -276,6 +276,7 @@ impl<'e> Breaker<'e> {
end: LineEnd::Para,
j: self.i,
pending_tab: None,
decimal: None,
space_w: 0.0,
has_tab: false,
skip: false,
@@ -839,6 +840,29 @@ impl Scan {
false
}
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`. The
/// text is shifted after each of its clusters, so the clusters already looked at are not
/// walked again: a long run without a '.' after a decimal tab is looked at once, not once
/// per cluster.
fn width_to_decimal(&mut self, pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
let start = DecimalScan { tab: tj, next: tj + 1, width: 0.0, found: false };
let d = self.decimal.get_or_insert(start);
if d.tab != tj || d.next > j.saturating_add(1) {
*d = start;
}
while !d.found && d.next <= j {
crate::work::steps(1);
let cc = pl.clusters.get(d.next)?;
if pl_text_at(cc) {
d.found = true;
} else {
d.width += cc.adv;
d.next += 1;
}
}
d.found.then_some(d.width)
}
/// Text after a decimal/centre/right tab shifts as it grows.
fn shift_pending_tab(&mut self, pl: &mut ParaLayout) {
let Some((tj, stop, tx)) = self.pending_tab else { return };
@@ -847,7 +871,7 @@ impl Scan {
TabAlign::Right => stop.pos - tx - seg_w,
TabAlign::Center => stop.pos - tx - seg_w / 2.0,
// Width up to the first '.' in the segment.
TabAlign::Decimal => stop.pos - tx - width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
TabAlign::Decimal => stop.pos - tx - self.width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
_ => 0.0,
};
let shift = shift.max(0.0);
+22
View File
@@ -482,3 +482,25 @@ fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() {
eprintln!("hinted header: {small} steps for 20 pages, {big} for 160");
assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}");
}
/// A paragraph of a decimal tab at 200 pt followed by `text`.
fn after_decimal_tab(text: &str) -> Document {
use wordcraft_doc::props::{TabAlign, TabStop};
let mut d = Document::from_text(&format!("\t{text}"));
let at = Pos::body(0, 0);
d.format_paragraphs(&at, &at, &|p| p.tabs = Some(vec![TabStop { pos: 200.0, align: TabAlign::Decimal, leader: Default::default() }])).unwrap();
d
}
/// The text after a decimal tab was looked at again from the tab for its '.' after each of its
/// clusters, so a long run of clusters without a '.' (soft hyphens take no room, so they stay on
/// one line) took quadratic time.
#[test]
fn text_after_a_decimal_tab_is_looked_at_once() {
let opts = LayoutOptions::default();
// The text after the tab still ends at the stop, where it ended before.
let l = lay(&after_decimal_tab("12.5"), &opts);
let (Some(end), Some(start)) = (l.caret(&Pos::body(0, 5)), l.caret(&Pos::body(0, 0))) else { panic!("no carets") };
assert!((end.x - start.x - 200.0).abs() < 1.0, "the text ends at the stop: {} from {}", end.x, start.x);
assert_layout_linear("decimal tab", 2000, |n| after_decimal_tab(&format!("1{}", "\u{AD}".repeat(n))), &opts);
}
-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 |