Files
wordcraft/crates/engine/src/cmd.rs
T

692 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Command implementations, one module per area. Each module exposes `specs()`.
pub mod accessibility;
pub mod canvas;
pub mod caret;
pub mod chart;
pub mod citations;
pub mod column;
pub mod connectors;
pub mod controls;
pub mod design;
pub mod draw;
pub mod edit;
pub mod equation;
pub mod fields;
pub mod file;
pub mod find_pattern;
pub mod fmt_revisions;
pub mod font;
pub mod format;
pub mod index;
pub mod ink_shape;
pub mod insert;
pub mod inspector;
pub mod lists;
pub mod mailings;
pub mod moves;
pub mod objects;
pub mod options;
pub mod outline;
pub mod packs;
pub mod page;
pub mod para;
pub mod paste;
pub mod phonetic;
pub mod protect;
pub mod references;
pub mod review;
pub mod shape_fill;
pub mod smart_art;
pub mod speech;
pub mod table;
pub mod table_draw;
pub mod table_formula;
pub mod table_mouse;
pub mod table_resize;
pub mod table_style;
pub mod templates;
pub mod text;
pub mod tools;
pub mod user_templates;
pub mod view;
pub mod wordart;
use serde_json::{Value, json};
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Pos, Revision, RevisionKind, StoryRef};
use crate::{CmdError, CmdResult, Registry, Session};
/// The full registry.
pub fn registry() -> Registry {
let mut v = Vec::new();
v.extend(text::specs());
v.extend(caret::specs());
v.extend(column::specs());
v.extend(edit::specs());
v.extend(paste::specs());
v.extend(format::specs());
v.extend(font::specs());
v.extend(phonetic::specs());
v.extend(para::specs());
v.extend(lists::specs());
v.extend(inspector::specs());
v.extend(view::specs());
v.extend(outline::specs());
v.extend(insert::specs());
v.extend(equation::specs());
v.extend(page::specs());
v.extend(table::specs());
v.extend(table_style::specs());
v.extend(table_draw::specs());
v.extend(table_resize::specs());
v.extend(table_mouse::specs());
v.extend(review::specs());
v.extend(file::specs());
v.extend(options::specs());
v.extend(packs::specs());
v.extend(design::specs());
v.extend(references::specs());
v.extend(fields::specs());
v.extend(mailings::specs());
v.extend(citations::specs());
v.extend(index::specs());
v.extend(objects::specs());
v.extend(shape_fill::specs());
v.extend(smart_art::specs());
v.extend(chart::specs());
v.extend(draw::specs());
v.extend(canvas::specs());
v.extend(wordart::specs());
v.extend(connectors::specs());
v.extend(tools::specs());
v.extend(templates::specs());
v.extend(user_templates::specs());
v.extend(protect::specs());
v.extend(speech::specs());
v.extend(controls::specs());
Registry::new(v)
}
/// A shortcut that differs on macOS, where Word for Mac moves it (⌘Q quits, ⌘Space is
/// Spotlight, ⌘⌥H hides other apps…): `mac` there, `other` everywhere else.
pub const fn mac_or(mac: &'static str, other: &'static str) -> &'static str {
if cfg!(target_os = "macos") { mac } else { other }
}
/// A shortcut with no macOS equivalent (Option+letter types the character there).
pub const fn non_mac(k: &'static str) -> &'static str {
mac_or("", k)
}
/// Parse a position from JSON (`{"story":"body","path":[0],"off":3}` or `{"block":0,"off":3}`).
pub fn parse_pos(v: &Value) -> Option<Pos> {
if let Ok(p) = serde_json::from_value::<Pos>(v.clone()) {
return Some(p);
}
let block = v.get("block")?.as_u64()? as usize;
let off = v.get("off").and_then(Value::as_u64).unwrap_or(0) as usize;
Some(Pos::body(block, off))
}
pub fn pos_json(p: &Pos) -> Value {
serde_json::to_value(p).unwrap_or(Value::Null)
}
/// The standard result: the selection after the command.
pub fn sel_result(s: &Session) -> CmdResult {
Ok(json!({"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus)}))
}
/// The paragraphs the selection covers: those between its ends (not the next one when it ends at
/// that one's start, #418) or, for whole table cells that aren't one run of the text (part of a
/// column), just the cells' own.
pub fn selected_paths(s: &Session) -> Vec<wordcraft_doc::Path> {
if s.cell_range().is_some()
&& let Some(segs) = s.column_segments()
{
return segs.iter().map(|(a, _)| a.path.clone()).collect();
}
let (a, b) = s.sel.ordered();
s.doc.para_paths_in(&a, &b)
}
/// What one selection deletion actually did, for IME outcome classification.
#[derive(Clone, Debug, Default)]
pub(crate) struct SelectionDelete {
pub at: Pos,
pub content_changed: bool,
pub state_changed: bool,
}
/// Delete the selection (respecting track changes), reporting whether committed content changed.
///
/// Under Track Changes an all-already-deleted range is a real no-op: no revision record is
/// created and the effect says so.
pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete, CmdError> {
let (a, b) = s.sel.ordered();
if a == b {
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
}
if canvas::selected_member(s).is_some() {
return Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into()));
}
controls::check_delete(s, &a, &b)?;
let effect = if s.doc.settings.track_changes {
if !track_delete_would_change(s, &a, &b) {
SelectionDelete { at: a.clone(), content_changed: false, state_changed: false }
} else {
let rid = new_revision(s, RevisionKind::Delete);
let deleted = track_delete_as(s, &a, &b, rid)?;
SelectionDelete { at: deleted.at, content_changed: deleted.changed, state_changed: deleted.changed }
}
} else {
let at = s.doc.delete_range(&a, &b)?;
SelectionDelete { at, content_changed: true, state_changed: true }
};
s.sel = crate::Selection::caret(effect.at.clone());
Ok(effect)
}
/// Delete the selection (respecting track changes); returns the collapsed position. With a
/// Drawing Canvas member picked (#2637) the selection is the whole canvas, which a command that
/// replaces the selection (Insert › Table, Equation, Symbol, Field…) would take away: refused.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
delete_selection_effect(s).map(|e| e.at)
}
/// A revision record for the current author.
pub fn new_revision(s: &mut Session, kind: RevisionKind) -> u32 {
let date = now_iso();
// Never one end of a move, nor the deletion a cut may still turn into one.
let held = s.pending_move.as_ref().map(|m| m.rev);
if let Some((i, _)) = s
.doc
.revisions
.iter()
.enumerate()
.rev()
.find(|(i, r)| r.kind == kind && r.author == s.author && r.date == date && r.move_name.is_none() && held != Some(*i as u32))
{
return i as u32;
}
s.doc.revisions.push(Revision { kind, author: s.author.clone(), date, move_name: None });
(s.doc.revisions.len() - 1) as u32
}
/// Whether the paragraph at `path` is followed by another paragraph in the same container (so
/// its paragraph mark can be removed by joining the two).
pub fn has_next_para(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> bool {
matches!(s.doc.block(story, &path.with_last(path.last().saturating_add(1))), Some(wordcraft_doc::Block::Para(_)))
}
/// Remove the paragraph mark of the paragraph at `path`: the next paragraph in the same container
/// joins it. The joined paragraph keeps this paragraph's properties and ends with the next one's
/// mark (and that mark's revisions). Returns `false`, changing nothing, when no paragraph follows
/// in the container (a story's or cell's last mark can't be removed).
pub fn join_next_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Path) -> Result<bool, CmdError> {
if !has_next_para(s, story, path) {
return Ok(false);
}
let next = path.with_last(path.last().saturating_add(1));
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
let revs = s.doc.para(story, &next).map(|p| (p.mark.ins, p.mark.del)).unwrap_or_default();
s.doc.delete_range(&Pos { story, path: path.clone(), off: len }, &Pos { story, path: next, off: 0 })?;
let p = s.doc.para_mut(story, path)?;
(p.mark.ins, p.mark.del) = revs;
p.touch();
Ok(true)
}
/// Remove the paragraph at `path` when it is empty, carries no section break and another
/// paragraph follows in the same container: the next paragraph keeps its own properties. Returns
/// `false`, changing nothing, otherwise.
pub fn remove_empty_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Path) -> Result<bool, CmdError> {
let empty = s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_none());
if !empty || !has_next_para(s, story, path) {
return Ok(false);
}
s.doc.remove_block(story, path)?;
Ok(true)
}
/// Whether a tracked deletion of `a..b` would change anything: any non-deleted run intersects
/// the range, or a removable/markable paragraph mark lies inside it.
pub(crate) fn track_delete_would_change(s: &Session, a: &Pos, b: &Pos) -> bool {
let paths = s.doc.paths_between(a, b);
for path in &paths {
let Some(p) = s.doc.para(a.story, path) else { continue };
let from = if *path == a.path { a.off } else { 0 };
let to = if *path == b.path { b.off } else { p.len() };
if p.run_ranges().any(|(r, c)| r.end > from && r.start < to && c.del.is_none()) {
return true;
}
if *path != b.path && has_next_para(s, a.story, path) && p.mark.del.is_none() {
return true;
}
}
false
}
/// What one tracked deletion did.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct TrackDeleteEffect {
pub at: Pos,
/// At least one run was removed/marked deleted, or a paragraph mark was removed/marked.
pub changed: bool,
}
/// Tracked deletion: own insertions are removed, other text is marked deleted.
///
/// Preflights first: an all-already-deleted range does nothing and creates no revision record.
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
if !track_delete_would_change(s, a, b) {
return Ok(a.clone());
}
let rid = new_revision(s, RevisionKind::Delete);
track_delete_as(s, a, b, rid).map(|e| e.at)
}
/// [`track_delete`] marking the deleted text with revision `rid`.
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<TrackDeleteEffect, CmdError> {
let author = s.author.clone();
let mut changed = false;
// Remove text this author inserted (it never existed for the reader); mark the rest.
let paths = s.doc.paths_between(a, b);
for path in paths.iter().rev() {
let Some(p) = s.doc.para(a.story, path) else { continue };
let from = if *path == a.path { a.off } else { 0 };
let to = if *path == b.path { b.off } else { p.len() };
// Text already deleted keeps its deletion (and its author), like Word.
let ranges: Vec<(usize, usize, bool)> = p
.run_ranges()
.filter(|(r, c)| r.end > from && r.start < to && c.del.is_none())
.map(|(r, c)| {
let own = c.ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == author);
(r.start.max(from), r.end.min(to), own)
})
.collect();
let para = s.doc.para_mut(a.story, path)?;
for (x, y, own) in ranges.into_iter().rev() {
if own {
para.delete(x, y)?;
} else {
para.format(x, y, &|c| c.del = Some(rid))?;
}
changed = true;
}
// The paragraph mark is inside the range unless this is its last paragraph. Like text,
// a mark this author inserted is removed (the paragraphs join again), any other is
// marked deleted; one already deleted keeps its deletion. Done after the text so a join
// can't pull the next paragraph's text into this one's range.
if *path != b.path && has_next_para(s, a.story, path) {
let (ins, del) = s.doc.para(a.story, path).map(|p| (p.mark.ins, p.mark.del)).unwrap_or_default();
if del.is_none() {
let own = ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == author);
if own {
join_next_para(s, a.story, path)?;
} else {
let p = s.doc.para_mut(a.story, path)?;
p.mark.del = Some(rid);
p.touch();
}
changed = true;
}
}
}
Ok(TrackDeleteEffect { at: a.clone(), changed })
}
/// What one text insertion/deletion commit changed, for IME outcome classification.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct InsertEffect {
pub content_changed: bool,
pub state_changed: bool,
}
/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes.
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, true).map(|_| ())
}
/// [`type_text`] with the effect the insertion had.
pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result<InsertEffect, CmdError> {
insert_text(s, text, true)
}
/// Insert pasted plain text like [`type_text`], except that its paragraph separators are text
/// being transferred, not Enter key presses: an empty list item between them stays (Enter on
/// one would end the list).
pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, false).map(|_| ())
}
/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders,
/// deletes the selection, applies tracked-change insertions, and splits paragraphs at every typed
/// line break or pasted paragraph separator. A pasted separator keeps the list semantics of pasted
/// text; a one-line control suppresses typed breaks only. It gives an otherwise undirected paragraph
/// right-to-left direction when its first strong letter is RTL, and returns whether content or state
/// changed.
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect, CmdError> {
let mut props = s.typing_props();
if s.sel.is_collapsed() {
s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus));
}
// Content controls: a placeholder is replaced, locked ones refuse.
let prepared = controls::prepare_edit(s)?;
let placeholder_changed = prepared.is_some();
if let Some(p) = prepared {
props = p;
}
// Pasted text is text: pending properties left by AutoCorrect don't give it a symbol's font (#2036).
if !enter {
props = props.without_symbol();
}
let deletion = delete_selection_effect(s)?;
// Typed text is new: never deleted, and not a formatting change of the text around it.
props.fmt_change = None;
// Its direction comes from its own letters, as on screen, not from the run it's typed into:
// Latin typed into reopened Hebrew must not be saved as right-to-left (#1543). The DOCX
// writer marks the right-to-left pieces itself.
props.rtl = None;
if s.doc.settings.track_changes && !text.is_empty() {
props.ins = Some(new_revision(s, RevisionKind::Insert));
props.del = None;
} else {
props.ins = None;
props.del = None;
}
let mut at = s.sel.focus.clone();
let mut first = true;
let mut inserted = false;
// Typed line breaks are the Enter key between the lines: in a one-line control it does
// nothing, so the lines run together and the control stays one paragraph (pasted text keeps
// its breaks, as before).
let no_breaks = enter && controls::refuses_paragraph(s);
for part in text.split(['\r', '\u{2029}']) {
if !first && !no_breaks {
at = split_para_as(s, &at, enter)?;
inserted = true;
}
first = false;
let part = part.replace('\u{000B}', "\n");
if !part.is_empty() {
inserted = true;
let starts_para = direction_unset(s, &at);
at = s.doc.insert_text(&at, &part, &props)?;
// Typing Arabic, Hebrew… as the first letters of a paragraph whose direction nothing
// sets makes it a right-to-left paragraph, so the caret and text start at the right
// margin. Paragraphs that already have letters, or a direction, keep theirs.
if starts_para
&& let Ok(p) = s.doc.para_mut(at.story, &at.path)
&& wordcraft_doc::bidi::first_strong_is_rtl(&p.text)
{
p.props.bidi = Some(true);
p.touch();
}
}
}
s.sel = crate::Selection::caret(at);
s.goal_x = None;
let content_changed = deletion.content_changed || inserted;
Ok(InsertEffect { content_changed, state_changed: deletion.state_changed || content_changed || placeholder_changed })
}
/// Is the paragraph at `at` still without letters (empty, or only spaces, digits and
/// punctuation), with no direction set on it, its style chain or the document defaults?
fn direction_unset(s: &Session, at: &Pos) -> bool {
let Some(p) = s.doc.para_at(at) else { return false };
if p.props.bidi.is_some() || p.text.chars().any(char::is_alphabetic) || s.doc.styles.default_para.bidi.is_some() {
return false;
}
let style = p.props.style.as_deref().unwrap_or("Normal");
!s.doc.styles.chain(style).iter().any(|st| st.para.bidi.is_some())
}
/// Take a paragraph out of its list like Word does when Enter or Backspace ends a list: a
/// "List Paragraph" goes back to Normal; any other style keeps itself with numbering switched off.
pub fn leave_list(para: &mut wordcraft_doc::Paragraph) {
if para.props.style.as_deref() == Some("ListParagraph") {
para.props.style = None;
para.props.numbering = None;
} else {
// `num: 0` overrides numbering a style may carry.
para.props.numbering = Some(wordcraft_doc::props::NumRef { num: 0, level: 0 });
}
para.props.indent_left = None;
para.props.indent_first = None;
para.props.indent_left_chars = None;
para.props.indent_first_chars = None;
para.touch();
}
/// Split the paragraph at `at` like Enter does: an empty list paragraph leaves the list, the
/// next paragraph gets the style's "next" style when Enter is at the end.
pub fn split_para(s: &mut Session, at: &Pos) -> Result<Pos, CmdError> {
split_para_as(s, at, true)
}
/// [`split_para`]; without `enter` an empty list item is split like any other paragraph.
fn split_para_as(s: &mut Session, at: &Pos, enter: bool) -> Result<Pos, CmdError> {
// `num: 0` means "explicitly not in a list", so it isn't a list item. The list may come from
// the paragraph's style (List Number 2 in a Word file) rather than the paragraph itself.
let (at_end, style, empty_list) = match s.doc.para_at(at) {
Some(p) => (
at.off >= p.len(),
p.props.style.clone(),
p.props.numbering.or_else(|| s.doc.styles.resolve_para(&p.props).numbering).filter(|n| enter && n.num != 0 && p.is_empty()),
),
None => return Err(CmdError::Failed("no paragraph at caret".into())),
};
if let Some(n) = empty_list {
// Enter on an empty list item: a nested item moves up a level, a top-level one ends
// the list (Word behaviour).
let para = s.doc.para_mut(at.story, &at.path)?;
if n.level > 0 {
para.props.numbering = Some(wordcraft_doc::props::NumRef { num: n.num, level: n.level - 1 });
para.touch();
} else {
leave_list(para);
}
return Ok(at.clone());
}
let mark_revs = s.doc.para_at(at).map(|p| (p.mark.ins, p.mark.del)).unwrap_or_default();
let new = s.doc.split_paragraph(at)?;
if at_end && let Some(st) = style.as_deref() {
let next = s.doc.styles.get(st).and_then(|x| x.next.clone());
if let Some(n) = next
&& n != st
{
let p = s.doc.para_mut(new.story, &new.path)?;
p.props.style = Some(n);
p.props.numbering = None;
// A heading's character formatting doesn't carry into body text.
p.mark = CharProps::default();
}
}
if s.doc.settings.track_changes {
let rid = new_revision(s, RevisionKind::Insert);
let p = s.doc.para_mut(at.story, &at.path)?;
p.mark.ins = Some(rid);
// The paragraph after the split ends with the original mark: it keeps that mark's
// revisions, never those of the text at the split point (which would credit the split
// to the author who inserted that text).
let t = s.doc.para_mut(new.story, &new.path)?;
(t.mark.ins, t.mark.del) = mark_revs;
}
Ok(new)
}
/// ISO-8601 timestamp (UTC, seconds) for `secs` since the Unix epoch.
pub fn iso_from_unix_secs(secs: u64) -> String {
use chrono::Datelike;
let t = secs % 86_400;
let hms = format!("{:02}:{:02}:{:02}", t / 3600, t / 60 % 60, t % 60);
// Within chrono's range, read the date from it; past it, keep the proleptic Gregorian answer
// `civil_from_days` gives rather than falling back to the epoch.
let (y, m, d) = match i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)) {
Some(dt) => (i64::from(dt.year()), dt.month(), dt.day()),
None => civil_from_days((secs / 86_400) as i64),
};
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{m:02}-{d:02}T{hms}Z")
}
/// Seconds since the Unix epoch (UTC): the system clock, or the browser's on the web.
pub fn now_unix() -> u64 {
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
{
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
// `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock.
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
{
let ms = js_sys::Date::now();
// Clamp a NaN or pre-epoch clock to 0 rather than fail.
if ms.is_finite() && ms > 0.0 { (ms / 1000.0) as u64 } else { 0 }
}
}
/// ISO-8601 timestamp (UTC, seconds).
pub fn now_iso() -> String {
iso_from_unix_secs(now_unix())
}
/// The local time zone's offset from UTC in seconds (east positive) at `unix`, daylight saving
/// included: the system's zone, or the browser's on the web. 0 (UTC) when it can't be found.
pub fn local_offset_secs(unix: u64) -> i64 {
system_offset_secs(unix).clamp(-26 * 3600, 26 * 3600)
}
#[cfg(not(target_arch = "wasm32"))]
fn system_offset_secs(unix: u64) -> i64 {
use chrono::Offset;
let Some(utc) = i64::try_from(unix).ok().and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)) else {
return 0;
};
// chrono's `Local` has internal `expect`s (unreadable zone data, an unmapped Windows year), so
// fence it: a failure means UTC, never a crash. A caught panic is still reported by the app's
// panic hook (logged, no abort); the document is untouched.
std::panic::catch_unwind(|| i64::from(utc.with_timezone(&chrono::Local).offset().fix().local_minus_utc())).unwrap_or(0)
}
/// On the web the browser's `Date` gives the offset directly (chrono isn't used there).
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
fn system_offset_secs(unix: u64) -> i64 {
let ms = (unix as f64) * 1000.0;
if !ms.is_finite() {
return 0;
}
// `getTimezoneOffset` is minutes *west* of UTC (UTC+13 gives -780); NaN for an invalid date.
let west_min = js_sys::Date::new(&js_sys::wasm_bindgen::JsValue::from_f64(ms)).get_timezone_offset();
if west_min.is_finite() && west_min.abs() <= 26.0 * 60.0 { -(west_min as i64) * 60 } else { 0 }
}
/// Other wasm targets (WASI) have neither chrono's clock nor a browser: UTC.
#[cfg(all(target_arch = "wasm32", not(target_os = "unknown")))]
fn system_offset_secs(_unix: u64) -> i64 {
0
}
/// The local civil date and time now: see [`local_civil`].
pub fn local_now() -> (i64, u32, u32, (u32, u32, u32), i64) {
let now = now_unix();
local_civil(now, local_offset_secs(now))
}
/// Local civil date and time for an instant: (year, month, day, (hour, minute, second), days since
/// 1970-01-01 of that local day, for the weekday). `offset_secs` is the local zone's offset from
/// UTC (east positive). Saturating, so a hostile offset or clock never overflows.
pub fn local_civil(unix: u64, offset_secs: i64) -> (i64, u32, u32, (u32, u32, u32), i64) {
let local = i64::try_from(unix).unwrap_or(i64::MAX).saturating_add(offset_secs);
let days = local.div_euclid(86_400);
let t = local.rem_euclid(86_400) as u32;
let (y, m, d) = civil_from_days(days);
(y, m, d, (t / 3600, t / 60 % 60, t % 60), days)
}
/// Days since 1970-01-01 → (year, month, day).
///
/// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by
/// [`iso_from_unix_secs`] for a `u64` seconds count past it), so it must stay exact for every
/// `z`. The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the day
/// within its cycle and whole cycles go back into the year (an i128 so i64::MIN/MAX can't
/// overflow). 1970-01-01 is `num_days_from_ce` 719_163, and 0..146_097 days from CE day 0 always
/// fit chrono. The result matches the proleptic Gregorian count the old hand-rolled code gave.
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
}
// Unreachable: `rest + 719_163` is 719_163..865_259, always a valid CE day.
None => (1970, 1, 1),
}
}
/// Story given in params (`"story": "body"` / `{"part": 3}`), else the caret's.
pub fn story_param(s: &Session, v: &Value) -> StoryRef {
v.get("story").and_then(|x| serde_json::from_value(x.clone()).ok()).unwrap_or(s.sel.focus.story)
}
/// The language (`w:lang`) of a paragraph's text: that of its first run with text (else of the
/// paragraph mark), through styles and the document defaults. Sorting and number parsing follow it.
pub fn para_lang(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph) -> Option<String> {
let run = p.runs.iter().find(|r| r.len > 0).map(|r| &r.props).unwrap_or(&p.mark);
doc.styles.resolve_char(p.props.style.as_deref(), run).lang
}
/// The document's default text language (`w:docDefaults`, the Normal style).
pub fn doc_lang(doc: &wordcraft_doc::Document) -> Option<String> {
doc.styles.resolve_char(None, &wordcraft_doc::CharProps::default()).lang
}
/// The language of text that names none: the document's default, else the editing language (a
/// Polish interface writes Polish), else none (English). As AutoCorrect decides it.
pub fn default_lang(s: &Session) -> Option<String> {
doc_lang(&s.doc).or_else(|| s.editing_lang.clone())
}
#[cfg(test)]
mod iso_tests {
use super::{civil_from_days, iso_from_unix_secs};
#[test]
fn formats_epoch_and_known_timestamps() {
assert_eq!(iso_from_unix_secs(0), "1970-01-01T00:00:00Z");
assert_eq!(iso_from_unix_secs(1_760_082_785), "2025-10-10T07:53:05Z");
}
#[test]
fn formats_leap_day_and_year_end() {
assert_eq!(iso_from_unix_secs(1_709_164_800), "2024-02-29T00:00:00Z");
assert_eq!(iso_from_unix_secs(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(iso_from_unix_secs(1_735_689_599), "2024-12-31T23:59:59Z");
}
#[test]
fn formats_dates_outside_chronos_range() {
// Year 10000 is still inside chrono's range: a plain zero-padded year, no leading `+`.
assert_eq!(iso_from_unix_secs(253_402_300_800), "10000-01-01T00:00:00Z");
// Past chrono's range, keep the proleptic Gregorian date instead of the Unix epoch.
assert_eq!(iso_from_unix_secs(10_000_000_000_000), "318857-05-20T17:46:40Z");
}
#[test]
fn civil_from_days_is_exact_beyond_chronos_range() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(20_734), (2026, 10, 8));
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year; values from the
// pre-chrono formula, both directions, both sides of the epoch.
assert_eq!(civil_from_days(95_026_602), (262_144, 1, 1));
assert_eq!(civil_from_days(99_000_000), (273_022, 10, 18));
assert_eq!(civil_from_days(-96_465_964), (-262_145, 3, 1));
assert_eq!(civil_from_days(-99_000_000), (-269_083, 3, 17));
// The extremes must not overflow or panic (i128 intermediates).
let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN));
}
}