diff --git a/CHANGELOG.md b/CHANGELOG.md index 062191b92..84b1110dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **`CobaltRota` stands where the page sets it in Word and LibreOffice.** Its header opens and + closes with a row that is only a rule, its thickness an empty cell's line. The page draws the + rule's borders across that line; Word and LibreOffice keep them outside it and grew each row + by the border's 0.9pt, so the sheet under them stood 1.8pt low. A table cell holding nothing + but an empty line now has that line cut to the room its row leaves it; a line with letters + keeps its height. Word's median drift falls + from 2.48pt to 0.74, LibreOffice's from 2.39 to 0.60, and no line is now more than 2pt off + in either, where 119 and 124 were. + - **`OrangeOps` stands on one page in Word.** It ran to two, and 6 of its 108 lines were found in Word's rendering; now 99 are, at a median drift of 0.42pt. - Its headings are Oswald SemiBold, a face whose file does not say it is bold. Word set diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index fa9be69a6..de50fd3cc 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -69,7 +69,7 @@ creation date is real metadata. |---|---| | Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it | | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs | -| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one | +| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one | | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | | Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below | | Images | Embedded pictures at the node's declared size | diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv index d483ba34c..aa874c87f 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv @@ -3999,154 +3999,154 @@ receipt-modern 0:94d25770d489 0.13 help@northwind-pay.example receipt-modern 0:d613d9566d09 0.13 northwindpayisafictionalinstitut receipt-modern 0:403a860bfc00 0.13 invented. receipt-modern 0:7b14b8d2c5c4 -0.04 page1of1 -rota-cobalt 0:33888e306262 1.18 quay -rota-cobalt 0:f5f481423c57 1.03 quaysidebar -rota-cobalt 0:d457d4238a16 1.43 monday31st -rota-cobalt 0:916a90a10237 1.43 tuesday1st -rota-cobalt 0:c602d130bfde 1.43 wednesday2nd -rota-cobalt 0:74cf6babb6f6 1.43 thursday3rd -rota-cobalt 0:ae6aa379ad0c 1.43 friday4th -rota-cobalt 0:a6643f169bfd 1.43 saturday5th -rota-cobalt 0:58752a3c0543 1.43 sunday6th -rota-cobalt 0:c4aee96677b0 2.02 /cleancrushedice -rota-cobalt 0:2895e01ceae0 1.26 machine&area -rota-cobalt 0:f4e7df025c89 2.22 pianist18:30/clean -rota-cobalt 0:d3acaa767810 1.71 crushedicemachine& -rota-cobalt 0:4a91ee5f0106 1.20 area -rota-cobalt 0:b913ee5e077a 2.02 privatedinner44pax/ -rota-cobalt 0:d89987d22df9 1.26 soulgf/pianist18:30 -rota-cobalt 0:fb980282ae0a 2.02 pianist18:30/partner -rota-cobalt 0:3c1fc038017e 1.26 meeting3:30pm -rota-cobalt 0:cc96b22615e5 1.42 pianist19:00 -rota-cobalt 0:bc14a4ce6407 2.17 statuslegend -rota-cobalt 0:1f58b9145b24 2.23 request -rota-cobalt 0:b4dc66dde806 2.23 off -rota-cobalt 0:b4dc66dde806#1 2.24 off -rota-cobalt 0:b4dc66dde806#2 2.24 off -rota-cobalt 0:b4dc66dde806#3 2.35 off -rota-cobalt 0:b4dc66dde806#4 2.35 off -rota-cobalt 0:b4dc66dde806#5 2.40 off -rota-cobalt 0:b4dc66dde806#6 2.40 off -rota-cobalt 0:b4dc66dde806#7 2.40 off -rota-cobalt 0:b4dc66dde806#8 2.60 off -rota-cobalt 0:b4dc66dde806#9 2.60 off -rota-cobalt 0:b4dc66dde806#10 2.81 off -rota-cobalt 0:b4dc66dde806#11 2.81 off -rota-cobalt 0:b4dc66dde806#12 2.92 off -rota-cobalt 0:b4dc66dde806#13 3.03 off -rota-cobalt 0:b4dc66dde806#14 3.03 off -rota-cobalt 0:b4dc66dde806#15 3.13 off -rota-cobalt 0:b4dc66dde806#16 3.13 off -rota-cobalt 0:b4dc66dde806#17 3.13 off -rota-cobalt 0:b4dc66dde806#18 3.08 off -rota-cobalt 0:b4dc66dde806#19 3.09 off -rota-cobalt 0:b4dc66dde806#20 3.09 off -rota-cobalt 0:cdc2def9aec4 2.23 hol -rota-cobalt 0:cdc2def9aec4#1 2.91 hol -rota-cobalt 0:cdc2def9aec4#2 2.91 hol -rota-cobalt 0:cdc2def9aec4#3 2.91 hol -rota-cobalt 0:cdc2def9aec4#4 2.91 hol -rota-cobalt 0:cdc2def9aec4#5 2.91 hol -rota-cobalt 0:cdc2def9aec4#6 2.91 hol -rota-cobalt 0:cdc2def9aec4#7 2.92 hol -rota-cobalt 0:cdc2def9aec4#8 3.03 hol -rota-cobalt 0:cdc2def9aec4#9 3.08 hol -rota-cobalt 0:cdc2def9aec4#10 3.08 hol -rota-cobalt 0:cdc2def9aec4#11 3.08 hol -rota-cobalt 0:cdc2def9aec4#12 3.09 hol -rota-cobalt 0:cdc2def9aec4#13 3.30 hol -rota-cobalt 0:cdc2def9aec4#14 3.30 hol -rota-cobalt 0:cdc2def9aec4#15 3.30 hol -rota-cobalt 0:08c4242b5f58 2.23 stock -rota-cobalt 0:f8f8922da592 2.23 standby -rota-cobalt 0:c2fb788c7dee 2.23 training -rota-cobalt 0:a18603086e5b 2.23 support -rota-cobalt 0:17ae69cc7e46 2.21 covers -rota-cobalt 0:412a5c513322 2.22 l104/d50 -rota-cobalt 0:304560bf3205 2.22 l33/d57 -rota-cobalt 0:ca6ce47ada53 2.22 l32/d76 -rota-cobalt 0:e594a1e64b7d 2.22 l22/d48 -rota-cobalt 0:7062fafe1dab 2.22 l65/d112 -rota-cobalt 0:778cb57b62fa 2.22 l73/d112 -rota-cobalt 0:1b4471920f01 2.22 l72/d83 -rota-cobalt 0:288965a1f2c8 2.12 management -rota-cobalt 0:6a61eb7383e8 2.23 rosa -rota-cobalt 0:3bb0f0bb5552 2.24 09:00-18:00 -rota-cobalt 0:3bb0f0bb5552#1 2.40 09:00-18:00 -rota-cobalt 0:3bb0f0bb5552#2 2.40 09:00-18:00 -rota-cobalt 0:3bb0f0bb5552#3 2.92 09:00-18:00 -rota-cobalt 0:9fff4d6be39d 2.24 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#1 2.24 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#2 1.67 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#3 2.35 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#4 2.35 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#5 2.81 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#6 2.81 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#7 2.92 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#8 3.03 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#9 3.13 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#10 3.13 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#11 3.13 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#12 2.41 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#13 2.41 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#14 2.41 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#15 3.09 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#16 2.41 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#17 2.41 16:00-01:00 -rota-cobalt 0:1017e02ccc77 2.24 08:00-18:00 -rota-cobalt 0:e669544e9895 1.45 08:00-16:00 -rota-cobalt 0:e669544e9895#1 2.23 08:00-16:00 -rota-cobalt 0:19c30663e8ef 1.51 16:00-21:00 -rota-cobalt 0:19c30663e8ef#1 2.30 16:00-21:00 -rota-cobalt 0:c9affe7f7587 2.34 tomas -rota-cobalt 0:6d43df86fe4b 2.35 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#1 2.35 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#2 2.40 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#3 2.60 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#4 2.60 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#5 2.24 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#6 3.03 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#7 2.35 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#8 3.13 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#9 3.09 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#10 2.62 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#11 3.30 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#12 3.30 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#13 2.62 16:00-00:00 -rota-cobalt 0:5a5f719aae11 1.65 11:00-16:00 -rota-cobalt 0:df9ba122242c 2.27 bartenders -rota-cobalt 0:dbaf86dd80e3 2.39 nadia -rota-cobalt 0:0cdd9d9e75dc 1.60 09:00-16:00 -rota-cobalt 0:0cdd9d9e75dc#1 1.81 09:00-16:00 -rota-cobalt 0:0cdd9d9e75dc#2 2.01 09:00-16:00 -rota-cobalt 0:0cdd9d9e75dc#3 2.01 09:00-16:00 -rota-cobalt 0:f1e2ce9aa575 1.67 16:00-22:00 -rota-cobalt 0:f1e2ce9aa575#1 1.87 16:00-22:00 -rota-cobalt 0:f1e2ce9aa575#2 1.87 16:00-22:00 -rota-cobalt 0:f1e2ce9aa575#3 2.08 16:00-22:00 -rota-cobalt 0:f1e2ce9aa575#4 2.08 16:00-22:00 -rota-cobalt 0:de7ee76e7373 2.60 elias -rota-cobalt 0:61587c4c0d6d 2.60 09:00-17:00 -rota-cobalt 0:7195a8ed72b9 1.81 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#1 2.22 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#2 2.33 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#3 2.39 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#4 2.39 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#5 2.39 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#6 2.40 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#7 2.40 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#8 2.60 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#9 2.60 12:00-16:00 -rota-cobalt 0:953cb9ba8128 2.80 mirela -rota-cobalt 0:c1298a6e85c2 2.81 12:00-18:00 -rota-cobalt 0:c1298a6e85c2#1 2.92 12:00-18:00 -rota-cobalt 0:86846e84f9bf 2.91 sofia -rota-cobalt 0:effcc54ba75f 2.91 karol -rota-cobalt 0:5c1e134d2d34 3.02 henrik -rota-cobalt 0:6326e0e8cfda 3.13 lucia -rota-cobalt 0:c235e502637c 3.06 barbacks -rota-cobalt 0:bd558de5f794 3.08 oskar -rota-cobalt 0:4941d6993ee9 3.08 teodor -rota-cobalt 0:1d3d3d6a07bd 3.29 vesna +rota-cobalt 0:33888e306262 0.28 quay +rota-cobalt 0:f5f481423c57 0.13 quaysidebar +rota-cobalt 0:d457d4238a16 0.53 monday31st +rota-cobalt 0:916a90a10237 0.53 tuesday1st +rota-cobalt 0:c602d130bfde 0.53 wednesday2nd +rota-cobalt 0:74cf6babb6f6 0.53 thursday3rd +rota-cobalt 0:ae6aa379ad0c 0.53 friday4th +rota-cobalt 0:a6643f169bfd 0.53 saturday5th +rota-cobalt 0:58752a3c0543 0.53 sunday6th +rota-cobalt 0:c4aee96677b0 1.12 /cleancrushedice +rota-cobalt 0:2895e01ceae0 0.36 machine&area +rota-cobalt 0:f4e7df025c89 1.32 pianist18:30/clean +rota-cobalt 0:d3acaa767810 0.81 crushedicemachine& +rota-cobalt 0:4a91ee5f0106 0.30 area +rota-cobalt 0:b913ee5e077a 1.12 privatedinner44pax/ +rota-cobalt 0:d89987d22df9 0.36 soulgf/pianist18:30 +rota-cobalt 0:fb980282ae0a 1.12 pianist18:30/partner +rota-cobalt 0:3c1fc038017e 0.36 meeting3:30pm +rota-cobalt 0:cc96b22615e5 0.52 pianist19:00 +rota-cobalt 0:bc14a4ce6407 0.37 statuslegend +rota-cobalt 0:1f58b9145b24 0.43 request +rota-cobalt 0:b4dc66dde806 0.43 off +rota-cobalt 0:b4dc66dde806#1 0.44 off +rota-cobalt 0:b4dc66dde806#2 0.44 off +rota-cobalt 0:b4dc66dde806#3 0.55 off +rota-cobalt 0:b4dc66dde806#4 0.55 off +rota-cobalt 0:b4dc66dde806#5 0.60 off +rota-cobalt 0:b4dc66dde806#6 0.60 off +rota-cobalt 0:b4dc66dde806#7 0.60 off +rota-cobalt 0:b4dc66dde806#8 0.80 off +rota-cobalt 0:b4dc66dde806#9 0.80 off +rota-cobalt 0:b4dc66dde806#10 1.01 off +rota-cobalt 0:b4dc66dde806#11 1.01 off +rota-cobalt 0:b4dc66dde806#12 1.12 off +rota-cobalt 0:b4dc66dde806#13 1.23 off +rota-cobalt 0:b4dc66dde806#14 1.23 off +rota-cobalt 0:b4dc66dde806#15 1.33 off +rota-cobalt 0:b4dc66dde806#16 1.33 off +rota-cobalt 0:b4dc66dde806#17 1.33 off +rota-cobalt 0:b4dc66dde806#18 1.28 off +rota-cobalt 0:b4dc66dde806#19 1.29 off +rota-cobalt 0:b4dc66dde806#20 1.29 off +rota-cobalt 0:cdc2def9aec4 0.43 hol +rota-cobalt 0:cdc2def9aec4#1 1.11 hol +rota-cobalt 0:cdc2def9aec4#2 1.11 hol +rota-cobalt 0:cdc2def9aec4#3 1.11 hol +rota-cobalt 0:cdc2def9aec4#4 1.11 hol +rota-cobalt 0:cdc2def9aec4#5 1.11 hol +rota-cobalt 0:cdc2def9aec4#6 1.11 hol +rota-cobalt 0:cdc2def9aec4#7 1.12 hol +rota-cobalt 0:cdc2def9aec4#8 1.23 hol +rota-cobalt 0:cdc2def9aec4#9 1.28 hol +rota-cobalt 0:cdc2def9aec4#10 1.28 hol +rota-cobalt 0:cdc2def9aec4#11 1.28 hol +rota-cobalt 0:cdc2def9aec4#12 1.29 hol +rota-cobalt 0:cdc2def9aec4#13 1.50 hol +rota-cobalt 0:cdc2def9aec4#14 1.50 hol +rota-cobalt 0:cdc2def9aec4#15 1.50 hol +rota-cobalt 0:08c4242b5f58 0.43 stock +rota-cobalt 0:f8f8922da592 0.43 standby +rota-cobalt 0:c2fb788c7dee 0.43 training +rota-cobalt 0:a18603086e5b 0.43 support +rota-cobalt 0:17ae69cc7e46 0.41 covers +rota-cobalt 0:412a5c513322 0.42 l104/d50 +rota-cobalt 0:304560bf3205 0.42 l33/d57 +rota-cobalt 0:ca6ce47ada53 0.42 l32/d76 +rota-cobalt 0:e594a1e64b7d 0.42 l22/d48 +rota-cobalt 0:7062fafe1dab 0.42 l65/d112 +rota-cobalt 0:778cb57b62fa 0.42 l73/d112 +rota-cobalt 0:1b4471920f01 0.42 l72/d83 +rota-cobalt 0:288965a1f2c8 0.32 management +rota-cobalt 0:6a61eb7383e8 0.43 rosa +rota-cobalt 0:3bb0f0bb5552 0.44 09:00-18:00 +rota-cobalt 0:3bb0f0bb5552#1 0.60 09:00-18:00 +rota-cobalt 0:3bb0f0bb5552#2 0.60 09:00-18:00 +rota-cobalt 0:3bb0f0bb5552#3 1.12 09:00-18:00 +rota-cobalt 0:9fff4d6be39d 0.44 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#1 0.44 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#2 -0.13 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#3 0.55 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#4 0.55 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#5 1.01 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#6 1.01 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#7 1.12 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#8 1.23 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#9 1.33 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#10 1.33 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#11 1.33 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#12 0.61 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#13 0.61 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#14 0.61 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#15 1.29 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#16 0.61 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#17 0.61 16:00-01:00 +rota-cobalt 0:1017e02ccc77 0.44 08:00-18:00 +rota-cobalt 0:e669544e9895 -0.35 08:00-16:00 +rota-cobalt 0:e669544e9895#1 0.43 08:00-16:00 +rota-cobalt 0:19c30663e8ef -0.29 16:00-21:00 +rota-cobalt 0:19c30663e8ef#1 0.50 16:00-21:00 +rota-cobalt 0:c9affe7f7587 0.54 tomas +rota-cobalt 0:6d43df86fe4b 0.55 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#1 0.55 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#2 0.60 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#3 0.80 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#4 0.80 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#5 0.44 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#6 1.23 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#7 0.55 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#8 1.33 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#9 1.29 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#10 0.82 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#11 1.50 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#12 1.50 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#13 0.82 16:00-00:00 +rota-cobalt 0:5a5f719aae11 -0.15 11:00-16:00 +rota-cobalt 0:df9ba122242c 0.47 bartenders +rota-cobalt 0:dbaf86dd80e3 0.59 nadia +rota-cobalt 0:0cdd9d9e75dc -0.20 09:00-16:00 +rota-cobalt 0:0cdd9d9e75dc#1 0.01 09:00-16:00 +rota-cobalt 0:0cdd9d9e75dc#2 0.21 09:00-16:00 +rota-cobalt 0:0cdd9d9e75dc#3 0.21 09:00-16:00 +rota-cobalt 0:f1e2ce9aa575 -0.13 16:00-22:00 +rota-cobalt 0:f1e2ce9aa575#1 0.07 16:00-22:00 +rota-cobalt 0:f1e2ce9aa575#2 0.07 16:00-22:00 +rota-cobalt 0:f1e2ce9aa575#3 0.28 16:00-22:00 +rota-cobalt 0:f1e2ce9aa575#4 0.28 16:00-22:00 +rota-cobalt 0:de7ee76e7373 0.80 elias +rota-cobalt 0:61587c4c0d6d 0.80 09:00-17:00 +rota-cobalt 0:7195a8ed72b9 0.01 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#1 0.42 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#2 0.53 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#3 0.59 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#4 0.59 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#5 0.59 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#6 0.60 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#7 0.60 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#8 0.80 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#9 0.80 12:00-16:00 +rota-cobalt 0:953cb9ba8128 1.00 mirela +rota-cobalt 0:c1298a6e85c2 1.01 12:00-18:00 +rota-cobalt 0:c1298a6e85c2#1 1.12 12:00-18:00 +rota-cobalt 0:86846e84f9bf 1.11 sofia +rota-cobalt 0:effcc54ba75f 1.11 karol +rota-cobalt 0:5c1e134d2d34 1.22 henrik +rota-cobalt 0:6326e0e8cfda 1.33 lucia +rota-cobalt 0:c235e502637c 1.26 barbacks +rota-cobalt 0:bd558de5f794 1.28 oskar +rota-cobalt 0:4941d6993ee9 1.28 teodor +rota-cobalt 0:1d3d3d6a07bd 1.49 vesna rota-cobalt 0:59f2ff7c7c70 0.03 timesshownin24-hourformat.shiftc rota-cobalt 0:594c41ec0506 0.03 quaysidebar|31aug-6sep2026 diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv index d813824a7..dc5c968c3 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv @@ -61,4 +61,4 @@ proposal-indigo 1 1 72 70 0.57 27.88 16 proposal-modern 1 1 44 44 0.41 0.75 0 proposal-northline 2 2 132 132 0.38 1.62 6 receipt-modern 1 1 57 57 0.26 0.29 0 -rota-cobalt 1 1 154 151 2.39 3.09 124 +rota-cobalt 1 1 154 151 0.60 1.29 0 diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv index 8d7b3032b..49c134963 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv @@ -4092,157 +4092,157 @@ receipt-modern 0:94d25770d489 0.19 help@northwind-pay.example receipt-modern 0:d613d9566d09 0.15 northwindpayisafictionalinstitut receipt-modern 0:403a860bfc00 0.19 invented. receipt-modern 0:7b14b8d2c5c4 0.07 page1of1 -rota-cobalt 0:33888e306262 0.92 quay -rota-cobalt 0:f5f481423c57 1.22 quaysidebar -rota-cobalt 0:d457d4238a16 1.58 monday31st -rota-cobalt 0:916a90a10237 1.58 tuesday1st -rota-cobalt 0:c602d130bfde 1.58 wednesday2nd -rota-cobalt 0:74cf6babb6f6 1.58 thursday3rd -rota-cobalt 0:ae6aa379ad0c 1.58 friday4th -rota-cobalt 0:a6643f169bfd 1.58 saturday5th -rota-cobalt 0:58752a3c0543 1.58 sunday6th -rota-cobalt 0:c4aee96677b0 2.00 /cleancrushedice -rota-cobalt 0:2895e01ceae0 1.25 machine&area -rota-cobalt 0:f4e7df025c89 2.24 pianist18:30/clean -rota-cobalt 0:d3acaa767810 1.73 crushedicemachine& -rota-cobalt 0:4a91ee5f0106 1.22 area -rota-cobalt 0:b913ee5e077a 2.00 privatedinner44pax/ -rota-cobalt 0:d89987d22df9 1.25 soulgf/pianist18:30 -rota-cobalt 0:fb980282ae0a 2.00 pianist18:30/partner -rota-cobalt 0:3c1fc038017e 1.25 meeting3:30pm -rota-cobalt 0:cc96b22615e5 1.52 pianist19:00 -rota-cobalt 0:ffe6874516db 1.52 mc10pax/pianist19:00mc2pax//60pa -rota-cobalt 0:cd49e19a7e3e 1.73 /ffexhire/cleancubed -rota-cobalt 0:672c5d949aee 1.22 icemachine&area -rota-cobalt 0:bc14a4ce6407 2.24 statuslegend -rota-cobalt 0:1f58b9145b24 2.25 request -rota-cobalt 0:b4dc66dde806 2.25 off -rota-cobalt 0:b4dc66dde806#1 2.33 off -rota-cobalt 0:b4dc66dde806#2 2.33 off -rota-cobalt 0:b4dc66dde806#3 2.46 off -rota-cobalt 0:b4dc66dde806#4 2.46 off -rota-cobalt 0:b4dc66dde806#5 2.48 off -rota-cobalt 0:b4dc66dde806#6 2.48 off -rota-cobalt 0:b4dc66dde806#7 2.48 off -rota-cobalt 0:b4dc66dde806#8 2.74 off -rota-cobalt 0:b4dc66dde806#9 2.74 off -rota-cobalt 0:b4dc66dde806#10 2.90 off -rota-cobalt 0:b4dc66dde806#11 2.90 off -rota-cobalt 0:b4dc66dde806#12 3.05 off -rota-cobalt 0:b4dc66dde806#13 3.19 off -rota-cobalt 0:b4dc66dde806#14 3.19 off -rota-cobalt 0:b4dc66dde806#15 3.20 off -rota-cobalt 0:b4dc66dde806#16 3.20 off -rota-cobalt 0:b4dc66dde806#17 3.20 off -rota-cobalt 0:b4dc66dde806#18 3.25 off -rota-cobalt 0:b4dc66dde806#19 3.27 off -rota-cobalt 0:b4dc66dde806#20 3.27 off -rota-cobalt 0:cdc2def9aec4 2.25 hol -rota-cobalt 0:cdc2def9aec4#1 3.03 hol -rota-cobalt 0:cdc2def9aec4#2 3.03 hol -rota-cobalt 0:cdc2def9aec4#3 3.03 hol -rota-cobalt 0:cdc2def9aec4#4 3.03 hol -rota-cobalt 0:cdc2def9aec4#5 3.03 hol -rota-cobalt 0:cdc2def9aec4#6 3.03 hol -rota-cobalt 0:cdc2def9aec4#7 3.05 hol -rota-cobalt 0:cdc2def9aec4#8 3.19 hol -rota-cobalt 0:cdc2def9aec4#9 3.25 hol -rota-cobalt 0:cdc2def9aec4#10 3.25 hol -rota-cobalt 0:cdc2def9aec4#11 3.25 hol -rota-cobalt 0:cdc2def9aec4#12 3.27 hol -rota-cobalt 0:cdc2def9aec4#13 3.40 hol -rota-cobalt 0:cdc2def9aec4#14 3.40 hol -rota-cobalt 0:cdc2def9aec4#15 3.40 hol -rota-cobalt 0:08c4242b5f58 2.25 stock -rota-cobalt 0:f8f8922da592 2.25 standby -rota-cobalt 0:c2fb788c7dee 2.25 training -rota-cobalt 0:a18603086e5b 2.25 support -rota-cobalt 0:17ae69cc7e46 2.34 covers -rota-cobalt 0:412a5c513322 2.32 l104/d50 -rota-cobalt 0:304560bf3205 2.32 l33/d57 -rota-cobalt 0:ca6ce47ada53 2.32 l32/d76 -rota-cobalt 0:e594a1e64b7d 2.32 l22/d48 -rota-cobalt 0:7062fafe1dab 2.32 l65/d112 -rota-cobalt 0:778cb57b62fa 2.32 l73/d112 -rota-cobalt 0:1b4471920f01 2.32 l72/d83 -rota-cobalt 0:288965a1f2c8 1.63 management -rota-cobalt 0:6a61eb7383e8 2.22 rosa -rota-cobalt 0:3bb0f0bb5552 2.33 09:00-18:00 -rota-cobalt 0:3bb0f0bb5552#1 2.48 09:00-18:00 -rota-cobalt 0:3bb0f0bb5552#2 2.48 09:00-18:00 -rota-cobalt 0:3bb0f0bb5552#3 3.05 09:00-18:00 -rota-cobalt 0:9fff4d6be39d 2.33 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#1 2.33 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#2 1.72 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#3 2.46 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#4 2.46 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#5 2.90 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#6 2.90 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#7 3.05 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#8 3.19 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#9 3.20 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#10 3.20 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#11 3.20 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#12 2.51 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#13 2.51 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#14 2.51 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#15 3.27 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#16 2.52 16:00-01:00 -rota-cobalt 0:9fff4d6be39d#17 2.52 16:00-01:00 -rota-cobalt 0:1017e02ccc77 2.33 08:00-18:00 -rota-cobalt 0:e669544e9895 1.52 08:00-16:00 -rota-cobalt 0:e669544e9895#1 2.38 08:00-16:00 -rota-cobalt 0:19c30663e8ef 1.46 16:00-21:00 -rota-cobalt 0:19c30663e8ef#1 2.32 16:00-21:00 -rota-cobalt 0:c9affe7f7587 2.36 tomas -rota-cobalt 0:6d43df86fe4b 2.46 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#1 2.46 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#2 2.48 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#3 2.74 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#4 2.74 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#5 2.41 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#6 3.19 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#7 2.44 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#8 3.20 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#9 3.27 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#10 2.77 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#11 3.40 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#12 3.40 16:00-00:00 -rota-cobalt 0:6d43df86fe4b#13 2.77 16:00-00:00 -rota-cobalt 0:5a5f719aae11 1.78 11:00-16:00 -rota-cobalt 0:df9ba122242c 1.81 bartenders -rota-cobalt 0:dbaf86dd80e3 2.38 nadia -rota-cobalt 0:0cdd9d9e75dc 1.68 09:00-16:00 -rota-cobalt 0:0cdd9d9e75dc#1 1.94 09:00-16:00 -rota-cobalt 0:0cdd9d9e75dc#2 2.07 09:00-16:00 -rota-cobalt 0:0cdd9d9e75dc#3 2.07 09:00-16:00 -rota-cobalt 0:f1e2ce9aa575 1.62 16:00-22:00 -rota-cobalt 0:f1e2ce9aa575#1 1.87 16:00-22:00 -rota-cobalt 0:f1e2ce9aa575#2 1.87 16:00-22:00 -rota-cobalt 0:f1e2ce9aa575#3 2.15 16:00-22:00 -rota-cobalt 0:f1e2ce9aa575#4 2.15 16:00-22:00 -rota-cobalt 0:de7ee76e7373 2.63 elias -rota-cobalt 0:61587c4c0d6d 2.74 09:00-17:00 -rota-cobalt 0:7195a8ed72b9 1.94 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#1 2.35 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#2 2.50 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#3 2.57 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#4 2.57 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#5 2.57 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#6 2.59 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#7 2.59 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#8 2.72 12:00-16:00 -rota-cobalt 0:7195a8ed72b9#9 2.72 12:00-16:00 -rota-cobalt 0:953cb9ba8128 2.91 mirela -rota-cobalt 0:c1298a6e85c2 2.90 12:00-18:00 -rota-cobalt 0:c1298a6e85c2#1 3.05 12:00-18:00 -rota-cobalt 0:86846e84f9bf 2.93 sofia -rota-cobalt 0:effcc54ba75f 2.94 karol -rota-cobalt 0:5c1e134d2d34 3.08 henrik -rota-cobalt 0:6326e0e8cfda 3.22 lucia -rota-cobalt 0:c235e502637c 2.67 barbacks -rota-cobalt 0:bd558de5f794 3.15 oskar -rota-cobalt 0:4941d6993ee9 3.16 teodor -rota-cobalt 0:1d3d3d6a07bd 3.42 vesna +rota-cobalt 0:33888e306262 -0.04 quay +rota-cobalt 0:f5f481423c57 0.38 quaysidebar +rota-cobalt 0:d457d4238a16 0.74 monday31st +rota-cobalt 0:916a90a10237 0.74 tuesday1st +rota-cobalt 0:c602d130bfde 0.74 wednesday2nd +rota-cobalt 0:74cf6babb6f6 0.74 thursday3rd +rota-cobalt 0:ae6aa379ad0c 0.74 friday4th +rota-cobalt 0:a6643f169bfd 0.74 saturday5th +rota-cobalt 0:58752a3c0543 0.74 sunday6th +rota-cobalt 0:c4aee96677b0 1.04 /cleancrushedice +rota-cobalt 0:2895e01ceae0 0.41 machine&area +rota-cobalt 0:f4e7df025c89 1.40 pianist18:30/clean +rota-cobalt 0:d3acaa767810 0.89 crushedicemachine& +rota-cobalt 0:4a91ee5f0106 0.38 area +rota-cobalt 0:b913ee5e077a 1.04 privatedinner44pax/ +rota-cobalt 0:d89987d22df9 0.41 soulgf/pianist18:30 +rota-cobalt 0:fb980282ae0a 1.04 pianist18:30/partner +rota-cobalt 0:3c1fc038017e 0.41 meeting3:30pm +rota-cobalt 0:cc96b22615e5 0.56 pianist19:00 +rota-cobalt 0:ffe6874516db 0.56 mc10pax/pianist19:00mc2pax//60pa +rota-cobalt 0:cd49e19a7e3e 0.89 /ffexhire/cleancubed +rota-cobalt 0:672c5d949aee 0.38 icemachine&area +rota-cobalt 0:bc14a4ce6407 0.44 statuslegend +rota-cobalt 0:1f58b9145b24 0.45 request +rota-cobalt 0:b4dc66dde806 0.45 off +rota-cobalt 0:b4dc66dde806#1 0.53 off +rota-cobalt 0:b4dc66dde806#2 0.53 off +rota-cobalt 0:b4dc66dde806#3 0.66 off +rota-cobalt 0:b4dc66dde806#4 0.66 off +rota-cobalt 0:b4dc66dde806#5 0.68 off +rota-cobalt 0:b4dc66dde806#6 0.68 off +rota-cobalt 0:b4dc66dde806#7 0.68 off +rota-cobalt 0:b4dc66dde806#8 0.94 off +rota-cobalt 0:b4dc66dde806#9 0.94 off +rota-cobalt 0:b4dc66dde806#10 1.10 off +rota-cobalt 0:b4dc66dde806#11 1.10 off +rota-cobalt 0:b4dc66dde806#12 1.25 off +rota-cobalt 0:b4dc66dde806#13 1.39 off +rota-cobalt 0:b4dc66dde806#14 1.39 off +rota-cobalt 0:b4dc66dde806#15 1.40 off +rota-cobalt 0:b4dc66dde806#16 1.40 off +rota-cobalt 0:b4dc66dde806#17 1.40 off +rota-cobalt 0:b4dc66dde806#18 1.45 off +rota-cobalt 0:b4dc66dde806#19 1.47 off +rota-cobalt 0:b4dc66dde806#20 1.47 off +rota-cobalt 0:cdc2def9aec4 0.45 hol +rota-cobalt 0:cdc2def9aec4#1 1.23 hol +rota-cobalt 0:cdc2def9aec4#2 1.23 hol +rota-cobalt 0:cdc2def9aec4#3 1.23 hol +rota-cobalt 0:cdc2def9aec4#4 1.23 hol +rota-cobalt 0:cdc2def9aec4#5 1.23 hol +rota-cobalt 0:cdc2def9aec4#6 1.23 hol +rota-cobalt 0:cdc2def9aec4#7 1.25 hol +rota-cobalt 0:cdc2def9aec4#8 1.39 hol +rota-cobalt 0:cdc2def9aec4#9 1.45 hol +rota-cobalt 0:cdc2def9aec4#10 1.45 hol +rota-cobalt 0:cdc2def9aec4#11 1.45 hol +rota-cobalt 0:cdc2def9aec4#12 1.47 hol +rota-cobalt 0:cdc2def9aec4#13 1.60 hol +rota-cobalt 0:cdc2def9aec4#14 1.60 hol +rota-cobalt 0:cdc2def9aec4#15 1.60 hol +rota-cobalt 0:08c4242b5f58 0.45 stock +rota-cobalt 0:f8f8922da592 0.45 standby +rota-cobalt 0:c2fb788c7dee 0.45 training +rota-cobalt 0:a18603086e5b 0.45 support +rota-cobalt 0:17ae69cc7e46 0.54 covers +rota-cobalt 0:412a5c513322 0.52 l104/d50 +rota-cobalt 0:304560bf3205 0.52 l33/d57 +rota-cobalt 0:ca6ce47ada53 0.52 l32/d76 +rota-cobalt 0:e594a1e64b7d 0.52 l22/d48 +rota-cobalt 0:7062fafe1dab 0.52 l65/d112 +rota-cobalt 0:778cb57b62fa 0.52 l73/d112 +rota-cobalt 0:1b4471920f01 0.52 l72/d83 +rota-cobalt 0:288965a1f2c8 -0.17 management +rota-cobalt 0:6a61eb7383e8 0.42 rosa +rota-cobalt 0:3bb0f0bb5552 0.53 09:00-18:00 +rota-cobalt 0:3bb0f0bb5552#1 0.68 09:00-18:00 +rota-cobalt 0:3bb0f0bb5552#2 0.68 09:00-18:00 +rota-cobalt 0:3bb0f0bb5552#3 1.25 09:00-18:00 +rota-cobalt 0:9fff4d6be39d 0.53 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#1 0.53 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#2 -0.08 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#3 0.66 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#4 0.66 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#5 1.10 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#6 1.10 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#7 1.25 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#8 1.39 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#9 1.40 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#10 1.40 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#11 1.40 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#12 0.71 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#13 0.71 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#14 0.71 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#15 1.47 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#16 0.72 16:00-01:00 +rota-cobalt 0:9fff4d6be39d#17 0.72 16:00-01:00 +rota-cobalt 0:1017e02ccc77 0.53 08:00-18:00 +rota-cobalt 0:e669544e9895 -0.28 08:00-16:00 +rota-cobalt 0:e669544e9895#1 0.58 08:00-16:00 +rota-cobalt 0:19c30663e8ef -0.34 16:00-21:00 +rota-cobalt 0:19c30663e8ef#1 0.52 16:00-21:00 +rota-cobalt 0:c9affe7f7587 0.56 tomas +rota-cobalt 0:6d43df86fe4b 0.66 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#1 0.66 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#2 0.68 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#3 0.94 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#4 0.94 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#5 0.61 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#6 1.39 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#7 0.64 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#8 1.40 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#9 1.47 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#10 0.97 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#11 1.60 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#12 1.60 16:00-00:00 +rota-cobalt 0:6d43df86fe4b#13 0.97 16:00-00:00 +rota-cobalt 0:5a5f719aae11 -0.02 11:00-16:00 +rota-cobalt 0:df9ba122242c 0.01 bartenders +rota-cobalt 0:dbaf86dd80e3 0.58 nadia +rota-cobalt 0:0cdd9d9e75dc -0.12 09:00-16:00 +rota-cobalt 0:0cdd9d9e75dc#1 0.14 09:00-16:00 +rota-cobalt 0:0cdd9d9e75dc#2 0.27 09:00-16:00 +rota-cobalt 0:0cdd9d9e75dc#3 0.27 09:00-16:00 +rota-cobalt 0:f1e2ce9aa575 -0.18 16:00-22:00 +rota-cobalt 0:f1e2ce9aa575#1 0.07 16:00-22:00 +rota-cobalt 0:f1e2ce9aa575#2 0.07 16:00-22:00 +rota-cobalt 0:f1e2ce9aa575#3 0.35 16:00-22:00 +rota-cobalt 0:f1e2ce9aa575#4 0.35 16:00-22:00 +rota-cobalt 0:de7ee76e7373 0.83 elias +rota-cobalt 0:61587c4c0d6d 0.94 09:00-17:00 +rota-cobalt 0:7195a8ed72b9 0.14 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#1 0.55 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#2 0.70 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#3 0.77 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#4 0.77 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#5 0.77 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#6 0.79 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#7 0.79 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#8 0.92 12:00-16:00 +rota-cobalt 0:7195a8ed72b9#9 0.92 12:00-16:00 +rota-cobalt 0:953cb9ba8128 1.11 mirela +rota-cobalt 0:c1298a6e85c2 1.10 12:00-18:00 +rota-cobalt 0:c1298a6e85c2#1 1.25 12:00-18:00 +rota-cobalt 0:86846e84f9bf 1.13 sofia +rota-cobalt 0:effcc54ba75f 1.14 karol +rota-cobalt 0:5c1e134d2d34 1.28 henrik +rota-cobalt 0:6326e0e8cfda 1.42 lucia +rota-cobalt 0:c235e502637c 0.87 barbacks +rota-cobalt 0:bd558de5f794 1.35 oskar +rota-cobalt 0:4941d6993ee9 1.36 teodor +rota-cobalt 0:1d3d3d6a07bd 1.62 vesna rota-cobalt 0:59f2ff7c7c70 0.08 timesshownin24-hourformat.shiftc rota-cobalt 0:594c41ec0506 0.08 quaysidebar|31aug-6sep2026 diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv index 35f57b959..29946974e 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv @@ -61,4 +61,4 @@ proposal-indigo 1 1 72 70 0.43 27.75 16 proposal-modern 1 1 44 44 0.35 0.73 0 proposal-northline 2 2 132 132 0.36 1.75 6 receipt-modern 1 1 57 57 0.33 0.44 0 -rota-cobalt 1 1 154 154 2.48 3.22 119 +rota-cobalt 1 1 154 154 0.74 1.42 0 diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index e9af8186a..c871b4f98 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -7148,9 +7148,84 @@ private void holdRowHeight(XWPFTableRow row, TableNode node, int rowIdx) { java.util.OptionalDouble height = layout.rowHeight(node, rowIdx); if (height.isPresent()) { holdRowAtLeast(row, height.getAsDouble()); + fitBlankLinesToTheRow(row); } } + /** + * Cuts the line of a cell that holds no letters to the room its row leaves it. + * + *
A row that is only a rule takes its thickness from an empty cell's font: on the page + * the rule's borders are drawn across that line, and in Word they stand outside it. The row + * is written less its borders (see {@link #holdRowAtLeast}), but the line is not, and Word + * grows the row to the line: {@code CobaltRota}'s two rules, a 2.3pt and a 1.95pt line under + * a 0.9pt border, stood 0.9pt taller each, and the whole sheet under them 1.8pt low. + * LibreOffice did the same. A line with no letters has nothing to cut into; one with + * letters keeps its line.
+ * + *The row's height is written less the most any of its cells takes above and below its + * content; Word reads it as the row's whole, a cell's content and margins filling it. So a + * cell's room is that height, as much again as the row's written height leaves out, less + * what the cell itself takes and its paragraph's space above and below — the page's row, + * less the cell's own margins and border. Taken off the written height alone, a blank row's + * line would lose its padding twice.
+ */ + private static void fitBlankLinesToTheRow(XWPFTableRow row) { + org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTrPr properties = row.getCtRow().getTrPr(); + if (properties == null || properties.sizeOfTrHeightArray() == 0) { + return; + } + Long rowTwips = writtenTwips(properties.getTrHeightArray(0).getVal()); + if (rowTwips == null) { + return; + } + long leftOut = 0; + for (XWPFTableCell cell : row.getTableCells()) { + leftOut = Math.max(leftOut, verticalMargins(cell)); + } + for (XWPFTableCell cell : row.getTableCells()) { + ListThe page draws the rule's borders across the empty line; Word keeps a cell's borders + * outside its content and grows the row to the line. {@code CobaltRota} opens and closes its + * header with two such rows, and its whole sheet stood 1.8pt low in Word.
+ */ +class DocxRuleRowTest { + + private static final DocumentStroke RULE = DocumentStroke.of(DocumentColor.rgb(16, 32, 80), 0.9); + + /** The 0.9pt border, in twips. */ + private static final long BORDER = 18; + + @Test + void aRuleRowsEmptyLineIsCutToTheRoomItsRowLeaves() throws Exception { + try (XWPFDocument document = export(DocumentInsets.zero(), DocumentTextStyle.DEFAULT.withSize(2))) { + XWPFTableRow rule = document.getTables().get(0).getRow(0); + + assertThat(line(rule)).as("the line stands inside the row").isEqualTo(rowHeight(rule)); + } + } + + @Test + void aLineWithLettersKeepsItsHeight() throws Exception { + try (XWPFDocument document = export(DocumentInsets.zero(), DocumentTextStyle.DEFAULT.withSize(2))) { + XWPFTableRow text = document.getTables().get(0).getRow(1); + + assertThat(line(text)).as("its letters would be cut").isEqualTo(rowHeight(text) + BORDER); + } + } + + @Test + void aPaddedBlankLineKeepsItsHeight() throws Exception { + // Word reads the row's height as its whole, the cell's margins inside it, and the + // padding already gives up the room the border takes: the blank line fits as it is. + try (XWPFDocument document = export(DocumentInsets.of(4), DocumentTextStyle.DEFAULT)) { + var table = document.getTables().get(0); + + assertThat(line(table.getRow(0))).isEqualTo(line(table.getRow(1))); + } + } + + private static long rowHeight(XWPFTableRow row) { + return DocxTwips.of(row.getCtRow().getTrPr().getTrHeightArray(0).getVal()); + } + + private static long line(XWPFTableRow row) { + return DocxTwips.of(row.getCell(0).getParagraphs().get(0).getCTP().getPPr().getSpacing().getLine()); + } + + private static XWPFDocument export(DocumentInsets padding, DocumentTextStyle blank) throws Exception { + DocumentTableStyle rule = DocumentTableStyle.builder() + .padding(padding) + .stroke(RULE) + .textStyle(blank) + .lineSpacing(0) + .build(); + DocumentTableStyle text = DocumentTableStyle.builder() + .padding(padding) + .stroke(RULE) + .lineSpacing(0) + .build(); + return DocxExports.withLayout(400, 300, 20, page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("").withStyle(rule), DocumentTableCell.text("").withStyle(rule)) + .rowCells(DocumentTableCell.text("Text").withStyle(text), DocumentTableCell.text("More").withStyle(text)))); + } +}