From 1d4f741f23773f47a27a5a017ba47e59629caee5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 07:55:01 +0200 Subject: [PATCH] Citations: eight more bibliography styles (#383) (#388) Citation and bibliography styles are now data: a CiteStyle table entry per style (in-text form, author name format, ordering, entry templates for books and journal articles). Adds Harvard (Anglia), ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST (name sort and title sort) and SIST02 next to APA, MLA, Chicago and IEEE. Numbered styles count in order of first citation (GOST: in its sorted order) and renumber when a citation is inserted. Word's style names and style-sheet file names select the matching style. The References > Style button is now a menu listing every style with the current one checked; its label shows the current style. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/engine/src/cmd/citations.rs | 742 +++++++++++++++++++++++----- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/et.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/sr-latn.tsv | 2 +- crates/ui-egui/src/i18n/sr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- crates/ui-egui/src/keytips.rs | 2 +- crates/ui-egui/src/ribbon.rs | 8 +- docs/gaps.md | 3 +- docs/roadmap.md | 3 +- docs/target-app-parity.md | 5 +- 16 files changed, 644 insertions(+), 139 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d976d87..8698566 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) | | 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents | | 2026-10-11 | Formatting revisions (#41): `w:rPrChange`/`w:pPrChange`, table/row/cell, section and numbering changes kept in DOCX; formatting recorded as revisions while tracking; accept/reject, Reviewing Pane descriptions, "Formatted: …" balloons and change bars | | 2026-10-10 | Estonian (`et`) interface (#294): 1,161 translated entries, locale and saved-preference tests, bundled-font checks; no Estonian proofing resources | @@ -151,6 +152,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) | | 2026-10-10 | trivial | Progress log: Draw tab ink (#307) | | 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) | | 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) | diff --git a/crates/engine/src/cmd/citations.rs b/crates/engine/src/cmd/citations.rs index 0e6cdd1..68d6126 100644 --- a/crates/engine/src/cmd/citations.rs +++ b/crates/engine/src/cmd/citations.rs @@ -1,15 +1,290 @@ -//! References: citations and bibliography (APA, MLA, Chicago, IEEE), source manager, index, -//! table of figures, cross-references, table of authorities, footnote options. +//! References: citations and bibliography (APA, MLA, Chicago, IEEE, Harvard, ISO 690, Turabian, +//! GB/T 7714, GOST, SIST02), source manager, index, table of figures, cross-references, table +//! of authorities, footnote options. use serde_json::{Value, json}; use wordcraft_doc::para::InlineObject; use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop}; -use wordcraft_doc::{Block, Paragraph, Path, Pos, Source, StoryRef}; +use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, Source, StoryRef}; use super::{delete_selection, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; -pub const STYLES: [&str; 4] = ["APA", "MLA", "Chicago", "IEEE"]; +/// How a style orders its bibliography (and, for numbered styles, numbers its citations). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Order { + /// Alphabetical by the first author's surname (the title when there is no author). + Author, + /// Alphabetical by title. + Title, + /// In order of first citation in the document. + Cited, +} + +/// How one author's name is written in a bibliography entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Name { + /// Rivera, Alex + LastFirst, + /// Alex Rivera + FirstLast, + /// Rivera, A. + LastInitials, + /// A. Rivera + InitialsLast, + /// Rivera A. + LastSpaceInitials, + /// RIVERA, Alex + UpperLastFirst, + /// RIVERA A + UpperLastBareInitials, +} + +/// The author list of a bibliography entry. +#[derive(Clone, Copy, Debug)] +pub struct Names { + /// The first author's form, and every later author's. + pub first: Name, + pub rest: Name, + /// Between names; between exactly two; before the last of three or more. + pub sep: &'static str, + pub two: &'static str, + pub last: &'static str, + /// List at most this many authors, then `et_al` (0: list them all). + pub max: usize, + pub et_al: &'static str, +} + +/// The in-text citation. +#[derive(Clone, Copy, Debug)] +pub enum Cite { + /// `(WhoYearPages)`. + AuthorDate { year_sep: &'static str, pages_sep: &'static str }, + /// `(Who Pages)`. + AuthorPage, + /// `n`; pages follow `pages_sep` inside the brackets (left out when `None`). + Number { open: &'static str, close: &'static str, pages_sep: Option<&'static str> }, +} + +/// A citation and bibliography style, as data. Entry templates fill `{n}`, `{names}`, `{year}`, +/// `{title}`, `{city}`, `{publisher}`, `{pub}` (city: publisher), `{journal}`, `{volume}`, +/// `{pages}`, `{url}` and `{type}` (GB/T 7714 document type code); text between `⟦` and `⟧` is +/// left out when a field in it is empty, and a full stop never doubles up. +#[derive(Debug)] +pub struct CiteStyle { + /// The name shown in the style picker and stored in the session. + pub name: &'static str, + /// Lowercase letter/digit prefixes of Word's style names and style-sheet file names that mean + /// this style (`gostname` matches "GOST - Name Sort"); the longest match wins. + pub aliases: &'static [&'static str], + pub cite: Cite, + /// Joins two authors in a citation ("&" or "and"). + pub and: &'static str, + /// Cite this many authors or more as "First et al.". + pub et_al_from: usize, + pub order: Order, + pub names: Names, + /// Entry template for a book (or anything without a journal), and for a journal article. + pub book: &'static str, + pub article: &'static str, + /// Default title of the bibliography. + pub bib_title: &'static str, +} + +impl CiteStyle { + /// Numbered styles cite `[1]`/`(1)` and list entries without a hanging indent. + pub fn numbered(&self) -> bool { + matches!(self.cite, Cite::Number { .. }) + } +} + +const fn names(first: Name, rest: Name, sep: &'static str, two: &'static str, last: &'static str) -> Names { + Names { first, rest, sep, two, last, max: 0, et_al: "" } +} + +const AUTHOR_DATE: Cite = Cite::AuthorDate { year_sep: " ", pages_sep: ", p. " }; + +/// Every style, in picker order (alphabetical). Formats follow the published style guides' +/// rules for the source types WordCraft records, written in our own words. +pub static STYLES: [CiteStyle; 12] = [ + CiteStyle { + name: "APA", + aliases: &["apa"], + cite: Cite::AuthorDate { year_sep: ", ", pages_sep: ", p. " }, + and: "&", + et_al_from: 3, + order: Order::Author, + names: names(Name::LastInitials, Name::LastInitials, ", ", ", & ", ", & "), + book: "⟦{names} ⟧({year}). {title}.⟦ {publisher}.⟧⟦ {url}⟧", + article: "⟦{names} ⟧({year}). {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧", + bib_title: "References", + }, + CiteStyle { + name: "Chicago", + aliases: &["chicago"], + cite: Cite::AuthorDate { year_sep: " ", pages_sep: ", " }, + and: "and", + et_al_from: 3, + order: Order::Author, + names: names(Name::LastFirst, Name::FirstLast, ", ", ", and ", ", and "), + book: "⟦{names}. ⟧{year}. {title}.⟦ {pub}.⟧⟦ {url}⟧", + article: "⟦{names}. ⟧{year}. {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧", + bib_title: "References", + }, + // GB/T 7714 sequence-coding: numbered by first citation, surnames in capitals with bare + // initials, at most three authors, a document type code after the title. + CiteStyle { + name: "GB/T 7714", + aliases: &["gb"], + cite: Cite::Number { open: "[", close: "]", pages_sep: Some(", ") }, + and: "and", + et_al_from: 3, + order: Order::Cited, + names: Names { max: 3, et_al: ", et al.", ..names(Name::UpperLastBareInitials, Name::UpperLastBareInitials, ", ", ", ", ", ") }, + book: "[{n}] ⟦{names}. ⟧{title}[{type}].⟦ {pub},⟧ {year}.⟦ {url}.⟧", + article: "[{n}] ⟦{names}. ⟧{title}[{type}]. {journal}, {year}⟦, {volume}⟧⟦: {pages}⟧.⟦ {url}.⟧", + bib_title: "References", + }, + // GOST 7.1: a numbered list in alphabetical order (by author or by title); areas separated + // by " – ", the container after "//". + CiteStyle { + name: "GOST (name sort)", + aliases: &["gost", "gostname"], + cite: Cite::Number { open: "[", close: "]", pages_sep: Some(", p. ") }, + and: "and", + et_al_from: 3, + order: Order::Author, + names: names(Name::LastSpaceInitials, Name::LastSpaceInitials, ", ", ", ", ", "), + book: GOST_BOOK, + article: GOST_ARTICLE, + bib_title: "References", + }, + CiteStyle { + name: "GOST (title sort)", + aliases: &["gosttitle"], + cite: Cite::Number { open: "[", close: "]", pages_sep: Some(", p. ") }, + and: "and", + et_al_from: 3, + order: Order::Title, + names: names(Name::LastSpaceInitials, Name::LastSpaceInitials, ", ", ", ", ", "), + book: GOST_BOOK, + article: GOST_ARTICLE, + bib_title: "References", + }, + // Harvard as taught by Anglia Ruskin: "Surname, I., Year. Title. Place: Publisher." + CiteStyle { + name: "Harvard (Anglia)", + aliases: &["harvard"], + cite: Cite::AuthorDate { year_sep: ", ", pages_sep: ", p. " }, + and: "and", + et_al_from: 4, + order: Order::Author, + names: names(Name::LastInitials, Name::LastInitials, ", ", " and ", " and "), + book: "⟦{names}, ⟧{year}. {title}.⟦ {pub}.⟧⟦ Available at: <{url}>.⟧", + article: "⟦{names}, ⟧{year}. {title}. {journal}⟦, {volume}⟧⟦, pp. {pages}⟧.⟦ Available at: <{url}>.⟧", + bib_title: "References", + }, + CiteStyle { + name: "IEEE", + aliases: &["ieee"], + cite: Cite::Number { open: "[", close: "]", pages_sep: None }, + and: "and", + et_al_from: 3, + order: Order::Cited, + names: names(Name::InitialsLast, Name::InitialsLast, ", ", ", ", ", "), + book: "[{n}] ⟦{names}, ⟧“{title},”⟦ {pub},⟧ {year}.⟦ {url}⟧", + article: "[{n}] ⟦{names}, ⟧“{title},” {journal}⟦, {volume}⟧⟦, {pages}⟧, {year}.⟦ {url}⟧", + bib_title: "References", + }, + // ISO 690 first element and date: surnames in capitals, the year after the names. + CiteStyle { + name: "ISO 690 (author-date)", + aliases: &["iso690"], + cite: AUTHOR_DATE, + and: "and", + et_al_from: 4, + order: Order::Author, + names: names(Name::UpperLastFirst, Name::UpperLastFirst, ", ", " and ", " and "), + book: "⟦{names}, ⟧{year}. {title}.⟦ {pub}.⟧⟦ Available from: {url}⟧", + article: "⟦{names}, ⟧{year}. {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ Available from: {url}⟧", + bib_title: "References", + }, + // ISO 690 numeric reference: numbered by first citation, the year with the imprint. + CiteStyle { + name: "ISO 690 (numerical)", + aliases: &["iso690num", "iso690nmerical"], + cite: Cite::Number { open: "(", close: ")", pages_sep: Some(", p. ") }, + and: "and", + et_al_from: 4, + order: Order::Cited, + names: names(Name::UpperLastFirst, Name::UpperLastFirst, ", ", " and ", " and "), + book: "{n}. ⟦{names}. ⟧{title}.⟦ {pub},⟧ {year}.⟦ Available from: {url}⟧", + article: "{n}. ⟦{names}. ⟧{title}. {journal}. {year}⟦, {volume}⟧⟦, {pages}⟧.⟦ Available from: {url}⟧", + bib_title: "References", + }, + CiteStyle { + name: "MLA", + aliases: &["mla"], + cite: Cite::AuthorPage, + and: "and", + et_al_from: 3, + order: Order::Author, + names: Names { max: 1, et_al: ", et al.", ..names(Name::LastFirst, Name::LastFirst, ", ", ", ", ", ") }, + book: "⟦{names}. ⟧“{title}.”⟦ {publisher},⟧ {year}.⟦ {url}⟧", + article: "⟦{names}. ⟧“{title}.” {journal}⟦, {volume}⟧⟦, {pages}⟧, {year}.⟦ {url}⟧", + bib_title: "Works Cited", + }, + // SIST 02: numbered by first citation, full names separated by semicolons. + CiteStyle { + name: "SIST02", + aliases: &["sist"], + cite: Cite::Number { open: "(", close: ")", pages_sep: Some(", p. ") }, + and: "and", + et_al_from: 3, + order: Order::Cited, + names: names(Name::LastFirst, Name::LastFirst, "; ", "; ", "; "), + book: "{n}) ⟦{names}. ⟧{title}.⟦ {city},⟧⟦ {publisher},⟧ {year}.⟦ {url}⟧", + article: "{n}) ⟦{names}. ⟧{title}. {journal}. {year}⟦, vol. {volume}⟧⟦, p. {pages}⟧.⟦ {url}⟧", + bib_title: "References", + }, + // Turabian: author-date citations, a bibliography with the year in the imprint. + CiteStyle { + name: "Turabian", + aliases: &["turabian"], + cite: Cite::AuthorDate { year_sep: " ", pages_sep: ", " }, + and: "and", + et_al_from: 4, + order: Order::Author, + names: names(Name::LastFirst, Name::FirstLast, ", ", ", and ", ", and "), + book: "⟦{names}. ⟧{title}.⟦ {pub},⟧ {year}.⟦ {url}.⟧", + article: "⟦{names}. ⟧“{title}.” {journal}⟦ {volume}⟧ ({year})⟦: {pages}⟧.⟦ {url}.⟧", + bib_title: "Bibliography", + }, +]; + +const GOST_BOOK: &str = "{n}. ⟦{names} ⟧{title}. –⟦ {city} :⟧⟦ {publisher},⟧ {year}.⟦ – URL: {url}.⟧"; +const GOST_ARTICLE: &str = "{n}. ⟦{names} ⟧{title} // {journal}. – {year}.⟦ – Vol. {volume}.⟧⟦ – P. {pages}.⟧⟦ – URL: {url}.⟧"; + +/// The style called `name`: one of ours (any case), or Word's style name or style-sheet file +/// name (`Harvard - Anglia`, `\GostTitle.XSL`). +pub fn find_style(name: &str) -> Option<&'static CiteStyle> { + if let Some(st) = STYLES.iter().find(|x| x.name.eq_ignore_ascii_case(name.trim())) { + return Some(st); + } + let key: String = name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect(); + let key = key.strip_suffix("xsl").unwrap_or(&key); + STYLES + .iter() + .flat_map(|st| st.aliases.iter().map(move |a| (st, *a))) + .filter(|(_, a)| key.starts_with(a)) + .max_by_key(|(_, a)| a.len()) + .map(|(st, _)| st) +} + +/// The session's style; APA when the name is unknown. +pub fn style(name: &str) -> &'static CiteStyle { + find_style(name).unwrap_or(&STYLES[0]) +} pub fn specs() -> Vec { vec![ @@ -18,14 +293,18 @@ pub fn specs() -> Vec { CommandSpec::new("references.citation", "Insert Citation", "References › Citations & Bibliography", citation).params(r#"{"tag"?: string, "source"?: Source (added if new), "pages"?: string}"#), CommandSpec::new("references.citationStyle", "Style", "References › Citations & Bibliography", |s, v| { let st = p::str(v, "style").unwrap_or("APA"); - let st = STYLES.iter().find(|x| x.eq_ignore_ascii_case(st)).ok_or_else(|| CmdError::Params(format!("style must be one of {STYLES:?}")))?; - s.bib_style = st.to_string(); + let st = find_style(st).ok_or_else(|| { + CmdError::Params(format!("style must be one of {:?}", STYLES.iter().map(|x| x.name).collect::>())) + })?; + s.bib_style = st.name.to_string(); update_citations(s)?; Ok(json!({"style": s.bib_style})) }) - .params(r#"{"style": "APA|MLA|Chicago|IEEE"}"#), + .params( + r#"{"style": "APA|Chicago|GB/T 7714|GOST (name sort)|GOST (title sort)|Harvard (Anglia)|IEEE|ISO 690 (author-date)|ISO 690 (numerical)|MLA|SIST02|Turabian" (Word's style names work too)}"#, + ), CommandSpec::new("references.bibliography", "Bibliography", "References › Citations & Bibliography", |s, v| { - let title = p::str(v, "title").unwrap_or(if s.bib_style == "MLA" { "Works Cited" } else { "References" }).to_string(); + let title = p::str(v, "title").unwrap_or(style(&s.bib_style).bib_title).to_string(); generated_list(s, "BIBLIOGRAPHY", &title)?; sel_result(s) }) @@ -109,102 +388,155 @@ fn authors(a: &str) -> Vec<(String, String)> { .collect() } -fn initials(first: &str) -> String { - first.split_whitespace().filter_map(|w| w.chars().next()).map(|c| format!("{c}.")).collect::>().join(" ") +/// "Alex Mei" → "A. M." (`dots`), or "A M" without them. +fn initials(first: &str, dots: bool) -> String { + let sep = if dots { ". " } else { " " }; + let mut out = first.split_whitespace().filter_map(|w| w.chars().next()).map(String::from).collect::>().join(sep); + if dots && !out.is_empty() { + out.push('.'); + } + out } -/// In-text citation for a style. -pub fn cite(src: &Source, style: &str, n: usize, pages: &str) -> String { +fn name(form: Name, last: &str, first: &str) -> String { + let join = |a: String, sep: &str, b: String| if a.is_empty() || b.is_empty() { format!("{a}{b}") } else { format!("{a}{sep}{b}") }; + match form { + Name::LastFirst => join(last.to_string(), ", ", first.to_string()), + Name::FirstLast => join(first.to_string(), " ", last.to_string()), + Name::LastInitials => join(last.to_string(), ", ", initials(first, true)), + Name::InitialsLast => join(initials(first, true), " ", last.to_string()), + Name::LastSpaceInitials => join(last.to_string(), " ", initials(first, true)), + Name::UpperLastFirst => join(last.to_uppercase(), ", ", first.to_string()), + Name::UpperLastBareInitials => join(last.to_uppercase(), " ", initials(first, false)), + } +} + +fn name_list(au: &[(String, String)], form: &Names) -> String { + let shown = if form.max > 0 { au.get(..form.max).unwrap_or(au) } else { au }; + let parts: Vec = shown.iter().enumerate().map(|(i, (l, f))| name(if i == 0 { form.first } else { form.rest }, l, f)).collect(); + let mut out = match parts.as_slice() { + [] => String::new(), + [one] => one.clone(), + [a, b] => format!("{a}{}{b}", form.two), + [init @ .., z] => format!("{}{}{z}", init.join(form.sep), form.last), + }; + if shown.len() < au.len() { + out.push_str(form.et_al); + } + out +} + +/// Append `s`, dropping its leading full stop when `out` already ends with one ("M." + ". "). +fn push_text(out: &mut String, s: &str) { + match s.strip_prefix('.') { + Some(rest) if out.ends_with('.') => out.push_str(rest), + _ => out.push_str(s), + } +} + +/// Fill one template segment; `None` when `optional` and a field in it is empty. +fn fill_segment(seg: &str, field: &dyn Fn(&str) -> String, optional: bool) -> Option { + let mut out = String::new(); + let mut rest = seg; + while let Some((before, after)) = rest.split_once('{') { + push_text(&mut out, before); + let Some((key, after)) = after.split_once('}') else { + push_text(&mut out, after); + return Some(out); + }; + let value = field(key); + if value.is_empty() && optional { + return None; + } + push_text(&mut out, &value); + rest = after; + } + push_text(&mut out, rest); + Some(out) +} + +/// Fill an entry template (see [`CiteStyle`]). +fn render(template: &str, field: &dyn Fn(&str) -> String) -> String { + let mut out = String::new(); + let mut rest = template; + while !rest.is_empty() { + let (seg, optional, after) = match rest.strip_prefix('⟦') { + Some(group) => match group.split_once('⟧') { + Some((g, after)) => (g, true, after), + None => (group, true, ""), + }, + None => match rest.split_once('⟦') { + Some((plain, _)) => (plain, false, rest.get(plain.len()..).unwrap_or("")), + None => (rest, false, ""), + }, + }; + if let Some(text) = fill_segment(seg, field, optional) { + push_text(&mut out, &text); + } + rest = after; + } + out +} + +/// In-text citation for a style; `n` is the source's number in numbered styles. +pub fn cite(src: &Source, style_name: &str, n: usize, pages: &str) -> String { + let st = style(style_name); let au = authors(&src.author); let lasts: Vec<&str> = au.iter().map(|a| a.0.as_str()).collect(); - let who = match lasts.len() { - 0 => src.title.clone(), - 1 => lasts.first().copied().unwrap_or("").to_string(), - 2 => format!("{} {} {}", lasts.first().copied().unwrap_or(""), if style == "APA" { "&" } else { "and" }, lasts.get(1).copied().unwrap_or("")), - _ => format!("{} et al.", lasts.first().copied().unwrap_or("")), + let who = match lasts.as_slice() { + [] => src.title.clone(), + [one] => one.to_string(), + [first, ..] if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."), + [a, b] => format!("{a} {} {b}", st.and), + [init @ .., z] => format!("{} {} {z}", init.join(", "), st.and), }; - match style { - "MLA" => { - if pages.is_empty() { - format!("({who})") - } else { - format!("({who} {pages})") - } - } - "Chicago" => { - if pages.is_empty() { - format!("({who} {})", src.year) - } else { - format!("({who} {}, {pages})", src.year) - } - } - "IEEE" => format!("[{n}]"), - _ => { - if pages.is_empty() { - format!("({who}, {})", src.year) - } else { - format!("({who}, {}, p. {pages})", src.year) - } - } + match st.cite { + Cite::AuthorPage if pages.is_empty() => format!("({who})"), + Cite::AuthorPage => format!("({who} {pages})"), + Cite::AuthorDate { year_sep, .. } if pages.is_empty() => format!("({who}{year_sep}{})", src.year), + Cite::AuthorDate { year_sep, pages_sep } => format!("({who}{year_sep}{}{pages_sep}{pages})", src.year), + Cite::Number { open, close, pages_sep: Some(sep) } if !pages.is_empty() => format!("{open}{n}{sep}{pages}{close}"), + Cite::Number { open, close, .. } => format!("{open}{n}{close}"), } } -/// Bibliography entry for a style. -pub fn entry(src: &Source, style: &str, n: usize) -> String { - let au = authors(&src.author); - let year = if src.year.is_empty() { "n.d.".to_string() } else { src.year.clone() }; - let pubinfo = [src.city.as_str(), src.publisher.as_str()].iter().filter(|x| !x.is_empty()).copied().collect::>().join(": "); - let journal = if src.journal.is_empty() { - String::new() - } else { - format!( - "{}{}{}", - src.journal, - if src.volume.is_empty() { String::new() } else { format!(", {}", src.volume) }, - if src.pages.is_empty() { String::new() } else { format!(", {}", src.pages) } - ) +/// GB/T 7714 document type code: M book, J journal article, R report, EB/OL web page, Z other; +/// "/OL" marks a source read online. +fn gb_type(src: &Source) -> String { + let code = match src.kind.to_ascii_lowercase().as_str() { + "website" | "web" => return "EB/OL".into(), + _ if !src.journal.is_empty() => "J", + "book" | "" => "M", + "article" => "J", + "report" => "R", + _ => "Z", }; - let url = if src.url.is_empty() { String::new() } else { format!(" {}", src.url) }; - match style { - "MLA" => { - let names = match au.len() { - 0 => String::new(), - 1 => au.first().map(|(l, f)| format!("{l}, {f}. ")).unwrap_or_default(), - _ => au.first().map(|(l, f)| format!("{l}, {f}, et al. ")).unwrap_or_default(), - }; - let container = if journal.is_empty() { src.publisher.clone() } else { journal }; - format!("{names}\u{201C}{}.\u{201D} {container}, {year}.{url}", src.title) + if src.url.is_empty() { code.into() } else { format!("{code}/OL") } +} + +/// Bibliography entry for a style; `n` is the source's number in numbered styles. +pub fn entry(src: &Source, style_name: &str, n: usize) -> String { + let st = style(style_name); + let au = authors(&src.author); + let field = |key: &str| -> String { + match key { + "n" => n.to_string(), + "names" => name_list(&au, &st.names), + "year" if src.year.is_empty() => "n.d.".into(), + "year" => src.year.clone(), + "title" => src.title.clone(), + "city" => src.city.clone(), + "publisher" => src.publisher.clone(), + "pub" => [src.city.as_str(), src.publisher.as_str()].into_iter().filter(|x| !x.is_empty()).collect::>().join(": "), + "journal" => src.journal.clone(), + "volume" => src.volume.clone(), + "pages" => src.pages.clone(), + "url" => src.url.clone(), + "type" => gb_type(src), + _ => String::new(), } - "Chicago" => { - let names = au - .iter() - .enumerate() - .map(|(i, (l, f))| if i == 0 { format!("{l}, {f}") } else { format!("{f} {l}") }) - .collect::>() - .join(", and "); - let rest = if journal.is_empty() { pubinfo } else { journal }; - format!("{names}. {year}. {}. {rest}.{url}", src.title) - } - "IEEE" => { - let names = au.iter().map(|(l, f)| format!("{} {l}", initials(f))).collect::>().join(", "); - let rest = if journal.is_empty() { pubinfo } else { journal }; - format!("[{n}] {names}, \u{201C}{},\u{201D} {rest}, {year}.{url}", src.title) - } - _ => { - let names = au.iter().map(|(l, f)| format!("{l}, {}", initials(f))).collect::>(); - let names = match names.len() { - 0 => String::new(), - 1 => names.first().cloned().unwrap_or_default(), - _ => format!( - "{}, & {}", - names.get(..names.len() - 1).map(|x| x.join(", ")).unwrap_or_default(), - names.last().cloned().unwrap_or_default() - ), - }; - let rest = if journal.is_empty() { src.publisher.clone() } else { journal }; - format!("{names} ({year}). {}. {rest}.{url}", src.title) - } - } + }; + render(if src.journal.is_empty() { st.book } else { st.article }, &field) } fn sources(s: &mut Session, v: &Value) -> CmdResult { @@ -243,18 +575,90 @@ fn citation(s: &mut Session, v: &Value) -> CmdResult { }; let src = s.doc.sources.iter().find(|x| x.tag == tag).cloned().ok_or_else(|| CmdError::Params(format!("no source `{tag}`")))?; let pages = p::str(v, "pages").unwrap_or("").to_string(); - let n = s.doc.sources.iter().position(|x| x.tag == tag).unwrap_or(0) + 1; + let st = style(&s.bib_style); + let n = number_of(&s.doc, &bib_order(&s.doc, st), &tag); let props = s.typing_props(); let at = delete_selection(s)?; let instr = if pages.is_empty() { format!("CITATION {tag}") } else { format!("CITATION {tag} \\p {pages}") }; - let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: cite(&src, &s.bib_style, n, &pages), locked: false }, &props)?; + let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: cite(&src, st.name, n, &pages), locked: false }, &props)?; s.sel = Selection::caret(end); + // A new citation can renumber the others (numbered styles count in order of first citation). + if st.numbered() { + refresh_citations(s)?; + } Ok(json!({"tag": tag})) } +/// The tag and `\p` pages of a CITATION field instruction. +fn citation_field(instr: &str) -> Option<(&str, &str)> { + let mut words = instr.split_whitespace(); + if words.next()? != "CITATION" { + return None; + } + let tag = words.next()?; + let pages = instr.split("\\p").nth(1).map(str::trim).unwrap_or(""); + Some((tag, pages)) +} + +/// Tags cited in the body, in order of first citation. +fn cited_tags(doc: &Document) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for path in doc.para_paths(StoryRef::Body) { + let Some(p) = doc.para(StoryRef::Body, &path) else { continue }; + for o in &p.objects { + if let InlineObject::Field { instr, .. } = o + && let Some((tag, _)) = citation_field(instr) + && seen.insert(tag.to_string()) + { + out.push(tag.to_string()); + } + } + } + out +} + +fn author_key(src: &Source) -> String { + authors(&src.author).first().map(|a| a.0.to_lowercase()).unwrap_or_else(|| src.title.to_lowercase()) +} + +/// Indices into `doc.sources` in bibliography order: the cited sources (every source while +/// nothing is cited), sorted or in order of first citation as the style asks. +fn bib_order(doc: &Document, st: &CiteStyle) -> Vec { + let cited = cited_tags(doc); + let mut idx: Vec = if cited.is_empty() { + (0..doc.sources.len()).collect() + } else if st.order == Order::Cited { + cited.iter().filter_map(|t| doc.sources.iter().position(|x| &x.tag == t)).collect() + } else { + let cited: std::collections::HashSet<&str> = cited.iter().map(String::as_str).collect(); + doc.sources.iter().enumerate().filter(|(_, x)| cited.contains(x.tag.as_str())).map(|(i, _)| i).collect() + }; + let key = + |i: &usize, by_title: bool| doc.sources.get(*i).map(|x| if by_title { x.title.to_lowercase() } else { author_key(x) }).unwrap_or_default(); + match st.order { + Order::Author => idx.sort_by_cached_key(|i| key(i, false)), + Order::Title => idx.sort_by_cached_key(|i| key(i, true)), + Order::Cited => {} + } + idx +} + +/// A source's number in a numbered style: its place in the bibliography order. +fn number_of(doc: &Document, order: &[usize], tag: &str) -> usize { + order.iter().position(|&i| doc.sources.get(i).is_some_and(|x| x.tag == tag)).unwrap_or(order.len()).saturating_add(1) +} + /// Refresh CITATION results and generated lists. pub fn update_citations(s: &mut Session) -> Result<(), CmdError> { - let style = s.bib_style.clone(); + refresh_citations(s)?; + update_generated(s) +} + +/// Refresh the results of the CITATION fields in the body. +fn refresh_citations(s: &mut Session) -> Result<(), CmdError> { + let st = style(&s.bib_style); + let order = bib_order(&s.doc, st); for path in s.doc.para_paths(StoryRef::Body) { let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; let updates: Vec<(usize, String)> = p @@ -262,13 +666,10 @@ pub fn update_citations(s: &mut Session) -> Result<(), CmdError> { .iter() .enumerate() .filter_map(|(k, o)| match o { - InlineObject::Field { instr, .. } if instr.trim_start().starts_with("CITATION") => { - let mut it = instr.split_whitespace().skip(1); - let tag = it.next()?.to_string(); - let pages = instr.split("\\p").nth(1).map(|x| x.trim().to_string()).unwrap_or_default(); - let n = s.doc.sources.iter().position(|x| x.tag == tag)? + 1; - let src = s.doc.sources.get(n - 1)?; - Some((k, cite(src, &style, n, &pages))) + InlineObject::Field { instr, .. } => { + let (tag, pages) = citation_field(instr)?; + let src = s.doc.sources.iter().find(|x| x.tag == tag)?; + Some((k, cite(src, st.name, number_of(&s.doc, &order, tag), pages))) } _ => None, }) @@ -284,7 +685,7 @@ pub fn update_citations(s: &mut Session) -> Result<(), CmdError> { } para.touch(); } - update_generated(s) + Ok(()) } /// Generated lists (BIBLIOGRAPHY, INDEX, table of figures, TOA): a heading paragraph holding @@ -365,26 +766,12 @@ fn page_of(s: &mut Session, pos: &Pos) -> u32 { /// Entry lines (text, hanging indent) for a generated list. fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> { if kind.starts_with("BIBLIOGRAPHY") { - let style = s.bib_style.clone(); - let cited: Vec = s - .doc - .para_paths(StoryRef::Body) - .iter() - .filter_map(|p| s.doc.para(StoryRef::Body, p)) - .flat_map(|p| p.objects.iter()) - .filter_map(|o| match o { - InlineObject::Field { instr, .. } if instr.trim_start().starts_with("CITATION") => { - instr.split_whitespace().nth(1).map(str::to_string) - } - _ => None, - }) + let st = style(&s.bib_style); + return bib_order(&s.doc, st) + .into_iter() + .enumerate() + .filter_map(|(k, i)| s.doc.sources.get(i).map(|x| (entry(x, st.name, k.saturating_add(1)), !st.numbered()))) .collect(); - let mut srcs: Vec<(usize, Source)> = - s.doc.sources.iter().cloned().enumerate().filter(|(_, x)| cited.is_empty() || cited.contains(&x.tag)).collect(); - if style != "IEEE" { - srcs.sort_by_key(|(_, x)| authors(&x.author).first().map(|a| a.0.to_lowercase()).unwrap_or_else(|| x.title.to_lowercase())); - } - return srcs.into_iter().map(|(i, x)| (entry(&x, &style, i + 1), style != "IEEE")).collect(); } // Collect marks with positions. let mut marks: Vec<(String, Pos)> = Vec::new(); @@ -683,4 +1070,111 @@ mod tests { let name = s.doc.bookmarks()[0].0.clone(); assert_eq!(crate::cmd::references::bookmark_text(&s.doc, &name).as_deref(), Some("Kilns")); } + + fn two_sources(s: &mut Session, a_title: &str) { + let a = json!({"tag": "A", "author": "Rivera, Alex; Chen, Mei", "title": a_title, "year": "2021", "publisher": "Harbor Books", "city": "Portland"}); + let b = json!({"tag": "B", "author": "Okafor, Ngozi", "title": "Kilns and Clay", "year": "2019", "journal": "Studio Quarterly", "volume": "4", "pages": "10-20"}); + s.run("references.sources", &json!({"add": a})).unwrap(); + s.run("references.sources", &json!({"add": b})).unwrap(); + } + + #[test] + fn author_date_styles_cite_and_list() { + let mut s = Session::new(wordcraft_doc::Document::new()); + two_sources(&mut s, "Shared Spaces"); + s.run("references.citation", &json!({"tag": "A", "pages": "12"})).unwrap(); + s.run("references.citation", &json!({"tag": "B"})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("references.bibliography", &json!({})).unwrap(); + let cases = [ + ( + "Harvard (Anglia)", + "(Rivera and Chen, 2021, p. 12)(Okafor, 2019)", + "Okafor, N., 2019. Kilns and Clay. Studio Quarterly, 4, pp. 10-20.\nRivera, A. and Chen, M., 2021. Shared Spaces. Portland: Harbor Books.", + ), + ( + "ISO 690 - First Element and Date", + "(Rivera and Chen 2021, p. 12)(Okafor 2019)", + "OKAFOR, Ngozi, 2019. Kilns and Clay. Studio Quarterly, 4, 10-20.\nRIVERA, Alex and CHEN, Mei, 2021. Shared Spaces. Portland: Harbor Books.", + ), + ( + "turabian", + "(Rivera and Chen 2021, 12)(Okafor 2019)", + "Okafor, Ngozi. “Kilns and Clay.” Studio Quarterly 4 (2019): 10-20.\nRivera, Alex, and Mei Chen. Shared Spaces. Portland: Harbor Books, 2021.", + ), + ]; + for (style, cites, list) in cases { + s.run("references.citationStyle", &json!({"style": style})).unwrap(); + let t = s.doc.plain_text(StoryRef::Body); + assert!(t.starts_with(cites) && t.contains(list), "{style}: {t}"); + } + assert!(s.run("references.citationStyle", &json!({"style": "Vancouver"})).unwrap_err().to_string().contains("Turabian")); + assert_eq!(s.bib_style, "Turabian", "an unknown style keeps the current one"); + } + + #[test] + fn numbered_styles_count_in_order_of_first_citation() { + let mut s = Session::new(wordcraft_doc::Document::new()); + two_sources(&mut s, "Shared Spaces"); + s.run("references.citationStyle", &json!({"style": "ISO 690 (numerical)"})).unwrap(); + s.run("text.insert", &json!({"text": "Later "})).unwrap(); + s.run("references.citation", &json!({"tag": "A", "pages": "12"})).unwrap(); + assert_eq!(fields(&s)[0].1, "(1, p. 12)"); + // Citing B earlier in the text makes it number 1 and renumbers A. + s.run("caret.docStart", &json!({})).unwrap(); + s.run("references.citation", &json!({"tag": "B"})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("references.bibliography", &json!({})).unwrap(); + let cases = [ + ( + "ISO 690 (numerical)", + "(1)Later (2, p. 12)", + "1. OKAFOR, Ngozi. Kilns and Clay. Studio Quarterly. 2019, 4, 10-20.\n2. RIVERA, Alex and CHEN, Mei. Shared Spaces. Portland: Harbor Books, 2021.", + ), + ( + "GB/T 7714", + "[1]Later [2, 12]", + "[1] OKAFOR N. Kilns and Clay[J]. Studio Quarterly, 2019, 4: 10-20.\n[2] RIVERA A, CHEN M. Shared Spaces[M]. Portland: Harbor Books, 2021.", + ), + ( + "SIST02", + "(1)Later (2, p. 12)", + "1) Okafor, Ngozi. Kilns and Clay. Studio Quarterly. 2019, vol. 4, p. 10-20.\n2) Rivera, Alex; Chen, Mei. Shared Spaces. Portland, Harbor Books, 2021.", + ), + ( + "IEEE", + "[1]Later [2]", + "[1] N. Okafor, “Kilns and Clay,” Studio Quarterly, 4, 10-20, 2019.\n[2] A. Rivera, M. Chen, “Shared Spaces,” Portland: Harbor Books, 2021.", + ), + ]; + for (style, cites, list) in cases { + s.run("references.citationStyle", &json!({"style": style})).unwrap(); + let t = s.doc.plain_text(StoryRef::Body); + assert!(t.starts_with(cites) && t.contains(list), "{style}: {t}"); + } + } + + #[test] + fn gost_numbers_follow_its_name_or_title_sort() { + let mut s = Session::new(wordcraft_doc::Document::new()); + two_sources(&mut s, "Atlas of Studios"); + s.run("references.citation", &json!({"tag": "A"})).unwrap(); + s.run("references.citation", &json!({"tag": "B", "pages": "15"})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("references.bibliography", &json!({})).unwrap(); + let okafor = "Okafor N. Kilns and Clay // Studio Quarterly. – 2019. – Vol. 4. – P. 10-20."; + let rivera = "Rivera A., Chen M. Atlas of Studios. – Portland : Harbor Books, 2021."; + // Word's style and style-sheet names pick the same styles. + s.run("references.citationStyle", &json!({"style": "\\GostName.XSL"})).unwrap(); + assert_eq!(s.bib_style, "GOST (name sort)"); + let t = s.doc.plain_text(StoryRef::Body); + assert!(t.starts_with("[2][1, p. 15]") && t.contains(&format!("1. {okafor}\n2. {rivera}")), "{t}"); + s.run("references.citationStyle", &json!({"style": "GOST - Title Sort"})).unwrap(); + let t = s.doc.plain_text(StoryRef::Body); + assert!(t.starts_with("[1][2, p. 15]") && t.contains(&format!("1. {rivera}\n2. {okafor}")), "{t}"); + assert_eq!(find_style("ISO 690 - Numerical Reference").map(|x| x.name), Some("ISO 690 (numerical)")); + assert_eq!(find_style("Harvard - Anglia 2008").map(|x| x.name), Some("Harvard (Anglia)")); + assert!(find_style("").is_none() && find_style("Vancouver").is_none()); + } } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1fcc747..9f2ae65 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -783,7 +783,7 @@ Style Inspector Inspector de estilos Style Set Conjunto de estilos Style based on: Estilo basado en: - Style: APA Estilo: APA + Style: {style} Estilo: {style} Styles Estilos Styles Pane Panel de estilos Subscript Subíndice diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index ff0dbe8..23c799d 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -691,7 +691,7 @@ Style Inspector Stiilikontroll Style Set Stiilikomplekt Style based on: Stiili alus: - Style: APA Stiil: APA + Style: {style} Stiil: {style} Styles Stiilid Styles Pane Stiilipaan Subscript Allindeks diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 608254f..e6f5199 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -760,7 +760,7 @@ Style スタイル Style based on: 基準にするスタイル: Style Set スタイル セット - Style: APA スタイル: APA + Style: {style} スタイル: {style} Styles スタイル Styles Pane スタイル ウィンドウ Subscript 下付き diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 691a076..bf43d72 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -761,7 +761,7 @@ Style Estilo Style based on: Estilo baseado em: Style Set Conjunto de estilos - Style: APA Estilo: APA + Style: {style} Estilo: {style} Styles Estilos Styles Pane Painel de estilos Subscript Subscrito diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 77e96a0..7b8c0cb 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -814,7 +814,7 @@ Style Stil Style based on: Stil zasnovan na: Style Set Skup stilova - Style: APA Stil: APA + Style: {style} Stil: {style} Styles Stilovi Styles Pane Okno stilova Subscript Indeks dole diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 33482a4..7604979 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -818,7 +818,7 @@ Style Стил Style based on: Стил заснован на: Style Set Скуп стилова - Style: APA Стил: APA + Style: {style} Стил: {style} Styles Стилови Styles Pane Окно стилова Subscript Индекс доле diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a7de23f..7d7b2f2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -785,7 +785,7 @@ Style based on: Стиль на основі: Style Inspector Інспектор стилів Style Set Набір стилів - Style: APA Стиль: APA + Style: {style} Стиль: {style} Styles Стилі Styles Pane Панель стилів Subscript Нижній індекс diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b330edf..03d9777 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -771,7 +771,7 @@ Style 样式 Style based on: 样式基于: Style Set 样式集 - Style: APA 样式:APA + Style: {style} 样式:{style} Styles 样式 Styles Pane 样式窗格 Subscript 下标 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 18307a5..bf21386 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -761,7 +761,7 @@ Style 樣式 Style based on: 樣式依據: Style Set 樣式集 - Style: APA 樣式:APA + Style: {style} 樣式:{style} Styles 樣式 Styles Pane 樣式窗格 Subscript 下標 diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 3f00be4..98b526a 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -251,7 +251,7 @@ fn controls(tab: &str) -> Vec { c("Researcher", "references.researcher"), c("Insert Citation", "references.citation"), c("Manage Sources", "references.sources"), - c("Style: APA", "references.citationStyle"), + m("Citation Style", "references.citationStyle"), c("Bibliography", "references.bibliography"), c("Insert Caption", "references.caption"), c("Insert Table of Figures", "references.tableOfFigures"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 62381b4..8682f8d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -940,7 +940,13 @@ fn references(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "citation", "Insert\nCitation", "references.citation", json!({}), false); stack(ui, |ui| { small(ui, app, "sources", Some("Manage Sources"), "Manage Sources", "references.sources", json!({}), false); - small(ui, app, "styles", Some("Style: APA"), "Citation Style", "references.citationStyle", json!({}), false); + let current = app.session.bib_style.clone(); + let label = crate::i18n::fmt(tl!("Style: {style}"), &[("style", ¤t)]); + menu_button(ui, app, "styles", Some(&label), "Citation Style", false, |ui, app| { + for st in &wordcraft_engine::cmd::citations::STYLES { + mi_check(ui, app, st.name, st.name == current, "references.citationStyle", json!({"style": st.name})); + } + }); small(ui, app, "bibliography", Some("Bibliography"), "Bibliography", "references.bibliography", json!({}), false); }); }); diff --git a/docs/gaps.md b/docs/gaps.md index d01be47..7187136 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -27,7 +27,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); missing: Lasso Select, Add Pen, Ink to Shape, Ink to Math, Ink Replay, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 5/11) | Pen and tablet users | 8–14 | [hardware-parity.md](hardware-parity.md) | | 13 | **Charts and SmartArt editing** (own renderer, chart data editing, SmartArt layouts) | | `insert.chart`, `insert.smartArt` missing | Reports and business documents | 45–70 | [target-app-parity.md](target-app-parity.md) | | 14 | **`.doc` can't be written; metafile pictures and Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | -| 15 | **References depth**: 4 of Word's 12 bibliography styles; sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Word's `Resources/Style` lists 12 styles | Students and researchers | 10–15 | [target-app-parity.md](target-app-parity.md) | +| 15 | **References depth**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: RTL sections and tables, kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 10–15 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: vertical text, ruby/Phonetic Guide, Enclose Characters, document grid, Asian Typography options | | Catalog misses `format.phonetic`, `format.enclose`, `para.asianTypography` | Chinese, Japanese and Korean documents | 20–30 | [layout-parity.md](layout-parity.md) | | 18 | **Screen-reader access** to the document canvas untested | | AccessKit enabled; no tests | Blind and low-vision users; public-sector procurement | 10–15 | [ui-parity.md](ui-parity.md) | @@ -66,6 +66,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | All 12 bibliography styles landed (#383); removed from References depth | | 2026-10-11 | trivial | Formatting revisions landed (#41); gap #5 narrowed to move tracking and content controls, 8–12 h | | 2026-10-10 | trivial | Draw Table and Eraser landed (#303); removed from the feature-gap list | | 2026-10-10 | trivial | Tabs and Borders and Shading dialogs landed (#320); removed from the dialog-depth list | diff --git a/docs/roadmap.md b/docs/roadmap.md index ea85968..8fb9702 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -50,7 +50,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | M3 | Insert tab | mostly done; equations done (#191); charts, SmartArt, icons, 3D, screenshot, online media missing | 50–80 h | | M4 | Layout and Design tabs | done except column balancing, group | 6–10 h | | M5 | Tables | done (row splitting, floating tables, style formatting); custom table styles (#256); Draw Table and Eraser (#303) | 6–10 h | -| M6 | References | first version done (TOC fields, cross-references, Zotero); footnote continuation, 8 more bibliography styles missing | 15–25 h | +| M6 | References | first version done (TOC fields, cross-references, Zotero); footnote continuation missing; all 12 bibliography styles (#383) | 15–25 h | | M7 | Review | done (balloons, paragraph-mark revisions, formatting revisions #41); move tracking, multilingual proofing missing | 35–60 h | | M8 | View | mostly done; window commands missing | 3–5 h | | M9 | Mailings | done (first version); Excel/Outlook data sources and email merge missing | 6–10 h | @@ -71,6 +71,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | M6: the eight missing bibliography styles landed (#383) | | 2026-10-11 | trivial | M7: formatting revisions landed (#41) | | 2026-10-10 | trivial | M5: Draw Table and Eraser landed (#303) | | 2026-10-10 | trivial | Merged main: PRs that landed removed from the Current focus list | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 98ed089..befa05e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (mail merge rule fixes, #427–#430; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are @@ -64,7 +64,7 @@ full parity for the area. | Page layout and sections | 7 | 70% | 72% | 12–20 | Margins, size, columns, breaks, page borders, line numbers, vertical alignment, drop caps, hyphenation. Columns don't balance; no document grid; no vertical text; RTL sections missing. Detail: [layout-parity.md](layout-parity.md). | | Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. | | Footnotes and endnotes | 3 | 60% | 60% | 5–8 | Placed and editable, numbers from Word files (#103). Long notes don't continue onto the next page; no continuation separator; no note options per section. | -| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. 4 of Word's 12 bibliography styles (APA, MLA, Chicago, IEEE); source manager depth; bibliography sources not yet in DOCX (#169 open). | +| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); source manager depth; bibliography sources not yet in DOCX (#169 open). | | Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Missing: move tracking, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). | | Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary, a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. | @@ -249,6 +249,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | References: all 12 of Word's bibliography styles (eight more: Harvard, ISO 690 ×2, Turabian, GB/T 7714, GOST ×2, SIST02) (#383); no percentage change | | 2026-10-11 | trivial | Mail merge fixes (#427–#430): SKIPIF drops matching records, NEXT advances later fields, IF previews, header/footer fields merge; Mailings row updated, no percentage change | | 2026-10-11 | trivial | Formatting revisions (#41): Review row updated; no percentage change | | 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink; catalog 384/430 → 390/430 (90.7%) |