mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-25 02:47:33 -06:00
* #775: STYLEREF fields in headers and footers show each page's result Running headers/footers with STYLEREF (e.g. the current chapter title) are evaluated per page at layout time (ECMA-376 §17.16.5.59): the page's first paragraph of the style (\l: last), else the nearest before, else after. Style names/ids, heading levels (STYLEREF 1), character styles, and the \n \r \w \s \t \p switches. Body STYLEREF fields update with the other fields. STYLEREF added to Insert › Quick Parts › Field with its options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #775: STYLEREF error text in our own words Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Review fixes for #882: translate the STYLEREF Field dialog strings Adds the style picker label, the six STYLEREF switch labels and the STYLEREF description to all 18 interface catalogs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * STYLEREF: reuse the style index while the body and styles are unchanged Headers with STYLEREF rebuilt and hashed the body's style index on every layout pass (echelon's follow-up note on #882). The layout cache now keeps the index with the body blocks it came from (shared, so an edited block is a different pointer) and the styles/numbering hash, and rebuilds it only when either changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * vi: STYLEREF field strings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Dmitrii Khlapov
Brandon Thomas
parent
8e0f42e4bc
commit
cf9a4ff7fa
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: an option to show only the selected text's comment, #1055; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; 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; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: an option to show only the selected text's comment, #1055; previously trivial: STYLEREF running headers, #775; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; 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; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -205,6 +205,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |
|
||||
| 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents |
|
||||
| 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) |
|
||||
|---|---|
|
||||
| 2026-10-12 | STYLEREF fields (#775): running headers and footers show each page's chapter title (page's first, or `\l` last, paragraph of the style, else the nearest before), style names, ids, heading levels and character styles, number switches; body STYLEREF updates with the other fields; STYLEREF in the Field dialog; PDF export follows the layout |
|
||||
| 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP |
|
||||
| 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off |
|
||||
| 2026-10-13 | No document behind the Start screen (#1129): Esc or Back from it shows an empty window (no page, ribbon greyed out but for Help) until Blank document, a template or a file is opened, as in Word; closing it asks nothing |
|
||||
|
||||
@@ -267,6 +267,12 @@ pub struct Counters {
|
||||
}
|
||||
|
||||
impl Counters {
|
||||
/// The current value of each level of `num`'s list (0 for a level not counted yet).
|
||||
pub fn values(&self, numbering: &Numbering, num: u32) -> Option<[u32; 9]> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
Some(self.state.get(&abs.id).copied().unwrap_or([0; 9]))
|
||||
}
|
||||
|
||||
/// Advance the counter for `num`/`level` and return the label (`"1."`, `"a)"`, `"•"`).
|
||||
pub fn next_label(&mut self, numbering: &Numbering, num: u32, level: u8) -> Option<(String, Level)> {
|
||||
let n = numbering.num(num)?;
|
||||
|
||||
@@ -712,43 +712,23 @@ fn caption(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// The number part of a heading's list label (`Chapter 2.` → `2`).
|
||||
fn chapter_number(label: &str) -> String {
|
||||
let word = label.split_whitespace().last().unwrap_or("");
|
||||
word.trim_matches(|c: char| !c.is_alphanumeric()).to_string()
|
||||
}
|
||||
|
||||
/// Renumber the body's SEQ fields in document order, honouring `\* format`, `\s level` (restart
|
||||
/// after each heading of that level), `\r n` (reset to n), `\c` (repeat) and `\h` (hide), and
|
||||
/// fill chapter-number `STYLEREF n \s` fields with the last level-n heading's number (its list
|
||||
/// label, or its count when the heading isn't numbered).
|
||||
/// fill body STYLEREF fields ([`wordcraft_layout::styleref`]): chapter numbers (`STYLEREF n \s`,
|
||||
/// the last level-n heading's number, or its count when the heading isn't numbered) and the
|
||||
/// text or number of the nearest paragraph of a style.
|
||||
pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
|
||||
let mut counters = wordcraft_doc::numbering::Counters::default();
|
||||
let mut headings = [0u32; 9];
|
||||
let mut chapter: [String; 9] = Default::default();
|
||||
let mut heading_text: [String; 9] = Default::default();
|
||||
let mut seq: std::collections::HashMap<String, (u32, u32)> = Default::default();
|
||||
// Built on the first STYLEREF (field results don't change what it finds).
|
||||
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
let label = p
|
||||
.props
|
||||
.numbering
|
||||
.or_else(|| s.doc.styles.resolve_para(&p.props).numbering)
|
||||
.filter(|n| n.num != 0)
|
||||
.and_then(|n| counters.next_label(&s.doc.numbering, n.num, n.level))
|
||||
.map(|(l, _)| l);
|
||||
if let Some(l) = heading_style_level(p)
|
||||
&& let Some(k) = (l as usize).checked_sub(1)
|
||||
&& let Some(count) = headings.get_mut(k)
|
||||
{
|
||||
*count = count.saturating_add(1);
|
||||
let n = label.map(|l| chapter_number(&l)).filter(|n| !n.is_empty()).unwrap_or_else(|| count.to_string());
|
||||
if let Some(c) = chapter.get_mut(k) {
|
||||
*c = n;
|
||||
}
|
||||
if let Some(t) = heading_text.get_mut(k) {
|
||||
*t = p.plain_text().trim().to_string();
|
||||
}
|
||||
}
|
||||
let mut updates: Vec<(usize, String)> = Vec::new();
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
@@ -774,15 +754,9 @@ pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
|
||||
updates.push((k, if f.has('h') { String::new() } else { fmt.format(n.0) }));
|
||||
}
|
||||
"STYLEREF" => {
|
||||
let level = f
|
||||
.args
|
||||
.first()
|
||||
.and_then(|a| a.strip_prefix("Heading").unwrap_or(a).trim().parse::<usize>().ok())
|
||||
.and_then(|l| l.checked_sub(1));
|
||||
let Some(level) = level else { continue };
|
||||
let r = if f.has('s') || f.has('n') { chapter.get(level) } else { heading_text.get(level) };
|
||||
if let Some(r) = r {
|
||||
updates.push((k, r.clone()));
|
||||
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc));
|
||||
if let Some(i) = ix.index_of(&path) {
|
||||
updates.push((k, ix.eval(instr, wordcraft_layout::styleref::At::Body(i))));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -892,6 +866,96 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// #775: body STYLEREF fields show the nearest paragraph of the style before them.
|
||||
#[test]
|
||||
fn body_styleref_finds_the_heading_before_it() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nChapter One\ntext here\nChapter Two\nend"));
|
||||
for t in ["Chapter One", "Chapter Two"] {
|
||||
s.run("select.text", &json!({"text": t})).unwrap();
|
||||
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
|
||||
}
|
||||
s.run("select.text", &json!({"text": "text here"})).unwrap();
|
||||
s.run("caret.end", &json!({})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": r#"STYLEREF "Heading 1" \* MERGEFORMAT"#})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "STYLEREF 1 \\p"})).unwrap();
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "STYLEREF \"Heading 1\" \\p"})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "STYLEREF \"No Such Style\""})).unwrap();
|
||||
let results: Vec<String> = fields(&s).into_iter().map(|f| f.1).collect();
|
||||
assert_eq!(results, ["below", wordcraft_layout::styleref::NO_TEXT, "Chapter One", "above"]);
|
||||
}
|
||||
|
||||
/// #775: a template's running header `STYLEREF "heading 1"` (a complex field, opened from
|
||||
/// DOCX) shows each page's chapter, and keeps its code through a save.
|
||||
#[test]
|
||||
fn docx_styleref_header_shows_each_page_s_chapter() {
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
let heading = |t: &str, brk: bool| {
|
||||
let b = if brk { "<w:pageBreakBefore/>" } else { "" };
|
||||
format!(
|
||||
r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/>{b}</w:pPr><w:r><w:t>{t}</w:t></w:r></w:p><w:p><w:r><w:t>Body of {t}.</w:t></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let body = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><w:document {ns}><w:body>{}{}{}<w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#,
|
||||
heading("Rivers", false),
|
||||
heading("Mountains", true),
|
||||
heading("Deserts", true)
|
||||
);
|
||||
let header = format!(
|
||||
r#"<w:hdr {ns}><w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> STYLEREF "heading 1" \* MERGEFORMAT </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>Cached</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:hdr>"#
|
||||
);
|
||||
let styles = format!(
|
||||
r#"<w:styles {ns}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style></w:styles>"#
|
||||
);
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/><Relationship Id="rIdS" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, text) in [
|
||||
("_rels/.rels", root),
|
||||
("word/document.xml", body.as_str()),
|
||||
("word/_rels/document.xml.rels", rels),
|
||||
("word/header1.xml", header.as_str()),
|
||||
("word/styles.xml", styles.as_str()),
|
||||
] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
|
||||
}
|
||||
let bytes = zw.finish().unwrap().into_inner();
|
||||
let headers = |s: &mut Session| -> Vec<String> {
|
||||
s.layout()
|
||||
.pages
|
||||
.iter()
|
||||
.map(|p| {
|
||||
p.header
|
||||
.iter()
|
||||
.filter_map(|it| {
|
||||
if let wordcraft_layout::Placed::Lines { para, .. } = it {
|
||||
Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut s = Session::new(crate::io::open_bytes("chapters.docx", &bytes).unwrap());
|
||||
assert_eq!(headers(&mut s), ["Rivers", "Mountains", "Deserts"]);
|
||||
let saved = crate::io::save_bytes("chapters.docx", &s.doc).unwrap();
|
||||
let mut again = Session::new(crate::io::open_bytes("chapters.docx", &saved).unwrap());
|
||||
assert_eq!(headers(&mut again), ["Rivers", "Mountains", "Deserts"]);
|
||||
let hid = again.doc.last_section.headers.default.unwrap();
|
||||
let instr = again.doc.para_paths(StoryRef::Part(hid)).iter().find_map(|p| {
|
||||
again.doc.para(StoryRef::Part(hid), p).unwrap().objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } => Some(instr.clone()),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
assert!(instr.is_some_and(|i| i.contains(r#"STYLEREF "heading 1""#)));
|
||||
}
|
||||
|
||||
/// #402: Custom Table of Contents settings become TOC switches, and updating honours them:
|
||||
/// levels (`\o`), a style with a level (`\t`), no hyperlinks, page numbers after a space
|
||||
/// (`\p`), or none at all (`\n`).
|
||||
|
||||
@@ -22,6 +22,9 @@ pub struct FieldCtx {
|
||||
pub title: Arc<str>,
|
||||
pub author: Arc<str>,
|
||||
pub filename: Arc<str>,
|
||||
/// Body paragraphs for STYLEREF fields in headers and footers, and where the page being
|
||||
/// drawn is among them (set only while laying out headers and footers that need it).
|
||||
pub styleref: Option<(Arc<crate::styleref::StyleIndex>, crate::styleref::At)>,
|
||||
/// Where the body's bookmarks start, for `PAGEREF` in headers and footers: known once the
|
||||
/// body is paginated, empty while it is.
|
||||
pub bookmark_pages: Arc<BookmarkPages>,
|
||||
@@ -63,6 +66,17 @@ impl FieldCtx {
|
||||
pub fn page_key(&self) -> (u32, u32, u32, u32, u64) {
|
||||
(self.page, self.pages, self.section_pages, self.section, self.bookmarks_key)
|
||||
}
|
||||
/// A key for caches of paragraphs with STYLEREF fields (0 when they show their stored result).
|
||||
pub fn styleref_key(&self) -> u64 {
|
||||
match &self.styleref {
|
||||
Some((ix, at)) => {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
std::hash::Hash::hash(&(ix.hash(), at), &mut h);
|
||||
std::hash::Hasher::finish(&h).max(1)
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The field's name (first word of the instruction, upper-cased).
|
||||
@@ -125,6 +139,10 @@ pub fn field_text_on(instr: &str, result: &str, ctx: &FieldCtx, page: u32) -> (S
|
||||
"TITLE" if result.is_empty() => (ctx.title.to_string(), false),
|
||||
"AUTHOR" if result.is_empty() => (ctx.author.to_string(), false),
|
||||
"FILENAME" if result.is_empty() => (ctx.filename.to_string(), false),
|
||||
"STYLEREF" => match &ctx.styleref {
|
||||
Some((ix, at)) => (ix.eval(instr, *at), true),
|
||||
None => (result.to_string(), false),
|
||||
},
|
||||
_ => (result.to_string(), false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod kinsoku;
|
||||
pub mod math;
|
||||
pub mod outline;
|
||||
pub mod para;
|
||||
pub mod styleref;
|
||||
mod table;
|
||||
mod textbox;
|
||||
mod vertical;
|
||||
@@ -472,6 +473,8 @@ struct Key {
|
||||
grid: (u32, u32, u32, bool),
|
||||
fit: bool,
|
||||
hide_deleted: bool,
|
||||
/// STYLEREF results in headers and footers ([`FieldCtx::styleref_key`]).
|
||||
sref: u64,
|
||||
}
|
||||
|
||||
/// Memoised paragraph layouts.
|
||||
@@ -485,6 +488,10 @@ pub struct LayoutCache {
|
||||
outlines: HashMap<(String, usize, usize, [u32; 4]), Option<Arc<Vec<(f32, f32)>>>>,
|
||||
env: u64,
|
||||
used: HashSet<Key>,
|
||||
/// The STYLEREF style index of the last pass and what it was built from: the body blocks
|
||||
/// (shared, so an edited block is a different pointer) and `env` (styles and numbering). Kept
|
||||
/// while both are unchanged, so headers using STYLEREF don't rebuild it on every pass.
|
||||
styleref: Option<(Blocks, u64, Arc<styleref::StyleIndex>)>,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
}
|
||||
@@ -503,6 +510,21 @@ impl LayoutCache {
|
||||
self.paras.clear();
|
||||
self.measures.clear();
|
||||
self.outlines.clear();
|
||||
self.styleref = None;
|
||||
}
|
||||
|
||||
/// The STYLEREF style index for `doc`, rebuilt only when the body or the styles changed.
|
||||
fn style_index(&mut self, doc: &Document) -> Arc<styleref::StyleIndex> {
|
||||
if let Some((body, env, ix)) = &self.styleref
|
||||
&& *env == self.env
|
||||
&& body.len() == doc.body.len()
|
||||
&& body.iter().zip(doc.body.iter()).all(|(a, b)| Arc::ptr_eq(a, b))
|
||||
{
|
||||
return ix.clone();
|
||||
}
|
||||
let ix = Arc::new(styleref::StyleIndex::build(doc));
|
||||
self.styleref = Some((doc.body.to_vec(), self.env, ix.clone()));
|
||||
ix
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,6 +807,7 @@ impl Ctx<'_> {
|
||||
grid: self.grid.key(),
|
||||
fit: self.boxes.inside(),
|
||||
hide_deleted: self.hide_deleted,
|
||||
sref: if self.fields.styleref.is_some() && styleref::has_styleref(p) { self.fields.styleref_key() } else { 0 },
|
||||
};
|
||||
(key, eq_number)
|
||||
}
|
||||
@@ -3683,6 +3706,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
|
||||
*per_section.entry(p.section).or_default() += 1;
|
||||
}
|
||||
let even_odd = ctx.doc.settings.even_odd_headers;
|
||||
let styles = styleref::headers_use_styleref(ctx.doc).then(|| ctx.cache.style_index(ctx.doc));
|
||||
let mut first_of_section: HashSet<usize> = HashSet::new();
|
||||
let mut seen = HashSet::new();
|
||||
for (i, p) in pages.iter().enumerate() {
|
||||
@@ -3717,6 +3741,14 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
|
||||
ctx.fields.pages = total;
|
||||
ctx.fields.section_pages = per_section.get(&page.section).copied().unwrap_or(1);
|
||||
ctx.fields.section = page.section as u32 + 1;
|
||||
// STYLEREF: the body paragraphs on this page.
|
||||
ctx.fields.styleref = styles.as_ref().map(|ix| {
|
||||
let paths = page.items.iter().filter_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Body, path, .. } => Some(path),
|
||||
_ => None,
|
||||
});
|
||||
(ix.clone(), ix.page_at(paths, page.first_block))
|
||||
});
|
||||
let w = sect.text_width();
|
||||
let x = sect.margin_left + sect.gutter;
|
||||
if let Some(id) = hid
|
||||
@@ -3746,6 +3778,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
|
||||
page.footer_story = Some(id);
|
||||
}
|
||||
}
|
||||
ctx.fields.styleref = None;
|
||||
}
|
||||
|
||||
/// Mirrored margins: even pages have the inside margin (and gutter) on the right, so their
|
||||
@@ -3799,6 +3832,8 @@ mod tests_numeral;
|
||||
#[cfg(test)]
|
||||
mod tests_pages;
|
||||
#[cfg(test)]
|
||||
mod tests_styleref;
|
||||
#[cfg(test)]
|
||||
mod tests_textbox;
|
||||
#[cfg(test)]
|
||||
mod tests_vertical;
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! STYLEREF fields (ECMA-376 Part 1 §17.16.5.59): the text, or the paragraph number, of the
|
||||
//! nearest paragraph (or run) with a given style.
|
||||
//!
|
||||
//! In a header or footer the search starts on the page the header is drawn on: its first
|
||||
//! paragraph of the style (with `\l`, its last), else the nearest one before the page, else the
|
||||
//! nearest one after. In the body it starts at the field's own paragraph and goes back towards
|
||||
//! the start of the document, then forward. Headers and footers are evaluated at layout time,
|
||||
//! page by page ([`crate::FieldCtx::styleref`]); body fields are updated like other fields.
|
||||
//!
|
||||
//! Switches: `\l` searches the page bottom-up; `\n` the paragraph number, `\r` and `\w` the
|
||||
//! number in full context (all levels; `\r` is treated like `\w`), `\s` a chapter number (the
|
||||
//! number alone, or the heading's count when it isn't numbered); `\t` drops the text around
|
||||
//! the number; `\p` adds "above" / "below".
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use wordcraft_doc::numbering::Counters;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
use wordcraft_doc::{Document, Path, StoryRef, StyleKind};
|
||||
|
||||
/// The result when no paragraph has the style (or the style doesn't exist).
|
||||
pub const NO_TEXT: &str = "Error: no paragraph in this style.";
|
||||
|
||||
/// Which number a STYLEREF shows instead of the text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Number {
|
||||
/// The text (no number switch).
|
||||
#[default]
|
||||
Text,
|
||||
/// `\n`: the paragraph's own number.
|
||||
Plain,
|
||||
/// `\r` / `\w`: the number with the levels above it.
|
||||
Full,
|
||||
/// `\s`: a chapter number.
|
||||
Chapter,
|
||||
}
|
||||
|
||||
/// A parsed STYLEREF instruction.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct StyleRefCode {
|
||||
/// Style name or id, or a heading level `1`–`9`.
|
||||
pub style: String,
|
||||
/// `\l`: the last paragraph of the style on the page.
|
||||
pub last: bool,
|
||||
pub number: Number,
|
||||
/// `\p`: "above" / "below".
|
||||
pub position: bool,
|
||||
/// `\t`: only the number, without the text of its level.
|
||||
pub suppress: bool,
|
||||
}
|
||||
|
||||
/// Parse `STYLEREF "Heading 1" \l \n …`. Quoted text stays whole; an unquoted name of several
|
||||
/// words is joined back up.
|
||||
pub fn parse(instr: &str) -> StyleRefCode {
|
||||
let mut words: Vec<(String, bool)> = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
chars.next();
|
||||
} else if c == '"' {
|
||||
chars.next();
|
||||
let mut w = String::new();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
|
||||
c => w.push(c),
|
||||
}
|
||||
}
|
||||
words.push((w, true));
|
||||
} else {
|
||||
let mut w = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() || c == '"' {
|
||||
break;
|
||||
}
|
||||
w.push(c);
|
||||
chars.next();
|
||||
}
|
||||
words.push((w, false));
|
||||
}
|
||||
}
|
||||
let mut code = StyleRefCode::default();
|
||||
let mut it = words.into_iter().skip(1);
|
||||
while let Some((w, quoted)) = it.next() {
|
||||
let switch = if quoted { None } else { w.strip_prefix('\\').map(|r| r.chars().next().map(|c| c.to_ascii_lowercase())) };
|
||||
match switch {
|
||||
Some(Some('l')) => code.last = true,
|
||||
Some(Some('n')) => code.number = Number::Plain,
|
||||
Some(Some('r' | 'w')) => code.number = Number::Full,
|
||||
Some(Some('s')) => code.number = Number::Chapter,
|
||||
Some(Some('p')) => code.position = true,
|
||||
Some(Some('t')) => code.suppress = true,
|
||||
// General switches (`\* MERGEFORMAT`, `\@ …`, `\# …`) take a word.
|
||||
Some(Some('*' | '@' | '#')) => {
|
||||
it.next();
|
||||
}
|
||||
Some(_) => {}
|
||||
None if code.style.is_empty() => code.style = w,
|
||||
None if !quoted => {
|
||||
code.style.push(' ');
|
||||
code.style.push_str(&w);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
code
|
||||
}
|
||||
|
||||
/// What the search looks for.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
enum Target {
|
||||
Para(String),
|
||||
Char(String),
|
||||
}
|
||||
|
||||
/// A body paragraph, as STYLEREF sees it.
|
||||
#[derive(Clone, Debug, Hash)]
|
||||
struct Entry {
|
||||
path: Path,
|
||||
style: String,
|
||||
text: String,
|
||||
/// Character-styled runs: (style id, text).
|
||||
runs: Vec<(String, String)>,
|
||||
/// The list label as shown (`Chapter 2.`).
|
||||
label: Option<String>,
|
||||
/// Each level's value up to the paragraph's, formatted; and whether the label's own
|
||||
/// template shows that level.
|
||||
levels: Vec<(String, bool)>,
|
||||
/// How many paragraphs of its style there are up to this one.
|
||||
ordinal: u32,
|
||||
}
|
||||
|
||||
/// Where a STYLEREF field is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum At {
|
||||
/// In a header or footer of a page whose body starts at entry `first`; entries
|
||||
/// `first..=last` are on the page (`last` is `None` when no body paragraph is).
|
||||
Page { first: usize, last: Option<usize> },
|
||||
/// In body paragraph `i`.
|
||||
Body(usize),
|
||||
}
|
||||
|
||||
/// The body's paragraphs with their styles, text and numbers, in document order.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct StyleIndex {
|
||||
entries: Vec<Entry>,
|
||||
by_path: HashMap<Path, usize>,
|
||||
/// Lower-cased style id or name → (id, character style).
|
||||
styles: HashMap<String, (String, bool)>,
|
||||
hash: u64,
|
||||
}
|
||||
|
||||
fn clean(s: &str) -> String {
|
||||
s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect::<String>().trim().to_string()
|
||||
}
|
||||
|
||||
impl StyleIndex {
|
||||
pub fn build(doc: &Document) -> StyleIndex {
|
||||
let mut styles = HashMap::new();
|
||||
for st in &doc.styles.styles {
|
||||
let v = (st.id.clone(), st.kind == StyleKind::Character);
|
||||
styles.entry(st.name.to_lowercase()).or_insert_with(|| v.clone());
|
||||
styles.insert(st.id.to_lowercase(), v);
|
||||
}
|
||||
let mut counters = Counters::default();
|
||||
let mut ordinals: HashMap<String, u32> = HashMap::new();
|
||||
let mut entries = Vec::new();
|
||||
let mut by_path = HashMap::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
let style = p.props.style.clone().unwrap_or_else(|| "Normal".to_string());
|
||||
let ord = ordinals.entry(style.clone()).or_insert(0);
|
||||
*ord = ord.saturating_add(1);
|
||||
let ordinal = *ord;
|
||||
let num = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0);
|
||||
let (mut label, mut levels) = (None, Vec::new());
|
||||
if let Some(n) = num
|
||||
&& let Some((l, def)) = counters.next_label(&doc.numbering, n.num, n.level)
|
||||
&& def.format != NumFormat::Bullet
|
||||
{
|
||||
let vals = counters.values(&doc.numbering, n.num).unwrap_or_default();
|
||||
for (k, v) in vals.iter().enumerate().take(usize::from(n.level.min(8)) + 1) {
|
||||
let Some(lv) = doc.numbering.level(n.num, k as u8) else { continue };
|
||||
if matches!(lv.format, NumFormat::None | NumFormat::Bullet) {
|
||||
continue;
|
||||
}
|
||||
let v = if *v == 0 { lv.start } else { *v };
|
||||
let fmt = if def.legal && lv.format != NumFormat::DecimalZero { NumFormat::Decimal } else { lv.format };
|
||||
levels.push((fmt.format(v), def.text.contains(&format!("%{}", k + 1))));
|
||||
}
|
||||
label = Some(l);
|
||||
}
|
||||
let mut runs: Vec<(String, String)> = Vec::new();
|
||||
let mut off = 0usize;
|
||||
for r in &p.runs {
|
||||
let end = off.saturating_add(r.len);
|
||||
if let Some(cs) = &r.props.style
|
||||
&& let Some(t) = p.text.get(off..end)
|
||||
{
|
||||
let t: String = t.chars().filter(|c| *c != wordcraft_doc::para::OBJ).collect();
|
||||
match runs.last_mut() {
|
||||
Some((s, prev)) if s == cs => prev.push_str(&t),
|
||||
_ => runs.push((cs.clone(), t)),
|
||||
}
|
||||
}
|
||||
off = end;
|
||||
}
|
||||
by_path.insert(path.clone(), entries.len());
|
||||
entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, ordinal });
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
entries.hash(&mut h);
|
||||
let mut keys: Vec<_> = styles.iter().collect();
|
||||
keys.sort();
|
||||
keys.hash(&mut h);
|
||||
StyleIndex { entries, by_path, styles, hash: h.finish() }
|
||||
}
|
||||
|
||||
/// A hash of everything a STYLEREF result can depend on (for layout caches).
|
||||
pub fn hash(&self) -> u64 {
|
||||
self.hash
|
||||
}
|
||||
|
||||
/// The entry of the body paragraph at `path`.
|
||||
pub fn index_of(&self, path: &Path) -> Option<usize> {
|
||||
self.by_path.get(path).copied()
|
||||
}
|
||||
|
||||
/// Where the header or footer of a page is, from the body paragraphs on the page (by path)
|
||||
/// and the page's first top-level block.
|
||||
pub fn page_at<'a>(&self, paths: impl Iterator<Item = &'a Path>, first_block: usize) -> At {
|
||||
let mut range: Option<(usize, usize)> = None;
|
||||
for i in paths.filter_map(|p| self.index_of(p)) {
|
||||
range = Some(range.map_or((i, i), |(a, b)| (a.min(i), b.max(i))));
|
||||
}
|
||||
match range {
|
||||
Some((first, last)) => At::Page { first, last: Some(last) },
|
||||
None => {
|
||||
let first = self.entries.iter().position(|e| e.path.0.first().is_some_and(|b| *b as usize >= first_block));
|
||||
At::Page { first: first.unwrap_or(self.entries.len()), last: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn target(&self, style: &str) -> Option<Target> {
|
||||
let style = style.trim();
|
||||
if let Ok(n) = style.parse::<u8>() {
|
||||
if !(1..=9).contains(&n) {
|
||||
return None;
|
||||
}
|
||||
let id = self.styles.get(&format!("heading{n}")).map(|s| s.0.clone()).unwrap_or_else(|| format!("Heading{n}"));
|
||||
return Some(Target::Para(id));
|
||||
}
|
||||
let (id, chr) = self.styles.get(&style.to_lowercase())?;
|
||||
Some(if *chr { Target::Char(id.clone()) } else { Target::Para(id.clone()) })
|
||||
}
|
||||
|
||||
fn text_of(&self, i: usize, t: &Target) -> Option<String> {
|
||||
let e = self.entries.get(i)?;
|
||||
match t {
|
||||
Target::Para(id) => (e.style == *id).then(|| e.text.clone()),
|
||||
Target::Char(id) => e.runs.iter().find(|(s, _)| s == id).map(|(_, t)| clean(t)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The field's result.
|
||||
pub fn eval(&self, instr: &str, at: At) -> String {
|
||||
let code = parse(instr);
|
||||
let Some(target) = self.target(&code.style) else { return NO_TEXT.to_string() };
|
||||
let n = self.entries.len();
|
||||
// Candidates in search order, with whether each one counts as below the field.
|
||||
let order: Vec<(usize, bool)> = match at {
|
||||
At::Page { first, last } => {
|
||||
let on: Vec<usize> = match last {
|
||||
Some(l) if first <= l => (first..=l.min(n.saturating_sub(1))).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let after = last.map_or(first, |l| l.saturating_add(1));
|
||||
let page: Vec<(usize, bool)> =
|
||||
if code.last { on.iter().rev().map(|i| (*i, true)).collect() } else { on.iter().map(|i| (*i, true)).collect() };
|
||||
page.into_iter().chain((0..first.min(n)).rev().map(|i| (i, false))).chain((after..n).map(|i| (i, true))).collect()
|
||||
}
|
||||
At::Body(i) => (0..=i.min(n.saturating_sub(1))).rev().map(|k| (k, false)).chain((i.saturating_add(1)..n).map(|k| (k, true))).collect(),
|
||||
};
|
||||
let Some((i, text, below)) = order.into_iter().find_map(|(i, below)| self.text_of(i, &target).map(|t| (i, t, below))) else {
|
||||
return NO_TEXT.to_string();
|
||||
};
|
||||
let Some(e) = self.entries.get(i) else { return NO_TEXT.to_string() };
|
||||
let mut out = match code.number {
|
||||
Number::Text if code.position => String::new(),
|
||||
Number::Text => text,
|
||||
Number::Chapter => e.label.as_deref().map(chapter_number).filter(|s| !s.is_empty()).unwrap_or_else(|| e.ordinal.to_string()),
|
||||
Number::Plain | Number::Full => {
|
||||
let own = match &e.label {
|
||||
Some(l) if code.suppress => suppress(l, &e.levels),
|
||||
Some(l) => l.trim().trim_end_matches('.').to_string(),
|
||||
None => e.ordinal.to_string(),
|
||||
};
|
||||
if code.number == Number::Full && e.label.is_some() {
|
||||
// Levels above that the label's own template doesn't show.
|
||||
let above: Vec<&str> =
|
||||
e.levels.iter().take(e.levels.len().saturating_sub(1)).filter(|(_, shown)| !shown).map(|(v, _)| v.as_str()).collect();
|
||||
if above.is_empty() { own } else { format!("{}.{own}", above.join(".")) }
|
||||
} else {
|
||||
own
|
||||
}
|
||||
}
|
||||
};
|
||||
if code.position {
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(if below { "below" } else { "above" });
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The number part of a label (`Chapter 2.` → `2`).
|
||||
fn chapter_number(label: &str) -> String {
|
||||
let word = label.split_whitespace().last().unwrap_or("");
|
||||
word.trim_matches(|c: char| !c.is_alphanumeric()).to_string()
|
||||
}
|
||||
|
||||
/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`).
|
||||
fn suppress(label: &str, levels: &[(String, bool)]) -> String {
|
||||
let kept: Vec<&str> = label.split_whitespace().filter(|w| levels.iter().any(|(v, _)| !v.is_empty() && w.contains(v.as_str()))).collect();
|
||||
let s = if kept.is_empty() { label.trim().to_string() } else { kept.join(" ") };
|
||||
s.trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// Does `p` hold a STYLEREF field?
|
||||
pub fn has_styleref(p: &wordcraft_doc::Paragraph) -> bool {
|
||||
p.objects.iter().any(is_styleref)
|
||||
}
|
||||
|
||||
fn is_styleref(o: &InlineObject) -> bool {
|
||||
matches!(o, InlineObject::Field { instr, .. } if crate::fields::field_name(instr) == "STYLEREF")
|
||||
}
|
||||
|
||||
/// Does any header or footer of `doc` hold a STYLEREF field?
|
||||
pub fn headers_use_styleref(doc: &Document) -> bool {
|
||||
doc.parts.keys().any(|id| doc.para_paths(StoryRef::Part(*id)).iter().any(|p| doc.para(StoryRef::Part(*id), p).is_some_and(has_styleref)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_names_levels_and_switches() {
|
||||
let c = parse(r#"STYLEREF "Heading 1" \l \n \t \* MERGEFORMAT"#);
|
||||
assert_eq!(c.style, "Heading 1");
|
||||
assert!(c.last && c.suppress && !c.position);
|
||||
assert_eq!(c.number, Number::Plain);
|
||||
assert_eq!(parse("STYLEREF 1 \\s").style, "1");
|
||||
assert_eq!(parse("STYLEREF 1 \\s").number, Number::Chapter);
|
||||
assert_eq!(parse("STYLEREF Heading 2 \\p").style, "Heading 2");
|
||||
assert_eq!(parse("STYLEREF").style, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppress_keeps_the_number() {
|
||||
assert_eq!(suppress("Chapter 2.", &[("2".into(), true)]), "2");
|
||||
assert_eq!(chapter_number("Chapter IV."), "IV");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! STYLEREF fields in headers and footers (#775): each page shows its own result.
|
||||
|
||||
use super::*;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{CharProps, NumRef};
|
||||
use wordcraft_doc::{ListKind, PartKind, Pos, para_block};
|
||||
|
||||
fn field(instr: &str) -> InlineObject {
|
||||
InlineObject::Field { instr: instr.into(), result: "stale".into(), locked: false, code: false }
|
||||
}
|
||||
|
||||
/// Body paragraphs: `(style, text, page break before)`; a header with one STYLEREF per code.
|
||||
fn doc(paras: &[(&str, &str, bool)], codes: &[&str]) -> Document {
|
||||
let mut d = Document::from_text(¶s.iter().map(|p| p.1).collect::<Vec<_>>().join("\n"));
|
||||
for (i, (style, _, brk)) in paras.iter().enumerate() {
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
|
||||
p.props.style = Some((*style).to_string());
|
||||
p.props.page_break_before = Some(*brk);
|
||||
}
|
||||
let blocks = codes
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut p = Paragraph::new();
|
||||
p.insert_object(0, field(c), &CharProps::default()).unwrap();
|
||||
para_block(p)
|
||||
})
|
||||
.collect();
|
||||
let id = d.add_part(PartKind::Header, blocks);
|
||||
d.last_section.headers.default = Some(id);
|
||||
d
|
||||
}
|
||||
|
||||
fn shown(items: &[Placed]) -> Vec<String> {
|
||||
items.iter().flat_map(|it| if let Placed::Lines { para, .. } = it { para.shown.iter().map(|s| s.1.clone()).collect() } else { vec![] }).collect()
|
||||
}
|
||||
|
||||
fn headers(d: &Document) -> Vec<Vec<String>> {
|
||||
let mut c = LayoutCache::new();
|
||||
layout(d, &mut c, &LayoutOptions::default()).pages.iter().map(|p| shown(&p.header)).collect()
|
||||
}
|
||||
|
||||
const CHAPTERS: &[(&str, &str, bool)] = &[
|
||||
("Heading1", "Beginnings", false),
|
||||
("Normal", "Text of one.", false),
|
||||
("Heading1", "Middles", true),
|
||||
("Normal", "Text of two.", false),
|
||||
// A page with no heading: the header keeps the chapter before it.
|
||||
("Normal", "More of two.", true),
|
||||
("Heading1", "Endings", true),
|
||||
("Heading2", "A section", false),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn each_page_s_header_shows_its_chapter() {
|
||||
let d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1" \* MERGEFORMAT"#, "STYLEREF 1", "STYLEREF heading1 \\l"]);
|
||||
let h = headers(&d);
|
||||
assert_eq!(h.len(), 4);
|
||||
for (page, want) in ["Beginnings", "Middles", "Middles", "Endings"].iter().enumerate() {
|
||||
assert_eq!(h[page], [*want, *want, *want], "page {}", page + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_from_the_bottom_with_l() {
|
||||
let paras = [("Heading2", "First", false), ("Heading2", "Second", false), ("Normal", "Body", false), ("Heading2", "Third", true)];
|
||||
let d = doc(¶s, &[r#"STYLEREF "Heading 2""#, r#"STYLEREF "Heading 2" \l"#]);
|
||||
assert_eq!(headers(&d), [vec!["First", "Second"], vec!["Third", "Third"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_style_or_no_paragraph_of_it_is_an_error() {
|
||||
let d = doc(CHAPTERS, &[r#"STYLEREF "No Such Style""#, r#"STYLEREF "Heading 3""#, "STYLEREF 12"]);
|
||||
let err = styleref::NO_TEXT;
|
||||
assert_eq!(headers(&d)[0], [err, err, err]);
|
||||
assert_eq!(err, "Error: no paragraph in this style.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_the_first_heading_the_next_one_shows() {
|
||||
let paras = [("Normal", "Preface", false), ("Heading1", "Chapter", true)];
|
||||
let d = doc(¶s, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]);
|
||||
assert_eq!(headers(&d), [vec!["Chapter", "below"], vec!["Chapter", "below"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_styles_and_paragraph_numbers() {
|
||||
let mut d = doc(CHAPTERS, &[r#"STYLEREF Strong"#, r#"STYLEREF "Heading 1" \n"#, r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 1" \s"#]);
|
||||
// Bold a word on page 2 with the Strong character style.
|
||||
d.format_range(&Pos::body(3, 8), &Pos::body(3, 11), &|c: &mut CharProps| c.style = Some("Strong".into())).unwrap();
|
||||
// Number the headings 1., 2., 3. and the section under them 3.1 (full context).
|
||||
let num = d.numbering.add_list(ListKind::Numbered);
|
||||
for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] {
|
||||
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl });
|
||||
}
|
||||
let h = headers(&d);
|
||||
assert_eq!(h[1], ["two", "2", "3.a", "2"]);
|
||||
assert_eq!(h[3], ["two", "3", "3.a", "3"]);
|
||||
}
|
||||
|
||||
/// Editing a heading updates the headers that show it, through the paragraph cache.
|
||||
#[test]
|
||||
fn headers_follow_heading_edits() {
|
||||
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#]);
|
||||
let mut c = LayoutCache::new();
|
||||
let first = layout(&d, &mut c, &LayoutOptions::default());
|
||||
assert_eq!(shown(&first.pages[1].header), ["Middles"]);
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
|
||||
p.insert_text(0, "The ", &CharProps::default()).unwrap();
|
||||
let again = layout(&d, &mut c, &LayoutOptions::default());
|
||||
assert_eq!(shown(&again.pages[1].header), ["The Middles"]);
|
||||
assert_eq!(shown(&again.pages[0].header), ["Beginnings"]);
|
||||
}
|
||||
|
||||
/// The style index is built once and reused while the body and the styles stay the same (long
|
||||
/// documents lay out on every keystroke); an edited heading or a changed style rebuilds it.
|
||||
#[test]
|
||||
fn style_index_is_reused_until_the_body_or_styles_change() {
|
||||
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#]);
|
||||
let mut c = LayoutCache::new();
|
||||
let index = |c: &LayoutCache| c.styleref.as_ref().map(|(_, _, ix)| ix.clone()).unwrap();
|
||||
let pages =
|
||||
|d: &Document, c: &mut LayoutCache| layout(d, c, &LayoutOptions::default()).pages.iter().map(|p| shown(&p.header)).collect::<Vec<_>>();
|
||||
let before = pages(&d, &mut c);
|
||||
let first = index(&c);
|
||||
assert_eq!(pages(&d, &mut c), before);
|
||||
assert!(Arc::ptr_eq(&first, &index(&c)), "nothing changed: the same index");
|
||||
// Rename the first chapter: rebuilt, and the header shows the new title.
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.text = "Openings".into();
|
||||
p.runs = vec![wordcraft_doc::para::Run { len: p.text.len(), props: Default::default() }];
|
||||
let after = pages(&d, &mut c);
|
||||
assert!(!Arc::ptr_eq(&first, &index(&c)), "an edited heading rebuilds it");
|
||||
assert!(after[0].iter().any(|t| t.contains("Openings")), "{after:?}");
|
||||
// A style change rebuilds it too.
|
||||
let second = index(&c);
|
||||
if let Some(st) = d.styles.styles.iter_mut().find(|s| s.id == "Heading1") {
|
||||
st.name = "Chapter Title".into();
|
||||
}
|
||||
let _ = pages(&d, &mut c);
|
||||
assert!(!Arc::ptr_eq(&second, &index(&c)), "a style change rebuilds it");
|
||||
}
|
||||
@@ -471,6 +471,32 @@ fn fields_put_their_shown_text_in_the_text_layer() {
|
||||
assert!(squash(&text[0]).contains("Page 1 of 2") && squash(&text[1]).contains("Page 2 of 2"), "{text:?}");
|
||||
}
|
||||
|
||||
/// #775: a running header `STYLEREF "Heading 1"` shows each page's chapter in the PDF.
|
||||
#[test]
|
||||
fn styleref_headers_show_each_page_s_chapter() {
|
||||
let mut d = Document::new();
|
||||
let mut h = Paragraph::new();
|
||||
let field = InlineObject::Field { instr: r#"STYLEREF "Heading 1""#.into(), result: "Stale".into(), locked: false, code: false };
|
||||
h.insert_object(0, field, &CharProps::default()).unwrap();
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(h)]);
|
||||
d.last_section.headers.default = Some(id);
|
||||
d.body = ["Alpha", "Beta"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let mut p = Paragraph::with_text(&format!("Chapter{t}"), CharProps::default()).styled("Heading1");
|
||||
p.props.page_break_before = Some(i > 0);
|
||||
para_block(p)
|
||||
})
|
||||
.collect();
|
||||
let text = extract_text(&export(&d, &PdfOptions::default()).unwrap());
|
||||
assert_eq!(text.len(), 2);
|
||||
let pages: Vec<String> = text.iter().map(|t| squash(t)).collect();
|
||||
assert_eq!(pages[0].matches("ChapterAlpha").count(), 2, "{pages:?}");
|
||||
assert_eq!(pages[1].matches("ChapterBeta").count(), 2, "{pages:?}");
|
||||
assert!(!pages.iter().any(|p| p.contains("Stale")), "{pages:?}");
|
||||
}
|
||||
|
||||
/// Bundled Source Sans 3 renamed to `family`, with glyph `gid` turned into a composite glyph whose
|
||||
/// component list is cut off: fonts still parse and shape, but krilla's subsetter rejects it.
|
||||
fn unsubsettable_font(family: &str, gid: u16) -> Vec<u8> {
|
||||
|
||||
@@ -923,6 +923,8 @@ pub enum FieldKind {
|
||||
Sequence,
|
||||
Merge,
|
||||
Toc,
|
||||
/// STYLEREF: a style and its search and number switches.
|
||||
Style,
|
||||
Plain,
|
||||
}
|
||||
|
||||
@@ -944,10 +946,21 @@ pub const FIELDS: &[(&str, &str, &str)] = &[
|
||||
("SEQ", "Numbering", "A numbered sequence (figures, tables…)"),
|
||||
("PAGEREF", "Links and References", "Page on which a bookmarked passage falls"),
|
||||
("REF", "Links and References", "Copy of the text a bookmark marks"),
|
||||
("STYLEREF", "Links and References", "Text of the nearest paragraph with a given style (running headers)"),
|
||||
("TOC", "Index and Tables", "A table of contents"),
|
||||
("MERGEFIELD", "Mail Merge", "A mail merge field"),
|
||||
];
|
||||
|
||||
/// STYLEREF's options (ECMA-376 §17.16.5.59): switch letter and label.
|
||||
pub const STYLEREF_SWITCHES: &[(char, &str)] = &[
|
||||
('l', "Search from bottom of page to top"),
|
||||
('n', "Insert paragraph number"),
|
||||
('r', "Insert paragraph number in relative context"),
|
||||
('w', "Insert paragraph number in full context"),
|
||||
('t', "Suppress all non-delimiter characters"),
|
||||
('p', "Insert position (above or below)"),
|
||||
];
|
||||
|
||||
/// The categories, in list order ("(All)" first).
|
||||
pub const FIELD_CATEGORIES: &[&str] =
|
||||
&["(All)", "Date and Time", "Document Information", "Links and References", "Index and Tables", "Mail Merge", "Numbering"];
|
||||
@@ -993,6 +1006,7 @@ pub fn field_kind(name: &str) -> FieldKind {
|
||||
"SEQ" => FieldKind::Sequence,
|
||||
"MERGEFIELD" => FieldKind::Merge,
|
||||
"TOC" => FieldKind::Toc,
|
||||
"STYLEREF" => FieldKind::Style,
|
||||
_ => FieldKind::Plain,
|
||||
}
|
||||
}
|
||||
@@ -1008,8 +1022,13 @@ pub struct FieldForm {
|
||||
pub date_format: String,
|
||||
/// `\*` value for number fields (empty = none).
|
||||
pub number_format: String,
|
||||
/// Bookmark (REF, PAGEREF), sequence name (SEQ) or merge field (MERGEFIELD).
|
||||
/// Bookmark (REF, PAGEREF), sequence name (SEQ), merge field (MERGEFIELD) or style name
|
||||
/// (STYLEREF).
|
||||
pub target: String,
|
||||
/// STYLEREF switches, as their letters (`l` search from the bottom of the page, `n`
|
||||
/// paragraph number, `r` relative context, `w` full context, `t` suppress non-numbers,
|
||||
/// `p` above/below).
|
||||
pub style_switches: String,
|
||||
/// Keep the formatting applied to the result when it updates (`\* MERGEFORMAT`).
|
||||
pub preserve: bool,
|
||||
/// Field Codes: type the code directly.
|
||||
@@ -1019,6 +1038,9 @@ pub struct FieldForm {
|
||||
bookmarks: Vec<String>,
|
||||
#[serde(skip)]
|
||||
merge_fields: Vec<String>,
|
||||
/// Paragraph and character style names, for STYLEREF.
|
||||
#[serde(skip)]
|
||||
styles: Vec<String>,
|
||||
}
|
||||
|
||||
impl FieldForm {
|
||||
@@ -1037,6 +1059,21 @@ impl FieldForm {
|
||||
code: String::new(),
|
||||
bookmarks,
|
||||
merge_fields: app.session.merge.headers.clone(),
|
||||
style_switches: String::new(),
|
||||
styles: {
|
||||
let mut v: Vec<String> = app
|
||||
.session
|
||||
.doc
|
||||
.styles
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|s| matches!(s.kind, wordcraft_doc::StyleKind::Paragraph | wordcraft_doc::StyleKind::Character) && !s.hidden)
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
v.sort_by_key(|n| n.to_lowercase());
|
||||
v.dedup();
|
||||
v
|
||||
},
|
||||
};
|
||||
f.select("DATE");
|
||||
f
|
||||
@@ -1050,8 +1087,16 @@ impl FieldForm {
|
||||
FieldKind::Bookmark => self.bookmarks.first().cloned().unwrap_or_default(),
|
||||
FieldKind::Sequence => "Figure".into(),
|
||||
FieldKind::Merge => self.merge_fields.first().cloned().unwrap_or_default(),
|
||||
FieldKind::Style => self
|
||||
.styles
|
||||
.iter()
|
||||
.find(|n| n.eq_ignore_ascii_case("Heading 1"))
|
||||
.or(self.styles.first())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Heading 1".into()),
|
||||
_ => String::new(),
|
||||
};
|
||||
self.style_switches.clear();
|
||||
if field_kind(name) == FieldKind::Date {
|
||||
self.date_format = if name == "TIME" { "h:mm am/pm" } else { "M/d/yyyy" }.into();
|
||||
}
|
||||
@@ -1077,6 +1122,14 @@ impl FieldForm {
|
||||
}
|
||||
}
|
||||
FieldKind::Toc => s.push_str(" \\o \"1-3\" \\h \\z \\u"),
|
||||
FieldKind::Style if !target.is_empty() => {
|
||||
s.push_str(&format!(" \"{}\"", target.replace(['"', '\\'], "")));
|
||||
for (c, _) in STYLEREF_SWITCHES {
|
||||
if self.style_switches.contains(*c) {
|
||||
s.push_str(&format!(" \\{c}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if matches!(field_kind(&self.name), FieldKind::Number | FieldKind::Sequence) && !self.number_format.is_empty() {
|
||||
@@ -1242,6 +1295,24 @@ fn field_options(ui: &mut Ui, f: &mut FieldForm, t: &Tokens) {
|
||||
});
|
||||
}
|
||||
}
|
||||
FieldKind::Style => {
|
||||
ui.label(tl!("Style name:"));
|
||||
egui::ComboBox::from_id_salt("field_style").selected_text(f.target.clone()).width(200.0).show_ui(ui, |ui| {
|
||||
for n in &f.styles {
|
||||
ui.selectable_value(&mut f.target, n.clone(), n);
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
for (c, label) in STYLEREF_SWITCHES {
|
||||
let mut on = f.style_switches.contains(*c);
|
||||
if ui.checkbox(&mut on, tl!(label)).changed() {
|
||||
f.style_switches.retain(|x| x != *c);
|
||||
if on {
|
||||
f.style_switches.push(*c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FieldKind::Toc | FieldKind::Plain => {
|
||||
ui.label(egui::RichText::new(tl!("This field has no options.")).small().color(t.text_dim));
|
||||
}
|
||||
@@ -1323,6 +1394,10 @@ mod tests {
|
||||
f.select("REF");
|
||||
f.target = "intro".into();
|
||||
assert_eq!(f.instruction(), r"REF intro \h");
|
||||
f.select("STYLEREF");
|
||||
assert_eq!(f.target, "Heading 1");
|
||||
f.style_switches = "pl".into();
|
||||
assert_eq!(f.instruction(), r#"STYLEREF "Heading 1" \l \p"#);
|
||||
f.select("MERGEFIELD");
|
||||
f.target = "First Name".into();
|
||||
assert_eq!(f.params().unwrap(), json!({"instr": r#"MERGEFIELD "First Name""#, "result": "«First Name»"}));
|
||||
|
||||
@@ -2441,6 +2441,14 @@
|
||||
Pastes the clipboard's picture. Поставя картината от клипборда.
|
||||
Region: Регион:
|
||||
Automatic ({region}) Автоматично ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Текст на най-близкия абзац с даден стил (текущи колонтитули)
|
||||
Style name: Име на стил:
|
||||
Search from bottom of page to top Търсене от долния към горния край на страницата
|
||||
Insert paragraph number Вмъкване на номер на абзац
|
||||
Insert paragraph number in relative context Вмъкване на номер на абзац в относителен контекст
|
||||
Insert paragraph number in full context Вмъкване на номер на абзац в пълен контекст
|
||||
Suppress all non-delimiter characters Премахване на всички знаци освен разделителите
|
||||
Insert position (above or below) Вмъкване на позиция (над или под)
|
||||
Undo 1 Action Отмяна на 1 действие
|
||||
Undo Actions Отмяна на действия
|
||||
Undo History История на отмяната
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2466,6 +2466,14 @@ preview Title Ukázka
|
||||
Correct accidental usage of cAPS LOCK key Opravovat náhodné použití klávesy cAPS LOCK
|
||||
Region: Oblast:
|
||||
Automatic ({region}) Automaticky ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Text nejbližšího odstavce s daným stylem (živá záhlaví)
|
||||
Style name: Název stylu:
|
||||
Search from bottom of page to top Hledat od konce stránky k začátku
|
||||
Insert paragraph number Vložit číslo odstavce
|
||||
Insert paragraph number in relative context Vložit číslo odstavce v relativním kontextu
|
||||
Insert paragraph number in full context Vložit číslo odstavce v úplném kontextu
|
||||
Suppress all non-delimiter characters Potlačit všechny znaky kromě oddělovačů
|
||||
Insert position (above or below) Vložit pozici (nad nebo pod)
|
||||
Search Hledat
|
||||
Title Bar Záhlaví okna
|
||||
Copy to Kopírovat do
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2081,6 +2081,14 @@
|
||||
Correct accidental usage of cAPS LOCK key Unbeabsichtigtes Verwenden der fESTSTELLTASTE korrigieren
|
||||
Region: Region:
|
||||
Automatic ({region}) Automatisch ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Text des nächsten Absatzes mit einer bestimmten Formatvorlage (lebende Kolumnentitel)
|
||||
Style name: Formatvorlagenname:
|
||||
Search from bottom of page to top Seite von unten nach oben durchsuchen
|
||||
Insert paragraph number Absatznummer einfügen
|
||||
Insert paragraph number in relative context Absatznummer in relativem Kontext einfügen
|
||||
Insert paragraph number in full context Absatznummer in vollständigem Kontext einfügen
|
||||
Suppress all non-delimiter characters Alle Zeichen außer Trennzeichen unterdrücken
|
||||
Insert position (above or below) Position einfügen (oberhalb oder unterhalb)
|
||||
Search Suchen
|
||||
Title Bar Titelleiste
|
||||
Copy to Kopieren nach
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2447,6 +2447,14 @@ preview Title Título
|
||||
Other Large Operators Otros operadores grandes
|
||||
Region: Región:
|
||||
Automatic ({region}) Automático ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Texto del párrafo más cercano con un estilo dado (encabezados variables)
|
||||
Style name: Nombre del estilo:
|
||||
Search from bottom of page to top Buscar desde el final de la página hacia el principio
|
||||
Insert paragraph number Insertar número de párrafo
|
||||
Insert paragraph number in relative context Insertar número de párrafo en contexto relativo
|
||||
Insert paragraph number in full context Insertar número de párrafo en contexto completo
|
||||
Suppress all non-delimiter characters Suprimir todos los caracteres que no sean delimitadores
|
||||
Insert position (above or below) Insertar posición (encima o debajo)
|
||||
Start-up Inicio de la aplicación
|
||||
Show the Start screen when WordCraft starts Mostrar la pantalla de inicio al abrir WordCraft
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Activado: WordCraft se abre en Archivo › Inicio, con un documento en blanco, las plantillas y tus documentos recientes. Desactivado: abre un documento en blanco.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2115,6 +2115,14 @@
|
||||
AutoFit Column by Double-Clicking Veeru automaatne sobitus topeltklõpsuga
|
||||
Region: Piirkond:
|
||||
Automatic ({region}) Automaatne ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Antud stiiliga lähima lõigu tekst (jooksvad päised)
|
||||
Style name: Stiili nimi:
|
||||
Search from bottom of page to top Otsi lehe lõpust alguse poole
|
||||
Insert paragraph number Lisa lõigu number
|
||||
Insert paragraph number in relative context Lisa lõigu number suhtelises kontekstis
|
||||
Insert paragraph number in full context Lisa lõigu number täielikus kontekstis
|
||||
Suppress all non-delimiter characters Jäta välja kõik märgid peale eraldajate
|
||||
Insert position (above or below) Lisa asukoht (ülal või all)
|
||||
Start-up Käivitamine
|
||||
Show the Start screen when WordCraft starts Näita WordCrafti käivitamisel avakuva
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Sees: WordCraft avaneb vaates Fail › Avaleht, kus on tühi dokument, mallid ja hiljutised dokumendid. Väljas: avaneb tühi dokument.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 460 and column 13.
|
@@ -2083,6 +2083,14 @@
|
||||
Automatic ({region}) Automatique ({region})
|
||||
Picture Image
|
||||
Pastes the clipboard's picture. Colle l'image du Presse-papiers.
|
||||
Text of the nearest paragraph with a given style (running headers) Texte du paragraphe le plus proche ayant un style donné (titres courants)
|
||||
Style name: Nom du style :
|
||||
Search from bottom of page to top Rechercher du bas vers le haut de la page
|
||||
Insert paragraph number Insérer le numéro de paragraphe
|
||||
Insert paragraph number in relative context Insérer le numéro de paragraphe en contexte relatif
|
||||
Insert paragraph number in full context Insérer le numéro de paragraphe en contexte complet
|
||||
Suppress all non-delimiter characters Supprimer tous les caractères autres que les séparateurs
|
||||
Insert position (above or below) Insérer la position (au-dessus ou au-dessous)
|
||||
Select Column Sélectionner la colonne
|
||||
Select Table Cells by Clicking Sélectionner des cellules du tableau en cliquant
|
||||
Insert Row or Column by Clicking Insérer une ligne ou une colonne en cliquant
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2464,6 +2464,14 @@
|
||||
Automatic ({region}) Automatikus ({region})
|
||||
Picture Kép
|
||||
Pastes the clipboard's picture. Beilleszti a vágólapon lévő képet.
|
||||
Text of the nearest paragraph with a given style (running headers) Az adott stílusú legközelebbi bekezdés szövege (élőfejek)
|
||||
Style name: Stílus neve:
|
||||
Search from bottom of page to top Keresés a lap aljától felfelé
|
||||
Insert paragraph number Bekezdésszám beszúrása
|
||||
Insert paragraph number in relative context Bekezdésszám beszúrása relatív környezetben
|
||||
Insert paragraph number in full context Bekezdésszám beszúrása teljes környezetben
|
||||
Suppress all non-delimiter characters Az elválasztókon kívüli összes karakter elhagyása
|
||||
Insert position (above or below) Pozíció beszúrása (fent vagy lent)
|
||||
Select Column Oszlop kijelölése
|
||||
Select Table Cells by Clicking Táblázatcellák kijelölése kattintással
|
||||
Insert Row or Column by Clicking Sor vagy oszlop beszúrása kattintással
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2457,6 +2457,14 @@ preview Title Esempio
|
||||
Correct accidental usage of cAPS LOCK key Correggi l'uso accidentale del tasto bLOC mAIUSC
|
||||
Region: Area geografica:
|
||||
Automatic ({region}) Automatico ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Testo del paragrafo più vicino con uno stile specificato (intestazioni correnti)
|
||||
Style name: Nome stile:
|
||||
Search from bottom of page to top Cerca dal fondo della pagina verso l'alto
|
||||
Insert paragraph number Inserisci numero di paragrafo
|
||||
Insert paragraph number in relative context Inserisci numero di paragrafo in contesto relativo
|
||||
Insert paragraph number in full context Inserisci numero di paragrafo in contesto completo
|
||||
Suppress all non-delimiter characters Elimina tutti i caratteri diversi dai delimitatori
|
||||
Insert position (above or below) Inserisci posizione (sopra o sotto)
|
||||
Search Cerca
|
||||
Title Bar Barra del titolo
|
||||
Copy to Copia in
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2081,6 +2081,14 @@
|
||||
AutoFit Column by Double-Clicking ダブルクリックで列の幅を自動調整
|
||||
Region: 地域:
|
||||
Automatic ({region}) 自動 ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) 指定したスタイルを持つ最も近い段落の文字列 (柱)
|
||||
Style name: スタイル名:
|
||||
Search from bottom of page to top ページの下から上へ検索する
|
||||
Insert paragraph number 段落番号を挿入する
|
||||
Insert paragraph number in relative context 段落番号を相対的な形式で挿入する
|
||||
Insert paragraph number in full context 段落番号を完全な形式で挿入する
|
||||
Suppress all non-delimiter characters 区切り文字以外の文字をすべて非表示にする
|
||||
Insert position (above or below) 位置 (上または下) を挿入する
|
||||
Start-up 起動
|
||||
Show the Start screen when WordCraft starts WordCraft の起動時にスタート画面を表示する
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. オン: WordCraft は [ファイル] › [ホーム] で開き、白紙の文書、テンプレート、最近使った文書を表示します。オフ: 白紙の文書を開きます。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 568 and column 13.
|
@@ -2123,6 +2123,14 @@
|
||||
Correct accidental usage of cAPS LOCK key Poprawiaj przypadkowe użycie klawisza cAPS LOCK
|
||||
Region: Region:
|
||||
Automatic ({region}) Automatycznie ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Tekst najbliższego akapitu o danym stylu (żywa pagina)
|
||||
Style name: Nazwa stylu:
|
||||
Search from bottom of page to top Szukaj od dołu strony do góry
|
||||
Insert paragraph number Wstaw numer akapitu
|
||||
Insert paragraph number in relative context Wstaw numer akapitu w kontekście względnym
|
||||
Insert paragraph number in full context Wstaw numer akapitu w pełnym kontekście
|
||||
Suppress all non-delimiter characters Pomiń wszystkie znaki niebędące ogranicznikami
|
||||
Insert position (above or below) Wstaw położenie (powyżej lub poniżej)
|
||||
|
||||
# Style, table style, watermark, paper size and equation gallery names, from #750 by @olekkut
|
||||
AaBbCcDd AaBbCcDd
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2093,6 +2093,14 @@
|
||||
AutoFit Column by Double-Clicking Autoajustar coluna com clique duplo
|
||||
Region: Região:
|
||||
Automatic ({region}) Automático ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Texto do parágrafo mais próximo com um determinado estilo (cabeçalhos dinâmicos)
|
||||
Style name: Nome do estilo:
|
||||
Search from bottom of page to top Pesquisar de baixo para cima na página
|
||||
Insert paragraph number Inserir número do parágrafo
|
||||
Insert paragraph number in relative context Inserir número do parágrafo em contexto relativo
|
||||
Insert paragraph number in full context Inserir número do parágrafo em contexto completo
|
||||
Suppress all non-delimiter characters Suprimir todos os caracteres que não sejam delimitadores
|
||||
Insert position (above or below) Inserir posição (acima ou abaixo)
|
||||
Start-up Inicialização
|
||||
Show the Start screen when WordCraft starts Mostrar a tela inicial ao abrir o WordCraft
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Ativado: o WordCraft abre em Arquivo › Início, com um documento em branco, os modelos e seus documentos recentes. Desativado: abre um documento em branco.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2464,6 +2464,14 @@ preview Title Пример
|
||||
AutoFit Column by Double-Clicking Автоподбор ширины столбца двойным щелчком
|
||||
Region: Регион:
|
||||
Automatic ({region}) Автоматически ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Текст ближайшего абзаца с заданным стилем (живые колонтитулы)
|
||||
Style name: Имя стиля:
|
||||
Search from bottom of page to top Искать от конца страницы к началу
|
||||
Insert paragraph number Вставлять номер абзаца
|
||||
Insert paragraph number in relative context Вставлять номер абзаца в относительном контексте
|
||||
Insert paragraph number in full context Вставлять номер абзаца в полном контексте
|
||||
Suppress all non-delimiter characters Скрывать все символы, кроме разделителей
|
||||
Insert position (above or below) Вставлять положение (выше или ниже)
|
||||
Start-up Запуск
|
||||
Show the Start screen when WordCraft starts Показывать начальный экран при запуске WordCraft
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Вкл.: WordCraft открывается на странице Файл › Главная с пустым документом, шаблонами и недавними документами. Выкл.: открывается пустой документ.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2091,6 +2091,14 @@
|
||||
AutoFit Column by Double-Clicking Automatsko uklapanje kolone dvostrukim klikom
|
||||
Region: Region:
|
||||
Automatic ({region}) Automatski ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Tekst najbližeg pasusa sa zadatim stilom (tekuća zaglavlja)
|
||||
Style name: Naziv stila:
|
||||
Search from bottom of page to top Pretraži od dna stranice ka vrhu
|
||||
Insert paragraph number Umetni broj pasusa
|
||||
Insert paragraph number in relative context Umetni broj pasusa u relativnom kontekstu
|
||||
Insert paragraph number in full context Umetni broj pasusa u punom kontekstu
|
||||
Suppress all non-delimiter characters Izostavi sve znakove osim graničnika
|
||||
Insert position (above or below) Umetni položaj (iznad ili ispod)
|
||||
Start-up Pokretanje
|
||||
Show the Start screen when WordCraft starts Prikaži početni ekran pri pokretanju programa WordCraft
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Uključeno: WordCraft se otvara na Fajl › Početak, sa praznim dokumentom, šablonima i nedavnim dokumentima. Isključeno: otvara prazan dokument.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2095,6 +2095,14 @@
|
||||
AutoFit Column by Double-Clicking Аутоматско уклапање колоне двоструким кликом
|
||||
Region: Регион:
|
||||
Automatic ({region}) Аутоматски ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Текст најближег пасуса са задатим стилом (текућа заглавља)
|
||||
Style name: Назив стила:
|
||||
Search from bottom of page to top Претражи од дна странице ка врху
|
||||
Insert paragraph number Уметни број пасуса
|
||||
Insert paragraph number in relative context Уметни број пасуса у релативном контексту
|
||||
Insert paragraph number in full context Уметни број пасуса у пуном контексту
|
||||
Suppress all non-delimiter characters Изостави све знакове осим граничника
|
||||
Insert position (above or below) Уметни положај (изнад или испод)
|
||||
Start-up Покретање
|
||||
Show the Start screen when WordCraft starts Прикажи почетни екран при покретању програма WordCraft
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Укључено: WordCraft се отвара на Фајл › Почетак, са празним документом, шаблонима и недавним документима. Искључено: отвара празан документ.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -2463,6 +2463,14 @@ preview Title Örnek
|
||||
AutoFit Column by Double-Clicking Sütunu Çift Tıklayarak Otomatik Sığdır
|
||||
Region: Bölge:
|
||||
Automatic ({region}) Otomatik ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Belirli bir stile sahip en yakın paragrafın metni (değişken üstbilgiler)
|
||||
Style name: Stil adı:
|
||||
Search from bottom of page to top Sayfanın altından üstüne doğru ara
|
||||
Insert paragraph number Paragraf numarası ekle
|
||||
Insert paragraph number in relative context Paragraf numarasını göreli bağlamda ekle
|
||||
Insert paragraph number in full context Paragraf numarasını tam bağlamda ekle
|
||||
Suppress all non-delimiter characters Ayırıcı olmayan tüm karakterleri gizle
|
||||
Insert position (above or below) Konum ekle (yukarıda veya aşağıda)
|
||||
Start-up Başlangıç
|
||||
Show the Start screen when WordCraft starts WordCraft açılırken Başlangıç ekranını göster
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Açık: WordCraft, Dosya › Giriş sayfasında boş belge, şablonlar ve son belgelerinizle açılır. Kapalı: boş bir belge açar.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 441 and column 13.
|
@@ -2093,6 +2093,14 @@
|
||||
AutoFit Column by Double-Clicking Добір ширини стовпця подвійним клацанням
|
||||
Region: Регіон:
|
||||
Automatic ({region}) Автоматично ({region})
|
||||
Text of the nearest paragraph with a given style (running headers) Текст найближчого абзацу із заданим стилем (живі колонтитули)
|
||||
Style name: Назва стилю:
|
||||
Search from bottom of page to top Шукати від кінця сторінки до початку
|
||||
Insert paragraph number Вставляти номер абзацу
|
||||
Insert paragraph number in relative context Вставляти номер абзацу у відносному контексті
|
||||
Insert paragraph number in full context Вставляти номер абзацу в повному контексті
|
||||
Suppress all non-delimiter characters Приховувати всі символи, крім роздільників
|
||||
Insert position (above or below) Вставляти положення (вище чи нижче)
|
||||
Start-up Запуск
|
||||
Show the Start screen when WordCraft starts Показувати початковий екран під час запуску WordCraft
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Увімкнено: WordCraft відкривається на сторінці Файл › Основне з порожнім документом, шаблонами та нещодавніми документами. Вимкнено: відкривається порожній документ.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -2560,3 +2560,13 @@
|
||||
# Show only the selected text's comment (#1066)
|
||||
In the margin Trong lề
|
||||
Only for the selected text Chỉ cho văn bản được chọn
|
||||
|
||||
# STYLEREF field (#882)
|
||||
Insert paragraph number Chèn số đoạn văn
|
||||
Insert paragraph number in full context Chèn số đoạn văn trong ngữ cảnh đầy đủ
|
||||
Insert paragraph number in relative context Chèn số đoạn văn trong ngữ cảnh tương đối
|
||||
Insert position (above or below) Chèn vị trí (phía trên hoặc phía dưới)
|
||||
Search from bottom of page to top Tìm từ cuối trang lên đầu
|
||||
Style name: Tên kiểu:
|
||||
Suppress all non-delimiter characters Bỏ mọi ký tự không phải dấu phân cách
|
||||
Text of the nearest paragraph with a given style (running headers) Văn bản của đoạn gần nhất có kiểu đã cho (đầu trang chạy)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2093,6 +2093,14 @@
|
||||
AutoFit Column by Double-Clicking 双击自动调整列宽
|
||||
Region: 区域:
|
||||
Automatic ({region}) 自动({region})
|
||||
Text of the nearest paragraph with a given style (running headers) 具有指定样式的最近段落的文本(书眉)
|
||||
Style name: 样式名称:
|
||||
Search from bottom of page to top 从页面底部向顶部搜索
|
||||
Insert paragraph number 插入段落编号
|
||||
Insert paragraph number in relative context 以相对上下文插入段落编号
|
||||
Insert paragraph number in full context 以完整上下文插入段落编号
|
||||
Suppress all non-delimiter characters 隐藏分隔符以外的所有字符
|
||||
Insert position (above or below) 插入位置(上方或下方)
|
||||
Start-up 启动
|
||||
Show the Start screen when WordCraft starts 启动 WordCraft 时显示开始屏幕
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. 开:WordCraft 打开时显示“文件 › 开始”,其中有空白文档、模板和最近使用的文档。关:直接打开空白文档。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2082,6 +2082,14 @@
|
||||
AutoFit Column by Double-Clicking 按兩下自動調整欄寬
|
||||
Region: 地區:
|
||||
Automatic ({region}) 自動({region})
|
||||
Text of the nearest paragraph with a given style (running headers) 具有指定樣式的最近段落文字(書眉)
|
||||
Style name: 樣式名稱:
|
||||
Search from bottom of page to top 從頁面底部往頂端搜尋
|
||||
Insert paragraph number 插入段落編號
|
||||
Insert paragraph number in relative context 以相對內容插入段落編號
|
||||
Insert paragraph number in full context 以完整內容插入段落編號
|
||||
Suppress all non-delimiter characters 隱藏分隔字元以外的所有字元
|
||||
Insert position (above or below) 插入位置(上方或下方)
|
||||
Start-up 啟動
|
||||
Show the Start screen when WordCraft starts 啟動 WordCraft 時顯示開始畫面
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. 開:WordCraft 開啟時顯示「檔案 › 常用」,其中有空白文件、範本和最近使用的文件。關:直接開啟空白文件。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 569 and column 13.
|
@@ -1,6 +1,6 @@
|
||||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** trivial (PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: duplicate document-grid row merged; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (STYLEREF running headers per page, #775; previously trivial: PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: a paragraph continued into the next column or page wraps around that column's shapes, #1374; previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
|
||||
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
|
||||
@@ -61,6 +61,8 @@ font can't match Word's metrics.
|
||||
| Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 |
|
||||
| Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — |
|
||||
| Hidden text excluded from breaking and hyphenation | ✅ | #173 | — |
|
||||
| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451) | — |
|
||||
| Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level; character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775) | — |
|
||||
| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers, PAGEREF in headers and footers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); a PAGE field in a paragraph that runs onto a later page (a page break inside it, or a natural split) shows that page's number, the rest of the paragraph broken again for the wider result (#1352); PAGEREF in headers and footers shows its bookmark's page, filled in after pagination like their totals (#1477); in the body it keeps its result until fields are updated | — |
|
||||
| Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | mode 15 behaviour; the ~60 legacy compat options are mostly ignored | 10–20 |
|
||||
| Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 |
|
||||
@@ -105,6 +107,7 @@ No overall parity estimate changes from these probes.
|
||||
| 2026-10-13 | trivial | PAGEREF in headers and footers shows its bookmark's page (#1477) |
|
||||
| 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) |
|
||||
| 2026-10-13 | trivial | On a page with footnotes, a paragraph that ends there keeps its space after above the footnote separator; if it doesn't fit, its last line (and, with widow control, one more) goes to the next page with the note. Pages without notes are unchanged (#1116) |
|
||||
| 2026-10-12 | trivial | STYLEREF fields in headers and footers show each page's chapter (or other styled text); Insert › Quick Parts › Field offers STYLEREF (#775) |
|
||||
| 2026-10-12 | trivial | a list label moves with its first line when the paragraph is centred or end-aligned (#1159) |
|
||||
| 2026-10-13 | trivial | A paragraph ending with a page break keeps its space after on the old page with its mark: the next page starts with the next paragraph at the top (it started that much too low, and a page break before after it opened a blank page) |
|
||||
| 2026-10-13 | trivial | Table rows: every cell gets the row's largest top and bottom margin; exact rows add the bottom margin on top and never split; a split row keeps its cells' bottom margin, border and space after on the first page, applies widow control in its cells and continues each cell at the top of the next page |
|
||||
|
||||
Reference in New Issue
Block a user