SpreadsheetFile.render() multiplies imported rich-text font sizes by 100 before XLSX export
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.4k
- PR merge metrics
- PR metrics pending
Description
What issue are you seeing?
SpreadsheetFile.render() mutates explicit rich-text run font sizes in an imported XLSX. If the same workbook instance is exported after rendering, SpreadsheetML font sizes are multiplied by 100.
In the synthetic repro below, the source rich-text runs use <sz val="11"/>. A plain import/export round trip preserves 11, but calling render() before export writes <x:sz val="1100"/> for both runs.
This is a silent file-corruption issue: the internal preview can still look normal, while Excel-compatible viewers such as WPS may show blank or extremely large text. The observed 1100 pt value also exceeds SpreadsheetML's valid Office font-size range.
What steps can reproduce the bug?
Environment:
- Codex App:
26.831.21537(build7579) - Codex CLI:
0.152.1 - Spreadsheets plugin:
26.826.12353 @oai/artifact-tool:2.8.52@oai/walnut:0.1.245- Bundled Node.js:
24.19.0 - Platform:
macOS 26.4 arm64
- Decode the fully synthetic workbook included below as
minimal_source.xlsx. - Run:
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const source = "minimal_source.xlsx";
const control = await SpreadsheetFile.importXlsx(await FileBlob.load(source));
const controlOutput = await SpreadsheetFile.exportXlsx(control);
await controlOutput.save("roundtrip_without_render.xlsx");
const rendered = await SpreadsheetFile.importXlsx(await FileBlob.load(source));
await rendered.render({
sheetName: "Repro",
range: "A1:D4",
scale: 1,
format: "png",
});
const renderedOutput = await SpreadsheetFile.exportXlsx(rendered);
await renderedOutput.save("roundtrip_after_render.xlsx");
- Inspect the rich-text sizes:
for f in minimal_source.xlsx roundtrip_without_render.xlsx roundtrip_after_render.xlsx; do
printf '%s: ' "$f"
unzip -p "$f" xl/sharedStrings.xml | grep -oE 'sz val="[0-9.]+"' | tr '\n' ' '
printf '\n'
done
Actual result:
minimal_source.xlsx: sz val="11" sz val="11"
roundtrip_without_render.xlsx: sz val="11" sz val="11"
roundtrip_after_render.xlsx: sz val="1100" sz val="1100"
The source contains one merged and wrapped rich-text cell (B2:C2) with an explicit row height. This report does not claim that merge/wrap is required; it records the minimized structure that reproduces the failure.
What is the expected behavior?
Rendering must not mutate workbook data used by a later export. Both round trips should preserve the two explicit run sizes as 11 pt.
If rendering requires an internal hundredths-of-a-point representation, that value should be converted back to SpreadsheetML point units before export.
Additional information
- The workbook is fully synthetic and contains no user, company, project, or repository data.
- It contains one sheet, one merged range, one shared string, no formulas, no macros, and no external links.
- All three XLSX ZIP packages pass
unzip -t. - SHA-256:
- source:
08ce01420be55289bba73b5ae0ebe110e1b91165e401d6ca3bad1f06859f541d - without render:
44430e75ef5539223d248f27702473f412d64f9ab32bfe52deb75a0cf34a604e - after render:
92cd5eeeb5f6dff4ff65ee83dd06940303b2576d91ebee92dfb35c2e94084e3c
- source:
- A likely explanation is a points-versus-hundredths-of-a-point unit leak from the render path. This is an inference from the exact
11 -> 1100boundary, not a source-code-confirmed root cause. - Current safe workaround: export the edited workbook before rendering, then re-import the exported XLSX into a disposable QA instance for rendering and never export that QA instance.
Sanitized minimal XLSX (base64)
Decode:
python3 -c 'import base64,pathlib; pathlib.Path("minimal_source.xlsx").write_bytes(base64.b64decode(pathlib.Path("repro.b64").read_text()))'
Save this block as repro.b64:
UEsDBBQAAAAIALU5I11LRa7cGwEAAEMDAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbK1TS08CMRC+8yuaXgkteDDG7MLBx1FNxB8wtrNsQ1/pFGT/vd3FV4wgB06T5ntmMq0WO2fZFhOZ4Gs+E1PO0KugjV/V/GV5P7nijDJ4DTZ4rHmHxBfzUbXsIhIrYk81b3OO11KSatEBiRDRF6QJyUEuz7SSEdQaVigvptNLqYLP6PMk9x58PmKsusUGNjazu11B9l0SWuLsZs/t42oOMVqjIBdcbr3+FTT5CBFFOXCoNZHGhcDloZAePJzxLX0sK0pGI3uClB/AFaLcWfkW0vo1hLU47vNH19A0RqEOauOKRFBMCJpaxOysGKZwYPz4pAoDn+QwZmfu8uX/fxXKnUU69y4G0xPCW0ion3Mql3v2Dj+9P6tUcvgD83dQSwMEFAAAAAgAtTkjXV2H9C60AAAALAEAAAsAAABfcmVscy8ucmVsc43Pvw6CMBAG8J2naG6XgoMxhsJiTFgNPkAtx59Qek1bFd7ejmIcHC933+/yFdUya/ZE50cyAvI0A4ZGUTuaXsCtueyOwHyQppWaDApY0UNVJsUVtQwx44fRehYR4wUMIdgT514NOEufkkUTNx25WYY4up5bqSbZI99n2YG7TwPKhLENy+pWgKvbHFizWvyHp64bFZ5JPWY04ceXr4soS9djELBo/iI33YmmNKLAY0e+KVm+AVBLAwQUAAAACAC1OSNd1kQ38sIAAAAfAQAADwAAAHhsL3dvcmtib29rLnhtbI2Pu27DMAxFd3+FwD2R06EoDFlZggJZi/YDVIuOhVikQCp9/H3Vut078YV7eK87fuTVvKFoYhrhsO/BIE0cE11GeHl+3D2A0RoohpUJR/hEhaPv3DvL9ZX5apqedISl1jJYq9OCOeieC1K7zCw51DbKxWoRDFEXxJpXe9f39zaHRLARBvkPg+c5TXji6ZaR6gYRXENt7nVJRcF3xrifJ+q3aijkZvwJi3DL8r06xxYVjAypNXKOB7De2V9V5+xfOP8FUEsDBBQAAAAIALU5I12WmsWG3AAAAD8CAAAaAAAAeGwvX3JlbHMvd29ya2Jvb2sueG1sLnJlbHOtkc1qwzAMgO99CqP74qSDMUacXsag1617AGErcWhiG0v7ydvPbKyksLEdehKS0KcPqd29z5N6pcxjDAaaqgZFwUY3hsHA8+Hh6hYUCwaHUwxkYCGGXbdpH2lCKTPsx8SqQAIb8CLpTmu2nmbkKiYKpdPHPKOUNA86oT3iQHpb1zc6rxnQbZQ6w6q9M5D3rgF1WBL9Bx/7frR0H+3LTEF+2KLfYj6yJ5ICxTyQGDiVWH+GpipU0L/6bC/pw7JM5aQnma/8D4Prixp4zOSeJJeXr0XW5W+fVp/9vfsAUEsDBBQAAAAIALU5I106cJNyiwEAAHQDAAANAAAAeGwvc3R5bGVzLnhtbKWTwYrcMAyG7/sUxveuZwItpSRZ9jLQSy87hV61iZMYbNnYnm2yT18pnkwzUCilJ0u/o8/+ZaV+mp0Vbzom47GRx8eDFBo73xscG/n9fPrwWYqUAXuwHnUjF53kU/tQp7xY/TJpnQURMDVyyjl8USp1k3aQHn3QSDuDjw4ypXFUKUQNfeIiZ1V1OHxSDgzK9kGIevCYk+j8BTPdQ7ar0NbpXbyBJeUoVVsjOF3y52jAsjSAM3YpYsWCKoXrkgraWHtDV4wmoa0D5KwjnigR1/i8BPKI5LSQ1u/+8vUYYTlWH3cF61KOfvWxp+bufRWpra0eMtVEM068Zh8Ub+bsHQW9gdEjWKZuFdegkDtt7Qs/wo/hDj8PAi/u5PLXvpH0mtyGLaRrXcNCKgkfsafd8DtytT4S6f+OF/OwnfPfCAEh2OXZmhGd3hzDlorJR/NONJ4Fbq/kyc6m45z6K8XPCOGs57WQbc/DalZd3d439q6tN1XwDDbyG8+13d3s9WJsNviHlhK2Vr//l/YXUEsDBBQAAAAIALU5I13FBbS47AAAAJABAAAUAAAAeGwvc2hhcmVkU3RyaW5ncy54bWylUE1PwzAMve9XWLmzdBwQmtJMCKlnpMEPyFLTRkqcEqcV8OtxhpAQV3Jw/Pz1/GxO7ynChoVDpl4d9p0CJJ/HQFOvXp6Hm3sFXB2NLmbCXn0gq5PdGeYK0krcq7nW5ag1+xmT431ekCTzmktyVWCZNC8F3cgzYk1R33bdnU4ukAKfV6pCq2Cl8Lbi4w+2OwDDoX3iFGvKk5iLtoY/YXNRag5KkM8xFyjTpVfD0F1fC5chU/2ueyjBxRbT1xHVnl1aIsIsG4lIo2tL/SH6L0k7zZEX5+Viop2xbCiazugzjRAD4S9eo5tQsVztF1BLAwQUAAAACAC1OSNdMoYCZikBAAD2AQAAGAAAAHhsL3dvcmtzaGVldHMvc2hlZXQxLnhtbFVRS0/DMAy+71dYvrOU8hCa0k7a0AQ3hHicQ+u10ZpkSrJ1/HucaHRwSBTb38OO5fJkBjiSD9rZCq/nBQLZxrXadhW+v22uHhBCVLZVg7NU4TcFXNYzOTq/Cz1RBBawocI+xv1CiND0ZFSYuz1ZrmydNypy6DsR9p5Um0lmEGVR3AujtMV6BiBbbcimJsDTtsJVuViXKHIpMz40jaG+vCE18OXcLgXPbYUFo6X4g52om9zDi4eWtuowxFc3PpHu+sjz3p09GjewOt9gNP9DiWDUqcIbhFG3sc9IaA4hOvN5TmS/zJucHlVUtfRuBJ81ksXtRJxMkZ0SYsWQkGLgbODssS6kOCZZPizzO1DWTS6GfEdrGoYAjTvYs9iU/f93UlzgvDExraz+AVBLAQIUAxQAAAAIALU5I11LRa7cGwEAAEMDAAATAAAAAAAAAAAAAACAAQAAAABbQ29udGVudF9UeXBlc10ueG1sUEsBAhQDFAAAAAgAtTkjXV2H9C60AAAALAEAAAsAAAAAAAAAAAAAAIABTAEAAF9yZWxzLy5yZWxzUEsBAhQDFAAAAAgAtTkjXdZEN/LCAAAAHwEAAA8AAAAAAAAAAAAAAIABKQIAAHhsL3dvcmtib29rLnhtbFBLAQIUAxQAAAAIALU5I12WmsWG3AAAAD8CAAAaAAAAAAAAAAAAAACAARgDAAB4bC9fcmVscy93b3JrYm9vay54bWwucmVsc1BLAQIUAxQAAAAIALU5I106cJNyiwEAAHQDAAANAAAAAAAAAAAAAACAASwEAAB4bC9zdHlsZXMueG1sUEsBAhQDFAAAAAgAtTkjXcUFtLjsAAAAkAEAABQAAAAAAAAAAAAAAIAB4gUAAHhsL3NoYXJlZFN0cmluZ3MueG1sUEsBAhQDFAAAAAgAtTkjXTKGAmYpAQAA9gEAABgAAAAAAAAAAAAAAIABAAcAAHhsL3dvcmtzaGVldHMvc2hlZXQxLnhtbFBLBQYAAAAABwAHAMIBAABfCAAAAAA=
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by running the supplied JavaScript reproduction with minimal_source.xlsx, using SpreadsheetFile.importXlsx(), render(), and exportXlsx(). Compare xl/sharedStrings.xml in the three output packages and trace the render-to-export path responsible for converting the explicit 11 pt rich-text sizes. Done means rendering no longer changes the exported sizes, which remain 11 pt and pass XLSX integrity checks.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js
- Domain
- tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100