XLSX → PDF: page setup the author controls, per document and per sheet
Status: design, ready to build · Date: 2026-08-18
Scope chosen by the user: option B — fix the conversion AND expose per-document controls,
with per-SHEET control as well.
The complaint, and what is actually wrong
"The PDF generate thing is too clunky for PDFs, we can't set max page for sheet width."
go/internal/platform/render/office.go → GotenbergOffice.ToPDF sends Gotenberg the file and
pdfa, and nothing else. No orientation, no fit, no paper size. LibreOffice therefore falls
back to portrait A4 honouring whatever print setup the workbook carries — and most workbooks
carry none. A 40-column sheet spills across four page-widths in an order nobody can read.
Measured, on the live demo's own gotenberg (8.33.0)
Two synthetic workbooks, converted through POST /forms/libreoffice/convert:
| Sheet | today (no options) | singlePageSheets=true |
fitToWidth=1 fitToHeight=0 |
|---|---|---|---|
| wide — 40 cols × 30 rows | 4 pages | 1 ✅ | 1 ✅ |
| tall — 12 cols × 2000 rows | 76 pages | 1 ❌ unreadable | 56 ✅ readable |
🔴 Do not reach for Gotenberg's singlePageSheets. It is the obvious-looking answer and it
is a trap: it fits BOTH dimensions, so a 2000-row rekap becomes one crushed page. It is not the
control the user asked for.
fitToWidth=1 + fitToHeight=0 is — "one page wide, as many pages tall as it needs", which
is precisely "max page for sheet width". It lives in the sheet's own pageSetup inside the
xlsx, needs no Gotenberg flag at all, and degrades correctly on both shapes.
The XML that produced the last column, injected into xl/worksheets/sheetN.xml:
<worksheet …>
<sheetPr><pageSetUpPr fitToPage="1"/></sheetPr> <!-- FIRST child of <worksheet> -->
<sheetData>…</sheetData>
<pageSetup orientation="landscape" fitToWidth="1" fitToHeight="0" paperSize="9"/>
</worksheet> <!-- AFTER </sheetData> -->
⚠️ Element order in the OOXML schema is not advisory. sheetPr must be the first child of
<worksheet> and pageSetup must follow </sheetData>; put them elsewhere and LibreOffice
silently ignores the block, which looks exactly like the feature not working. Both were verified
in the measurement above.
🔴 The rule that must not be broken
An explicit pageSetup an author already set is a DECISION, not a gap. Somebody who chose
"2 pages wide, portrait, A3" for their rekap chose it. The injector fills in a default for
sheets that carry no page setup and applies the stored override where one exists — it never
silently overwrites an author's own print settings with ours.
This means three states per sheet, and the UI has to be able to say which:
- Author's own — the workbook already carries
pageSetup; we leave it alone. - Our default — no page setup anywhere; we inject fit-to-width/landscape.
- Overridden here — somebody set it on this document in Obscura; ours wins.
What to build
1. The injector (pure, testable, no I/O)
New package, e.g. go/internal/platform/office/pagesetup. An xlsx is a zip; this is a
deterministic XML edit, not a render — no new service, no LibreOffice round trip.
// Settings is one sheet's page setup. Zero value = "leave whatever the workbook has".
type Settings struct {
FitToWidth int // 1 = one page wide. 0 = don't fit.
FitToHeight int // 0 = as many pages tall as needed.
Orientation string // "" | "portrait" | "landscape"
PaperSize int // OOXML paperSize code; 9 = A4, 8 = A3.
}
// Apply rewrites the workbook's sheets. `perSheet` is keyed by sheet NAME; `def` applies to
// every sheet without an entry. Sheets that already carry a pageSetup are left untouched
// unless perSheet names them explicitly.
func Apply(xlsx []byte, def Settings, perSheet map[string]Settings) ([]byte, error)
- Sheet names come from
xl/workbook.xml(<sheet name="Rekap" sheetId="1" r:id="rId1"/>),
and the r:id maps to the part path throughxl/_rels/workbook.xml.rels. 🔴 Do NOT assume
sheet1.xmlis the first sheet — the ordering is the workbook's, and the file names do not
have to match it. - Preserve every other zip entry byte-for-byte. Rewrite only the sheet parts touched.
.xls(legacy binary) and.odsare NOT zips of this shape: pass them through untouched
rather than corrupting them. Only.xlsxis in scope.
2. Storage — per document, per sheet
Migration (check ls go/migrations | tail first; 00202 is taken). Suggested shape:
CREATE TABLE document_page_setup (
document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
-- '' = the document-wide default; otherwise the SHEET NAME it applies to.
sheet_name text NOT NULL DEFAULT '',
fit_to_width int NOT NULL DEFAULT 1,
fit_to_height int NOT NULL DEFAULT 0,
orientation text NOT NULL DEFAULT 'landscape',
paper_size int NOT NULL DEFAULT 9,
PRIMARY KEY (document_id, sheet_name)
);
Sheet NAME rather than index, because inserting a sheet renumbers indices and would silently
move somebody's setting onto a different sheet.
3. Wire it into the conversion
ToPDF is on the path for preview, official copy, PDF/A and egress. Apply the injector for
spreadsheet sources only; every other format keeps its exact current behaviour.
4. UI
A page-setup panel on a spreadsheet document: the document-wide default plus a per-sheet
override list. State it in the three-state vocabulary above — a sheet showing "author's own"
must not look like a sheet nobody has configured.
Verifying it
stego-style measurement, not eyeballing: convert and count pages.
# on valbox; gotenberg is on the compose network, not published
docker run --rm --network deploy_default -v /tmp/x:/w -w /w --user 0 curlimages/curl:latest \
-s -o out.pdf -F "files=@book.xlsx" http://gotenberg:3000/forms/libreoffice/convert
strings out.pdf | grep -c "/Type */Page[^s]"
⚠️ Two traps that cost time in the measurement session:
- valbox runs zsh, which does NOT word-split unquoted $opts. Passing curl flags through a
shell variable silently sends them as ONE argument, and every variant returns byte-identical
output — which reads as "the flag does nothing". Write the flags literally.
- Gotenberg returns 200 for unknown form fields. A 200 does not mean your parameter was
understood. Compare page counts, never status codes.
Hard constraints (from the repo's own history)
- 🔴 NEVER
go test ./...— the test DSN points at the LIVE demo Postgres. Verify with
cd go && go build ./... && go vet ./..., and drive the real thing for behaviour. - 🔴 NEVER
git add -A— this checkout is shared with other agents; stage explicit paths. - 🔴 NEVER
docker compose down -v— it wiped demo pgdata+miniodata once. - Migrations open with
-- +goose Up. Without it the server does not boot,go buildis green,
and update.sh rolls back reporting the OLD commit — which reads exactly like a stale deploy. - Guards before commit:
npm run perm-guard(now includes openapi-guard) and, for web,
cd web && npm run ui-guards. - Regenerate the client after any openapi.yaml edit:
cd packages/api-client && ../../web/node_modules/.bin/openapi-typescript ../../api/openapi.yaml -o src/schema.ts - ⚠️ The official-copy path is delicate. A previous PDF/A pass re-exported official copies
through LibreOffice DRAW and wrecked margins on production. Render and COMPARE before shipping
anything that touches it.
Deployment
Do not deploy prod. The deployment agent owns releases; demo (x056.obscura.val.id) is the
place to prove it.
Addendum: what changed when the design met the code
Built 2026-08-18 — 044e4d90, fb512085, 642c4012. Deployed to the x056 demo and
measured there. The design above is right about the mechanism and about the doctrine; the
list below is what it did not know.
🔴 1. ToPDF is not the engine on a deployment that licensed office
The biggest gap. The design says "wire it into the conversion" and names ToPDF — but the
demo and production both licence the office module, and there previews and official copies
convert through the OnlyOffice doc-server, not gotenberg. convertViaOnlyOffice hands
the doc-server a content URL and the doc-server re-fetches the STORED bytes. An
in-process rewrite reaches it only by being staged somewhere it can fetch.
Wiring only ToPDF would have shipped a page setup that saves, reports its state correctly,
renders a whole settings panel — and does absolutely nothing on the deployments that
matter. It would have looked like the workbook ignoring the setting.
The fix already existed: merged variables are in exactly this position, and stage through a
transient blob with a variant=merged content token (convertMergedViaOnlyOffice). A
rewritten workbook now takes the same route (convertViaOnlyOfficeWithPageSetup), and a
workbook we did not rewrite keeps the byte-exact URL path untouched.
🔴 2. The conversion cache would have served the old pagination
Not mentioned in the design at all. A converted office version is cached by the source
version's content_hash, so changing the page setup changes the render while the key stays
still — the panel says "1 page wide" and the reader keeps seeing four pages. The key now
carries a |ps:<hash> suffix, the same move mergeKey makes for the same reason. It is
non-empty for every spreadsheet, so renders cached before this feature miss rather than
being served.
That suffix then exposed two pre-existing bugs in the same table, both of which already
affected merged renders:
SweepOrphansdeleted every row whosecontent_hashdid not name a document version.
A suffixed key names none, so the sweep would have thrown away every live spreadsheet
render on each pass. Now matched onsplit_part(content_hash, '|', 1).- The admin per-document purge passed bare version hashes and matched exactly, so it
walked straight past suffixed keys: "purge this document's preview cache" reported rows
removed and left the cached render in place.PurgeByVersionHashnow matches the base.
⚠️ 3. "pageSetup goes after </sheetData>" only holds for the fixture
True of the two-element test workbook, not of a real one. CT_Worksheet is a sequence,
and a real sheet has mergeCells, hyperlinks, printOptions, pageMargins between
sheetData and pageSetup, then headerFooter, rowBreaks, drawing, tableParts,
extLst after it. Inserting immediately after </sheetData> puts it before elements
that must precede it.
The rule that actually holds, and what the injector does: insert immediately before the
first depth-1 element that must FOLLOW pageSetup, else before </worksheet>. Depth matters
— extLst also appears nested inside elements that precede pageSetup, and matching a nested
one inserts into the wrong slot.
Same for the head: <sheetPr> may already exist (Excel writes codeName on it routinely),
and pageSetUpPr is the last of its three children. So: extend the existing element,
never replace it.
⚠️ 4. Per-sheet overrides alone are unusable on a real workbook
The design's model gives the document-wide default gap-filling power only, and per-sheet rows
as the only way past an author's decision. On a thirty-sheet rekap that means thirty saves.
Added force_all on the document-wide row: apply the default to every sheet, including
the ones whose author set them up. Off by default, refused on a per-sheet save, and the panel
still reports what was displaced (forced: true plus the author's own settings) — so the
doctrine holds. Overwriting an author's decision is possible, it is just never silent.
⚠️ 5. Which version the panel inspects
Unspecified, and the obvious answer is wrong: making an official copy appends a PDF
version, so a panel reading the current version would vanish from exactly the documents
somebody has been working on. It reads the newest version whose MIME is xlsx.
⚠️ 6. fitToWidth defaults to 1 in the schema and means nothing on its own
Reading a sheet's own pageSetup naively reports "1 page wide" for a sheet that is not
fitting at all, because both fit attributes default to 1 and only take effect with
<pageSetUpPr fitToPage="1"/>. Inspect reports 0/0 unless the switch is on — what the
sheet actually prints like, not what its attributes say.
Measured on the demo, through OnlyOffice (not gotenberg)
Same workbooks, driven through the live app rather than a curl to the sidecar. The page
counts come from the secure-preview session, which is the app's own pagination.
| sheet | setting | pages | page size |
|---|---|---|---|
| 40 × 30 | opted out (all zeros) | 4 | 595 × 842 — A4 portrait |
| 40 × 30 | built-in default | 1 | 842 × 595 — A4 landscape |
| 40 × 30 | 2 wide, portrait F4 | 2 | 612 × 936 — F4 |
| 40 × 30 | official copy, same setting | 2 | 612 × 936 |
| 12 × 2000 | built-in default | 59 | A4 landscape |
| authored (its own pageSetup) | built-in default | 1 | 842 × 595 — the author's |
| authored | force_all, portrait A3 |
1 | 842 × 1191 — displaced |
The engines differ slightly (gotenberg said 56 for the tall sheet, OnlyOffice says 59). The
shape is what matters: not 1.
Regression check on the delicate path: ZZ TOC PDFA probe (docx) re-rendered its official
copy at 7 pages, 612 × 792, identical to the copy rendered before this change. Non-xlsx
sources cannot take a different path — applyPageSetup returns unchanged on the MIME check
and the branch condition is merged || pageSetupApplied, which for a docx is exactly
merged as before.
Paper sizes
The design named A4 and A3. The set is closed on purpose — LibreOffice reverts an unknown
code to A4 without saying so — and it carries F4/Folio (code 14), because Indonesian
offices print on it as a matter of course.
One trap of our own
Translations = typeof en, so a key in en.ts with no twin in id.ts fails the whole
locale file under tsc. i18next selects only _other for Indonesian, which makes omitting
_one look correct; it is not, and the web image refuses to build. The deploy gate caught it
and rolled back cleanly — but that is the wrong thing to be caught by. Run npx tsc --noEmit
as its own command after touching a locale.