Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions internal/tex/matrix_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,13 @@ func (p *parser) matrixCommand(name string) ([]*mml.Node, error) {
}
spacing.apply(table, initial)
table = finishArrayRules(table)
// ArrayItem.createMml uses ParseUtil.fenced, whose open/close properties
// classify the row as INNER and preserve spacing from its predecessor.
if name == "pmatrix" {
return []*mml.Node{p.fenced("(", table, ")", true)}, nil
return []*mml.Node{p.leftRightFenced("(", table, ")", true)}, nil
}
if name == "cases" {
return []*mml.Node{p.fenced("{", table, "", true)}, nil
return []*mml.Node{p.leftRightFenced("{", table, "", true)}, nil
}
return []*mml.Node{table}, nil
}
Expand Down
79 changes: 79 additions & 0 deletions matrix_fence_spacing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: Apache-2.0
package mathjax_test

import (
"compress/gzip"
"encoding/json"
"os"
"strings"
"testing"

mathjax "github.com/d2lang/mathjax-go"
)

func TestLegacyMatrixFenceSpacingOriginalReferences(t *testing.T) {
file, err := os.Open("testdata/matrix_fence_spacing_mathjax_3_2_2.json.gz")
if err != nil {
t.Fatal(err)
}
defer file.Close()
reader, err := gzip.NewReader(file)
if err != nil {
t.Fatal(err)
}
defer reader.Close()
var fixture struct {
MathjaxGitCommit string
FreshOriginalPerCase bool
Counts struct {
Total, FullSVGAssertions, Valid, Diagnostics, QualifiedDiagnostics, OriginalRuntimeErrors int
}
Cases []struct {
Name, TeX, SVG string
Display, OriginalValid bool
}
QualifiedDiagnostics []struct {
Original struct{ SVG string }
}
OriginalRuntimeErrors []json.RawMessage
}
if err := json.NewDecoder(reader).Decode(&fixture); err != nil {
t.Fatal(err)
}
if fixture.MathjaxGitCommit != "ad8f5c21cb810236551da8c6512ba733e67357ee" || !fixture.FreshOriginalPerCase ||
fixture.Counts.Total != 1236 || fixture.Counts.FullSVGAssertions != 1220 || fixture.Counts.Valid != 1156 ||
fixture.Counts.Diagnostics != 64 || fixture.Counts.QualifiedDiagnostics != 16 || fixture.Counts.OriginalRuntimeErrors != 0 ||
len(fixture.Cases) != 1220 || len(fixture.QualifiedDiagnostics) != 16 || len(fixture.OriginalRuntimeErrors) != 0 {
t.Fatal("unbound legacy Matrix fence references")
}
for _, c := range fixture.QualifiedDiagnostics {
if !strings.Contains(c.Original.SVG, `data-mml-node="merror"`) || !strings.Contains(c.Original.SVG, `data-mjx-error="Undefined control sequence \boldsymbol"`) {
t.Fatal("qualified original is not the retained unsupported-command diagnostic")
}
}
seen := map[string]bool{}
valid := 0
for _, c := range fixture.Cases {
if c.Name == "" || c.SVG == "" || seen[c.Name] || c.OriginalValid == strings.Contains(c.SVG, `data-mml-node="merror"`) {
t.Fatal("invalid original reference", c.Name)
}
seen[c.Name] = true
if c.OriginalValid {
valid++
}
t.Run(c.Name, func(t *testing.T) {
options := mathjax.DefaultOptions()
options.Display = c.Display
got, err := mathjax.RenderWithOptions(c.TeX, options)
if err != nil {
t.Fatal(err)
}
if got != c.SVG {
t.Fatalf("complete original SVG differs\ngot: %s\nwant: %s", got, c.SVG)
}
})
}
if valid != fixture.Counts.Valid {
t.Fatalf("valid originals: got %d, want %d", valid, fixture.Counts.Valid)
}
}
57 changes: 57 additions & 0 deletions testdata/generate_matrix_fence_spacing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# SPDX-License-Identifier: Apache-2.0
# Regenerate only from D2's frozen original. Usage:
# python3 testdata/generate_matrix_fence_spacing.py PINNED_ASSETS --node NODE
import argparse,gzip,hashlib,json,pathlib,subprocess
HERE=pathlib.Path(__file__).resolve().parent
p=argparse.ArgumentParser()
p.add_argument('assets',type=pathlib.Path)
p.add_argument('--node',default='node')
a=p.parse_args()
hashes={'polyfills.js':'7fe1d048c78b0e09854c1259f7413868a51cc8f0c822489eb1ac36ae6c85ce01','mathjax.js':'cbbc1051a1f8abb1a181b6aa0fe927c020e3631ca630d19f52d9abb65b5ee869','setup.js':'a52cb0bbabfbd7796b9fedd793b9386c474e3123e788ee151cbe47ca1fa6e881'}
for name,want in hashes.items():
if hashlib.sha256((a.assets/name).read_bytes()).hexdigest()!=want:raise ValueError('unverified '+name)
cases=[]; seen=set()
def add(group,name,tex):
for display in [False,True]:
key=(tex,display)
if key in seen:continue
seen.add(key);cases.append(dict(group=group,name=f'{group}-{name}-'+('display' if display else 'inline'),tex=tex,display=display))
commands=['matrix','array','pmatrix','cases','eqalign','displaylines','eqalignno','leqalignno']
def matrix(name,body=None):
if body is None:body=r'x&if x>0\cr 0&otherwise' if name=='cases' else r'a&b\cr c&d'
return '\\'+name+'{'+body+'}'
neighbors={'text':r'\text{Matrix: }','textplain':r'\text{label}','ord':'x','number':'2','bin':'+','rel':'=','op':r'\sum','function':r'\sin','open':'(','close':')','punct':',','inner':r'\mathinner{z}','forced-ord':r'\mathord{z}','forced-op':r'\mathop{z}','forced-bin':r'\mathbin{z}','forced-rel':r'\mathrel{z}','forced-open':r'\mathopen{(}','forced-close':r'\mathclose{)}','forced-punct':r'\mathpunct{,}','space':r'\quad','fraction':r'\frac{p}{q}'}
for name in commands:
m=matrix(name)
add('bare',name,m)
for label,n in neighbors.items():
add('preceding',name+'-'+label,n+m)
add('following',name+'-'+label,m+n)
for label,left,right in [('text',r'\text{Matrix: }',r'\text{ done}'),('ord','x','y'),('bin','x+','+y'),('rel','x=','=y'),('op',r'\sum',r'\sum'),('function',r'\sin',r'\cos'),('inner',r'\mathinner{z}',r'\mathinner{w}')]:add('surrounding',name+'-'+label,left+m+right)
for label,tex in [('mathsf',r'\mathsf{x'+m+'y}'),('mathbf',r'\mathbf{x'+m+'y}'),('mathit',r'\mathit{x'+m+'y}'),('boldsymbol',r'\boldsymbol{x'+m+'y}'),('sup',r'X^{x'+m+'y}'),('sub',r'X_{x'+m+'y}'),('denominator',r'\frac{z}{x'+m+'y}'),('root',r'\sqrt{x'+m+'y}'),('postscript',r'x'+m+r'_i^jy'),('braced',r'x{'+m+'}y'),('scriptstyle',r'\scriptstyle x'+m+'y'),('textstyle',r'\textstyle x'+m+'y')]:add('scope',name+'-'+label,tex)
for label,body in [('single','a'),('empty',''),('fraction',r'\frac{a}{b}&\sqrt{c}\cr g&h'),('font',r'\mathbf{a}&\mathsf{b}\cr c&d')]:
if name=='cases' and label in ['fraction','font']:continue
add('body',name+'-'+label,r'x'+matrix(name,body)+'y')
for env in ['matrix','pmatrix','bmatrix','Bmatrix','vmatrix','Vmatrix','cases','smallmatrix','array','aligned']:
m='\\begin{'+env+'}'+('{cc}' if env=='array' else '')+r'a&b\\c&d'+'\\end{'+env+'}'
for label,tex in [('bare',m),('text',r'\text{Matrix: }'+m),('surrounding','x'+m+'y'),('operator',r'\sin'+m+r'\cos'),('font',r'\mathbf{x'+m+'y}'),('script',r'X^{x'+m+'y}')]:add('environment',env+'-'+label,tex)
for name in commands:
for label,tex in [('missing','\\'+name),('close','\\'+name+'{a'),('unbraced-script','X^\\'+name+'{a}'),('extra-close','\\'+name+'{a}}')]:add('diagnostic',name+'-'+label,tex)

records=[];qualified=[];runtime=[]
for start in range(0,len(cases),24):
batch=cases[start:start+24]
inputs=''.join(json.dumps({'tex':c['tex'],'options':{'Display':c['display'],'Em':16,'Ex':8}})+'\n' for c in batch)
run=subprocess.run([a.node,'--jitless',str(HERE/'differential/oracle.mjs'),'--asset-dir',str(a.assets)],input=inputs,text=True,capture_output=True,check=True)
outputs=[json.loads(line) for line in run.stdout.rstrip('\n').split('\n')]
if len(outputs)!=len(batch):raise ValueError('incomplete original output')
for c,original in zip(batch,outputs):
if original.get('error'):runtime.append({**c,'original':original});continue
svg=original['svg'];valid='data-mml-node="merror"' not in svg
if r'data-mjx-error="Undefined control sequence \boldsymbol"' in svg:
if valid:raise ValueError('qualified diagnostic is valid')
qualified.append({**c,'original':original,'qualification':'Frozen D2 original does not register \\boldsymbol, while Go accepts it; excluded from exact SVG assertions and parity credit.'})
else:records.append({**c,'originalValid':valid,'svg':svg})
print('fresh original',min(start+24,len(cases)),'/',len(cases),flush=True)
fixture={'mathjaxGitCommit':'ad8f5c21cb810236551da8c6512ba733e67357ee','oracleAssetsSHA256':hashes,'freshOriginalPerCase':True,'counts':{'total':len(cases),'fullSVGAssertions':len(records),'valid':sum(c['originalValid'] for c in records),'diagnostics':sum(not c['originalValid'] for c in records),'qualifiedDiagnostics':len(qualified),'originalRuntimeErrors':len(runtime)},'cases':records,'qualifiedDiagnostics':qualified,'originalRuntimeErrors':runtime}
(HERE/'matrix_fence_spacing_mathjax_3_2_2.json.gz').write_bytes(gzip.compress((json.dumps(fixture,ensure_ascii=False,indent=2)+'\n').encode(),mtime=0))
17 changes: 17 additions & 0 deletions testdata/matrix_fence_spacing_README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Legacy Matrix fence spacing

MathJax 3.2.2's `Base.Matrix` stores the `pmatrix` and `cases` fence pair on its ArrayItem. `ArrayItem.createMml` calls `ParseUtil.fenced`, which gives the enclosing mrow `open`, `close`, and `texClass: INNER` properties. Those properties preserve thin TeX spacing from preceding text, ordinary atoms, and operators. The Go legacy commands used a plain delimiter row and lost that spacing; the existing `leftRightFenced` constructor restores the original row contract. Matrix environments already use that contract.

`matrix_fence_spacing_mathjax_3_2_2.json.gz` preserves 1,236 fresh original requests: 1,156 valid SVGs, 64 exact original diagnostics, and 16 separately retained original-unsupported `boldsymbol` diagnostics. The last group receives no exact-SVG assertion or parity credit because Go accepts that extra command. No original runtime failures occurred. All 1,220 strict SVG assertions match the fix; against canonical baseline `fc40841854f0b3cdad53ee346a71646c478b7ba8`, 98 valid outputs are fixed and 1,122 assertions remain unchanged. There are no SVG regressions.

The corpus covers all eight legacy Matrix commands (`matrix`, `array`, `pmatrix`, `cases`, `eqalign`, `displaylines`, `eqalignno`, `leqalignno`), preceding and following atom classes, surrounding text and operators, font and script scopes, nested fractions and roots, matrix cell bodies, ten matrix/array environment controls, and original diagnostic boundaries. Inline and display mode are tested for every input. `matrix_fence_spacing_observations.json` independently records the frozen active registrations, original methods, and original fenced-row properties after rendering.

Regenerate only with the hash-verified, unmodified D2 MathJax 3.2.2 assets; each request gets a fresh runtime:

```sh
python3 testdata/generate_matrix_fence_spacing.py /path/to/pinned/assets --node /path/to/node
node --jitless testdata/observe_matrix_fence_spacing.cjs /path/to/pinned/assets
go test ./... -run TestLegacyMatrixFenceSpacingOriginalReferences -count=1
```

The finite corpus establishes the measured fence spacing contract; it does not establish universal MathJax parity.
Binary file not shown.
Loading
Loading