Browse / github.com/storytold/wordcraft / patch
xtask: field oracle, field codes compared in real Word and WordCraft
xtask: field oracle, field codes compared in real Word and WordCraft ## What `cargo xtask field-oracle <codes.txt | --code CODE>... [--out table.md] [--cli PATH] [--no-word]` takes field codes (the text between the braces), inserts each one as a field in a temporary document of real Word (COM through `powershell.exe`, from WSL or Windows) and of `wordcraft-cli`, updates both, and prints a Word-versus-WordCraft table. Word's answers are asked live and never stored. A PR that asserts Word behaviour for a field can now paste the table instead of a claim. - Word side: `xtask/src/field_oracle.ps1` (new documents only, alerts off, closed without saving, only a WINW
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Harrison-Booth (imported from pull request #4617, open)
- Base
4b8b06fe03f93c9fc81783ee3e5b01977be89efc- Size
- +390 −0 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
0133cffcea8281f2500c6bbf
How it went
No other operator has reported using it yet.
Diff
raw · xtask/field-oracle/scout-1.txt xtask/src/field_oracle.ps1 xtask/src/field_oracle.rs xtask/src/main.rs
new file mode 100644@@ -0,0 +1,59 @@+# Field codes behind the Word-versus-WordCraft differences in the first scout report (2026-10-26).+# One code per line (the text between the braces, without them); blank lines and # lines are skipped.++# Last format switch wins+NUMPAGES \* ArabicDash \* roman+NUMPAGES \* roman \* ArabicDash+PAGE \* Hex \* CardText+PAGE \* roman \* Arabic++# Basics that already match+= 790 \* CardText+= 790 \* CardText \* Caps+= 21 \* OrdText \* FirstCap+= 31 \* ArabicDash+= 458 \* Hex+= 458 \* Hex \* Lower++# Caps lowercases the rest of each word; FirstCap leaves it+TITLE "WEEKLY REPORT" \* Caps+TITLE "wEEKLY rEPORT" \* Caps+TITLE "wEEKLY rEPORT" \* FirstCap+TITLE "WEEKLY REPORT" \* Lower \* Caps+TITLE "WEEKLY REPORT" \* Caps \* Lower+= 255 \* Hex \* Caps++# DollarText truncates the whole part and rounds cents modulo 100+= 0.999 \* DollarText+= 1.995 \* DollarText+= 999999.995 \* DollarText+= 12.345 \* DollarText+= -0.004 \* DollarText+= 1.4 \* DollarText+= -1 \* DollarText++# Numbers outside a text format's range+= 1000140 \* CardText+= 999999 \* OrdText+= 1000000 \* OrdText+= 32767 \* roman+= 32768 \* roman+= 1000000 \* Hex+= -5 \* CardText+= -5 \* ArabicDash+= 0 \* roman+= 0 \* alphabetic+= 0 \* OrdText+= 27 \* alphabetic+= 2.5 \* CardText++# Backslash is an escape inside a quoted argument+TITLE "x \* Upper"+SUBJECT "Draft two" \* Upper++# Formula fields outside a table+= 1+1+=1+1+= SUM(1,2)+= 2*3+= SUM(2,3) \* CardTextnew file mode 100644@@ -0,0 +1,36 @@+# Runs field codes in real Word through COM. Driven by `cargo xtask field-oracle`, which defines+# $codeHex (hex of the UTF-8 codes, one per line, newline-joined) before this text and reads one+# `index<TAB>hex-of-result` line per code from stdout. Only temporary documents are used: alerts+# are off, documents close without saving, and only a Word this script started is killed.+$ErrorActionPreference = 'Stop'+function FromHex([string]$h) {+ if ($h.Length -eq 0) { return '' }+ $b = New-Object byte[] ($h.Length / 2)+ for ($i = 0; $i -lt $b.Length; $i++) { $b[$i] = [Convert]::ToByte($h.Substring(2 * $i, 2), 16) }+ [Text.Encoding]::UTF8.GetString($b)+}+function ToHex([string]$s) { -join ([Text.Encoding]::UTF8.GetBytes($s) | ForEach-Object { $_.ToString('x2') }) }++$before = @(Get-Process WINWORD -ErrorAction SilentlyContinue | ForEach-Object { $_.Id })+$word = $null+try {+ $word = New-Object -ComObject Word.Application+ $word.Visible = $false+ $word.DisplayAlerts = 0+ $doc = $word.Documents.Add()+ $codes = (FromHex $codeHex) -split "`n"+ for ($i = 0; $i -lt $codes.Count; $i++) {+ try {+ $end = $doc.Content.End - 1+ $f = $doc.Fields.Add($doc.Range($end, $end), -1, $codes[$i], $false)+ [void]$f.Update()+ $res = $f.Result.Text+ } catch { $res = 'COM error: ' + $_.Exception.Message }+ $doc.Content.InsertParagraphAfter()+ '{0}{1}{2}' -f $i, "`t", (ToHex (($res -replace "[\r\n\a]+", ' ').TrimEnd()))+ }+ $doc.Close(0)+} finally {+ if ($word) { try { $word.Quit(0) } catch {} }+ Get-Process WINWORD -ErrorAction SilentlyContinue | Where-Object { $before -notcontains $_.Id } | Stop-Process -Force+}new file mode 100644@@ -0,0 +1,290 @@+//! `cargo xtask field-oracle`: field codes in, a Word-versus-WordCraft table out.+//!+//! Each code is inserted as a field in a temporary document of real Word (COM through+//! `powershell.exe`; from WSL it needs `WSL_INTEROP`) and of `wordcraft-cli`, both are updated, and+//! the two result texts are compared. Word's answers are never stored: Word is asked each time.++use std::io::Write;+use std::path::{Path, PathBuf};+use std::process::{Command, Stdio};+use std::time::{Duration, Instant};++const PS_SCRIPT: &str = include_str!("field_oracle.ps1");+const WORD_TIMEOUT: Duration = Duration::from_secs(120);++pub const USAGE: &str = "\+usage: cargo xtask field-oracle <codes.txt | --code CODE>... [--out table.md] [--cli wordcraft-cli]+ codes.txt one field code per line (the text between the braces); blank and # lines are skipped+ --code add one code from the command line (repeatable)+ --out also write the table to this file+ --cli a built wordcraft-cli (default: cargo run -p wordcraft-cli)+ --no-word skip Word and print WordCraft's results only+";++/// The codes in `text`: one per line, blank and `#` lines skipped.+pub fn parse_codes(text: &str) -> Vec<String> {+ text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(str::to_string).collect()+}++fn to_hex(s: &str) -> String {+ s.bytes().map(|b| format!("{b:02x}")).collect()+}++fn from_hex(h: &str) -> Option<String> {+ let bytes: Option<Vec<u8>> = (0..h.len()).step_by(2).map(|i| u8::from_str_radix(h.get(i..i + 2)?, 16).ok()).collect();+ String::from_utf8(bytes?).ok()+}++/// Word's results by code index, from the script's `index<TAB>hex` lines.+pub fn parse_word_output(out: &str, n: usize) -> Result<Vec<String>, String> {+ let mut res = vec![None; n];+ for line in out.lines().map(str::trim).filter(|l| !l.is_empty()) {+ let (i, hex) = line.split_once('\t').unwrap_or((line, ""));+ let i: usize = i.parse().map_err(|_| format!("unexpected output from Word: {line}"))?;+ let text = from_hex(hex).ok_or_else(|| format!("bad result from Word: {line}"))?;+ *res.get_mut(i).ok_or_else(|| format!("Word returned index {i} for {n} codes"))? = Some(text);+ }+ res.into_iter().enumerate().map(|(i, r)| r.ok_or_else(|| format!("Word returned no result for code {i}"))).collect()+}++fn powershell() -> Command {+ let mut c = if cfg!(windows) {+ Command::new("powershell.exe")+ } else {+ let wsl = Path::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe");+ let mut c = Command::new(if wsl.exists() { wsl.to_str().unwrap_or("powershell.exe") } else { "powershell.exe" });+ if std::env::var_os("WSL_INTEROP").is_none() {+ c.env("WSL_INTEROP", "/run/WSL/2_interop");+ }+ c+ };+ c.args(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File"]);+ c+}++/// A path PowerShell understands: `wslpath -w` under WSL, the path itself on Windows.+fn windows_path(p: &Path) -> Result<String, String> {+ if cfg!(windows) {+ return Ok(p.to_string_lossy().into_owned());+ }+ let o = Command::new("wslpath").arg("-w").arg(p).output().map_err(|e| format!("wslpath: {e}"))?;+ if !o.status.success() {+ return Err(format!("wslpath -w {}: {}", p.display(), String::from_utf8_lossy(&o.stderr).trim()));+ }+ Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())+}++fn word_results(codes: &[String]) -> Result<Vec<String>, String> {+ if codes.iter().any(|c| c.contains('\n')) {+ return Err("a field code cannot contain a newline".into());+ }+ // A script file, not `-Command -`: PowerShell reads a piped multi-line script line by line and runs nothing.+ let dir = std::env::temp_dir().join(format!("field-oracle-word-{}", std::process::id()));+ std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;+ let ps1 = dir.join("field_oracle.ps1");+ let run = std::fs::write(&ps1, format!("$codeHex = '{}'\n{PS_SCRIPT}", to_hex(&codes.join("\n"))))+ .map_err(|e| format!("{}: {e}", ps1.display()))+ .and_then(|()| run_script(&ps1, codes.len()));+ let _ = std::fs::remove_dir_all(&dir);+ run+}++fn run_script(ps1: &Path, n: usize) -> Result<Vec<String>, String> {+ let mut child = powershell()+ .arg(windows_path(ps1)?)+ .stdin(Stdio::null())+ .stdout(Stdio::piped())+ .stderr(Stdio::piped())+ .spawn()+ .map_err(|e| format!("powershell.exe: {e}"))?;+ // Read both pipes off-thread so a full pipe never stalls Word's script while we poll the clock.+ let (mut out, mut err) = (child.stdout.take(), child.stderr.take());+ let out = std::thread::spawn(move || read_all(out.as_mut()));+ let err = std::thread::spawn(move || read_all(err.as_mut()));+ let start = Instant::now();+ let status = loop {+ if let Some(s) = child.try_wait().map_err(|e| format!("powershell.exe: {e}"))? {+ break s;+ }+ if start.elapsed() > WORD_TIMEOUT {+ // The script's `finally` can't run now; Word is left to the caller, never killed by name.+ let _ = child.kill();+ return Err(format!("Word did not finish within {}s (a Word it started may still be running)", WORD_TIMEOUT.as_secs()));+ }+ std::thread::sleep(Duration::from_millis(100));+ };+ let (out, err) = (out.join().unwrap_or_default(), err.join().unwrap_or_default());+ if !status.success() {+ return Err(format!("Word run failed ({status}): {}", err.trim()));+ }+ parse_word_output(&out, n)+}++fn read_all(r: Option<&mut impl std::io::Read>) -> String {+ let mut s = String::new();+ if let Some(r) = r {+ let mut b = Vec::new();+ let _ = r.read_to_end(&mut b);+ s = String::from_utf8_lossy(&b).into_owned();+ }+ s+}++fn cli_command(cli: Option<&Path>) -> Command {+ match cli {+ Some(p) => Command::new(p),+ None => {+ let mut c = crate::cargo();+ c.args(["run", "-q", "-p", "wordcraft-cli", "--"]);+ c+ }+ }+}++/// WordCraft's results: one paragraph per code, every field updated, saved as text.+fn wordcraft_results(codes: &[String], cli: Option<&Path>) -> Result<Vec<String>, String> {+ let dir = std::env::temp_dir().join(format!("field-oracle-{}", std::process::id()));+ std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;+ let out = dir.join("out.txt");+ let mut commands = Vec::new();+ for c in codes {+ commands.push(serde_json::json!({"command": "insert.field", "params": {"instr": c}}));+ commands.push(serde_json::json!("text.newParagraph"));+ }+ commands.push(serde_json::json!({"command": "references.updateFields", "params": {"all": true}}));+ let script = serde_json::json!({"template": "blank", "commands": commands, "save": [out.to_string_lossy()]});+ let run = (|| {+ let mut child = cli_command(cli)+ .args(["run", "--script", "-"])+ .stdin(Stdio::piped())+ .stdout(Stdio::null())+ .stderr(Stdio::piped())+ .spawn()+ .map_err(|e| format!("wordcraft-cli: {e}"))?;+ child.stdin.take().ok_or("wordcraft-cli: no stdin")?.write_all(script.to_string().as_bytes()).map_err(|e| format!("wordcraft-cli: {e}"))?;+ let o = child.wait_with_output().map_err(|e| format!("wordcraft-cli: {e}"))?;+ if !o.status.success() {+ return Err(format!("wordcraft-cli failed ({}): {}", o.status, String::from_utf8_lossy(&o.stderr).trim()));+ }+ std::fs::read_to_string(&out).map_err(|e| format!("{}: {e}", out.display()))+ })();+ let _ = std::fs::remove_dir_all(&dir);+ split_paragraphs(&run?, codes.len())+}++/// The first `n` lines of the saved text (a trailing line break and the empty last paragraph are not results).+pub fn split_paragraphs(text: &str, n: usize) -> Result<Vec<String>, String> {+ let lines: Vec<&str> = text.lines().collect();+ if lines.len() < n {+ return Err(format!("WordCraft saved {} paragraphs for {n} codes (a result with a line break?)", lines.len()));+ }+ Ok(lines.iter().take(n).map(|l| l.trim_end().to_string()).collect())+}++fn cell(s: &str) -> String {+ format!("`{}`", s.replace('|', "\\|").replace('`', "'"))+}++/// Fields WordCraft fills in at layout time: the text export has no result for them (render to see them).+fn is_layout_field(code: &str) -> bool {+ let name = code.split_whitespace().next().unwrap_or("").to_ascii_uppercase();+ ["PAGE", "NUMPAGES", "SECTIONPAGES", "SECTION"].contains(&name.as_str())+}++/// The comparison table. `word` is `None` when Word was skipped.+pub fn table(codes: &[String], word: Option<&[String]>, wc: &[String]) -> String {+ let mut s = String::from("| Code | Word | WordCraft | |\n|---|---|---|---|\n");+ let (mut same, mut diff) = (0, 0);+ for (i, code) in codes.iter().enumerate() {+ let w = word.and_then(|w| w.get(i));+ let verdict = match w {+ None => "",+ Some(_) if is_layout_field(code) && wc.get(i).is_none_or(String::is_empty) => "n/a (layout field)",+ Some(w) if Some(w) == wc.get(i) => {+ same += 1;+ "match"+ }+ Some(_) => {+ diff += 1;+ "**DIFF**"+ }+ };+ s += &format!("| {} | {} | {} | {verdict} |\n", cell(code), w.map_or(String::new(), |w| cell(w)), cell(wc.get(i).map_or("", String::as_str)));+ }+ if word.is_some() {+ s += &format!("\n{} codes: {same} match, {diff} differ.\n", codes.len());+ }+ s+}++pub fn run(root: &Path, args: &[&str]) -> Result<(), String> {+ let (mut codes, mut out, mut cli, mut no_word) = (Vec::new(), None::<PathBuf>, None::<PathBuf>, false);+ let mut it = args.iter();+ while let Some(a) = it.next() {+ match *a {+ "--code" => codes.push(it.next().ok_or("--code needs a value")?.to_string()),+ "--out" => out = Some(it.next().ok_or("--out needs a value")?.into()),+ "--cli" => cli = Some(it.next().ok_or("--cli needs a value")?.into()),+ "--no-word" => no_word = true,+ f if f.starts_with("--") => return Err(format!("unknown option `{f}`\n\n{USAGE}")),+ f => {+ let p = Path::new(f);+ let p = if p.exists() { p.to_path_buf() } else { root.join(f) };+ codes.extend(parse_codes(&std::fs::read_to_string(&p).map_err(|e| format!("{}: {e}", p.display()))?));+ }+ }+ }+ if codes.is_empty() {+ return Err(format!("no field codes given\n\n{USAGE}"));+ }+ let wc = wordcraft_results(&codes, cli.as_deref())?;+ let word = if no_word { None } else { Some(word_results(&codes)?) };+ let md = table(&codes, word.as_deref(), &wc);+ print!("{md}");+ if let Some(o) = out {+ std::fs::write(&o, &md).map_err(|e| format!("{}: {e}", o.display()))?;+ }+ Ok(())+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn codes_skip_comments_and_blanks() {+ assert_eq!(parse_codes("# c\n\n= 1+1 \n PAGE \\* roman\n"), ["= 1+1", "PAGE \\* roman"]);+ }++ #[test]+ fn hex_round_trips_unicode() {+ assert_eq!(from_hex(&to_hex("a→b \\* Upper")).as_deref(), Some("a→b \\* Upper"));+ assert_eq!(from_hex("zz"), None);+ assert_eq!(from_hex("abc"), None);+ }++ #[test]+ fn word_output_maps_by_index_and_rejects_gaps() {+ let out = format!("1\t{}\n0\t\n", to_hex("six"));+ assert_eq!(parse_word_output(&out, 2).unwrap(), ["", "six"]);+ assert!(parse_word_output("0\t\n", 2).is_err());+ assert!(parse_word_output("5\t\n", 2).is_err());+ }++ #[test]+ fn table_marks_differences_and_escapes_pipes() {+ let codes = vec!["a".to_string(), "b|c".to_string()];+ let t = table(&codes, Some(&["1".into(), "2".into()]), &["1".into(), "3".into()]);+ let layout = table(&["PAGE \\* roman".to_string()], Some(&["i".into()]), &["".into()]);+ assert!(layout.contains("n/a (layout field)") && layout.contains("0 match, 0 differ"));+ assert!(t.contains("| `a` | `1` | `1` | match |"));+ assert!(t.contains("| `b\\|c` | `2` | `3` | **DIFF** |"));+ assert!(t.contains("2 codes: 1 match, 1 differ."));+ }++ #[test]+ fn paragraphs_drop_the_trailing_empty_one() {+ assert_eq!(split_paragraphs("x\ny \n\n", 2).unwrap(), ["x", "y"]);+ assert!(split_paragraphs("x\n", 2).is_err());+ }+}@@ -4,6 +4,7 @@ //! invoked through `std::process::Command`. mod assets;+mod field_oracle; mod ico; mod layers; mod parity;@@ -34,6 +35,9 @@ commands: visual-diff <ref.pdf> <test.pdf> <outdir> [--private] [--dpi N] compare two PDFs of a document page by page (SSIM, line breaks, offsets); needs poppler (pdftoppm, pdftotext)+ field-oracle <codes.txt | --code CODE>... [--out table.md] [--cli wordcraft-cli] [--no-word]+ run field codes in real Word (COM) and in wordcraft-cli, print a comparison table+ (xtask/field-oracle/scout-1.txt is the first corpus; Word and PowerShell needed) ico <out.ico> <in.png>... pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh) ";@@ -51,6 +55,7 @@ fn main() -> ExitCode { Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), Some("parity") => parity::run(&root()),+ Some("field-oracle") => field_oracle::run(&root(), &rest), Some("ico") => ico::run(&rest), Some("visual-diff") => visual_diff::run(&rest), Some("version") => version::run(&root(), &rest),