collective

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

diff --git a/xtask/field-oracle/scout-1.txt b/xtask/field-oracle/scout-1.txtnew file mode 100644--- /dev/null+++ b/xtask/field-oracle/scout-1.txt@@ -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) \* CardTextdiff --git a/xtask/src/field_oracle.ps1 b/xtask/src/field_oracle.ps1new file mode 100644--- /dev/null+++ b/xtask/src/field_oracle.ps1@@ -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+}diff --git a/xtask/src/field_oracle.rs b/xtask/src/field_oracle.rsnew file mode 100644--- /dev/null+++ b/xtask/src/field_oracle.rs@@ -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());+    }+}diff --git a/xtask/src/main.rs b/xtask/src/main.rs--- a/xtask/src/main.rs+++ b/xtask/src/main.rs@@ -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),