diff --git a/CHANGELOG.md b/CHANGELOG.md index 28fcad470..38a4647ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **`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 + "ACHIEVEMENTS" about 1% wider than the page, past its box, and broke it onto a second line, and + three such headings added 51pt. A paragraph of one line now has room for a line three + hundredths wider than its own: it breaks no word, and broken in Word it is a line more. + - Its role bar stands 3.8pt up inside its name's line, by the name's negative bottom margin. + A negative bottom edge — a paragraph's, an image's, a barcode's or a rule's — was dropped; + it now comes out of the space above the next paragraph, its own top edge included, or of the + space owed below where a table or a page break follows. LibreOffice's `NorthlineProposal` gains from it too: lines past + 2pt fall from 34 to 6. + - Line by line, no document moves further from the page in Word or LibreOffice. + - **A paragraph breaks its lines where the page does, though Word sets its size to the half point.** Word states a type size in half points, so `EngineeringResume`'s 7.8pt profile was set at 8pt and took a line more, and its 6.9pt skills, set at 7pt, broke "SQL" onto a line of its diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index adc35793b..fa9be69a6 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -219,6 +219,13 @@ far as its letters leave room above them, its text seated where the page sets it can give is not written. Text laid over the flow in text boxes owes no space, so its edges move nothing. +A negative bottom `margin` or `padding` — a paragraph's, an image's, a barcode's or a rule's — +pulls the next paragraph up into it the same way: it comes out of the space above that +paragraph, its own top edge included, even where the two stand in different containers. What +that space cannot give is not written. Before a table, a page break or the end of a cell it +comes out of the space owed below instead. Layers that overlap on the page take none of it +from one another, as the space between them is measured from their boxes. + A paragraph's or a list's `lineSpacing` — the gap the page puts between two wrapped lines — goes into the line: Word has one line height for a paragraph and no gap between its lines, so a paragraph that wraps is written with its lines taller. The page has one gap fewer than @@ -294,7 +301,9 @@ them; a reader's new text wraps at that measure. The measure grows by the share that grows most for its size — pictures and tracking keep their own width — and in a paragraph of several lines stays a point short of that, unless a line needs the room, so a word the page broke off by a fraction of a point is not pulled back up. A paragraph of one line is never -narrowed. A centred or right-aligned paragraph keeps +narrowed, and has room for a line three hundredths wider than its own: Word sets a face other +than the page's, or emboldens one whose file does not say it is bold, a little wider, and a +line of one broken in Word is a line more. A centred or right-aligned paragraph keeps the page's measure, as moving its other edge would move its lines. A list's items, a line pair's line, text over the flow and a header's or footer's line keep the page's measure too. 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 ec2fcd566..d483ba34c 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv @@ -992,11 +992,11 @@ cv-nordic_clean 0:f87e2f0c02dc 0.11 layoutlint(java21,javaparser,spo cv-nordic_clean 0:a646f74483b1 0.25 rows,untypedoffsets,implicitpage cv-nordic_clean 0:6793d28e9783 0.27 chromeforge(java,graphcompose,pa cv-nordic_clean 0:e3cfea265b3b 0.41 graphcompose:cinematiccovers,pul -cv-orange_ops 0:8157f110feec 81.86 warehouseoperationssupervisor -cv-orange_ops 0:a8acc3a90a7b 81.97 +447700900123 -cv-orange_ops 0:e6da1125d5d5 81.97 marcus.bennett@email.com -cv-orange_ops 0:6a3bd39f358d 81.97 birmingham,westmidlands,uk -cv-orange_ops 0:84308bea4540 81.97 linkedin +cv-orange_ops 0:8157f110feec 77.81 warehouseoperationssupervisor +cv-orange_ops 0:a8acc3a90a7b 77.92 +447700900123 +cv-orange_ops 0:e6da1125d5d5 77.92 marcus.bennett@email.com +cv-orange_ops 0:6a3bd39f358d 77.92 birmingham,westmidlands,uk +cv-orange_ops 0:84308bea4540 77.92 linkedin cv-panel 0:8f1010120ed6 -0.40 jordanrivera cv-panel 0:2d58f0cadc2d 0.12 platformengineer cv-panel 0:89ff05847e96 0.19 london,uk|+442055551000 @@ -3837,34 +3837,35 @@ proposal-northline 0:185e8dc84248 -0.10 primarycontact proposal-northline 0:59d00e0c1b99 -0.27 projectlead proposal-northline 0:4190fd3a8d72 -0.05 proposalvalidity proposal-northline 0:56a2e019b8aa -0.22 14days -proposal-northline 0:e2ea93b97611 10.92 strengthenbrand -proposal-northline 0:7d95098b921b 10.42 credibilityandvisual -proposal-northline 0:218e304f8b84 9.92 consistency. -proposal-northline 0:35597cc9803f 10.92 improvewebsite -proposal-northline 0:83f63137756b 10.42 usabilityanddrive -proposal-northline 0:cce9fba37d4e 9.92 meaningfulaction. -proposal-northline 0:59aaadaf0c54 10.92 communicateyour -proposal-northline 0:30193022f687 10.42 valueclearlyacrossall -proposal-northline 0:04b5995c746f 9.92 touchpoints. -proposal-northline 0:b45d848fe1b6 10.92 buildascalabledigital -proposal-northline 0:e8228bfeba0e 10.42 foundationforfuture -proposal-northline 0:1fa35c7a1402 9.92 growth. -proposal-northline 0:bc981abcdf59 9.91 scopeofwork -proposal-northline 0:28450edd9a4f 10.27 discovery&research -proposal-northline 0:155f35b4967f 10.87 stakeholderinterviews,brandaudit -proposal-northline 0:973aeda5fe6d 10.17 insightstoinformstrategy. -proposal-northline 0:12bff3036b66 10.37 visualidentityrefinement -proposal-northline 0:d7ac738462f9 10.97 refinelogosystem,colorpalette,ty -proposal-northline 0:9136e740c370 10.27 cohesive,modernidentity. -proposal-northline 0:fc5134bf5fd1 10.47 websiteux/uidesign -proposal-northline 0:5d58b01c7096 11.07 designaresponsive,accessibility- -proposal-northline 0:5d6068f6a879 10.37 navigationandclearconversionpath -proposal-northline 0:32001e20b2ed 10.57 contentstructure&messaging -proposal-northline 0:b3c948ec3e36 11.17 definesitearchitecture,keymessag -proposal-northline 0:231428fde7aa 10.47 communicatevalueandbuildtrust. -proposal-northline 0:8c7703bb6fb7 10.67 launchsupport -proposal-northline 0:64aa6ccd2e0e 11.27 developerhandoff,qasupport,train -proposal-northline 0:1f71a8d2d1d2 10.57 ensureasmoothlaunch. +proposal-northline 0:265e93f6b9da -0.04 (until8september2026) +proposal-northline 0:e2ea93b97611 0.72 strengthenbrand +proposal-northline 0:7d95098b921b 0.22 credibilityandvisual +proposal-northline 0:218e304f8b84 -0.28 consistency. +proposal-northline 0:35597cc9803f 0.72 improvewebsite +proposal-northline 0:83f63137756b 0.22 usabilityanddrive +proposal-northline 0:cce9fba37d4e -0.28 meaningfulaction. +proposal-northline 0:59aaadaf0c54 0.72 communicateyour +proposal-northline 0:30193022f687 0.22 valueclearlyacrossall +proposal-northline 0:04b5995c746f -0.28 touchpoints. +proposal-northline 0:b45d848fe1b6 0.72 buildascalabledigital +proposal-northline 0:e8228bfeba0e 0.22 foundationforfuture +proposal-northline 0:1fa35c7a1402 -0.28 growth. +proposal-northline 0:bc981abcdf59 -0.29 scopeofwork +proposal-northline 0:28450edd9a4f 0.07 discovery&research +proposal-northline 0:155f35b4967f 0.67 stakeholderinterviews,brandaudit +proposal-northline 0:973aeda5fe6d -0.03 insightstoinformstrategy. +proposal-northline 0:12bff3036b66 0.17 visualidentityrefinement +proposal-northline 0:d7ac738462f9 0.77 refinelogosystem,colorpalette,ty +proposal-northline 0:9136e740c370 0.07 cohesive,modernidentity. +proposal-northline 0:fc5134bf5fd1 0.27 websiteux/uidesign +proposal-northline 0:5d58b01c7096 0.87 designaresponsive,accessibility- +proposal-northline 0:5d6068f6a879 0.17 navigationandclearconversionpath +proposal-northline 0:32001e20b2ed 0.37 contentstructure&messaging +proposal-northline 0:b3c948ec3e36 0.97 definesitearchitecture,keymessag +proposal-northline 0:231428fde7aa 0.27 communicatevalueandbuildtrust. +proposal-northline 0:8c7703bb6fb7 0.47 launchsupport +proposal-northline 0:64aa6ccd2e0e 1.07 developerhandoff,qasupport,train +proposal-northline 0:1f71a8d2d1d2 0.37 ensureasmoothlaunch. proposal-northline 0:026890c58de9 0.01 northlinestudio|northlinestudio. proposal-northline 1:5205c2a261bc -0.34 northline proposal-northline 1:da0daaba4b15 -0.34 studio diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv index 0392c4c96..d813824a7 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv @@ -16,7 +16,7 @@ cv-modern_professional 2 2 61 59 0.12 0.59 5 cv-monogram_sidebar 1 1 53 50 3.57 5.69 50 cv-navy_sidebar 1 1 71 71 2.81 3.03 71 cv-nordic_clean 1 1 63 58 0.20 0.57 0 -cv-orange_ops 1 2 108 5 81.97 81.97 5 +cv-orange_ops 1 2 108 5 77.92 77.92 5 cv-panel 1 1 79 73 0.35 0.78 0 cv-professional_sidebar 1 1 68 64 2.71 2.90 64 cv-serif_headline 1 1 103 103 0.44 0.88 0 @@ -59,6 +59,6 @@ letter-timeline_minimal 1 1 15 15 1.32 1.52 0 proposal-editorial 2 2 132 119 43.27 45.18 68 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 131 0.78 10.47 34 +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 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 ce61fe97e..8d7b3032b 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv @@ -987,11 +987,104 @@ cv-nordic_clean 0:a646f74483b1 0.31 rows,untypedoffsets,implicitpage cv-nordic_clean 0:6793d28e9783 0.43 chromeforge(java,graphcompose,pa cv-nordic_clean 0:e3cfea265b3b 0.51 graphcompose:cinematiccovers,pul cv-orange_ops 0:a6c9a7bc3a1d -0.34 marcusbennett -cv-orange_ops 0:8157f110feec 3.80 warehouseoperationssupervisor -cv-orange_ops 0:a8acc3a90a7b 3.95 +447700900123 -cv-orange_ops 0:e6da1125d5d5 3.95 marcus.bennett@email.com -cv-orange_ops 0:6a3bd39f358d 3.95 birmingham,westmidlands,uk -cv-orange_ops 0:84308bea4540 3.95 linkedin +cv-orange_ops 0:8157f110feec -0.28 warehouseoperationssupervisor +cv-orange_ops 0:8157f110feec#1 0.21 warehouseoperationssupervisor +cv-orange_ops 0:a8acc3a90a7b -0.01 +447700900123 +cv-orange_ops 0:e6da1125d5d5 -0.01 marcus.bennett@email.com +cv-orange_ops 0:6a3bd39f358d -0.01 birmingham,westmidlands,uk +cv-orange_ops 0:84308bea4540 -0.01 linkedin +cv-orange_ops 0:32c1cf49a2fe 0.11 skills +cv-orange_ops 0:8225698c0a01 0.10 warehouseoperationsmanagement +cv-orange_ops 0:fab5b1b3f07b 0.05 teamleadership&development +cv-orange_ops 0:fe67bd8bcb96 0.00 inventorycontrol&accuracy +cv-orange_ops 0:52d5776e1f94 0.06 logistics&freightcoordination +cv-orange_ops 0:40c502892bf5 0.01 orderfulfilment&dispatch +cv-orange_ops 0:16d5a33ecc7e -0.04 kpimonitoring&reporting +cv-orange_ops 0:01fc299910cc 0.02 leanprocessimprovement +cv-orange_ops 0:d52f9933e794 -0.03 health&safetycompliance +cv-orange_ops 0:2513b7af97dd -0.08 wms&erpsystems +cv-orange_ops 0:034e1f3a8777 0.01 problemsolving&decisionmaking +cv-orange_ops 0:115925c1b96b -0.04 stakeholdercommunication +cv-orange_ops 0:6ee18b42fbf1 0.00 achievements +cv-orange_ops 0:8c76fa6dfb6b 0.14 15%productivityincrease +cv-orange_ops 0:5ba0c9469ae2 -0.09 improvedwarehouseproductivity +cv-orange_ops 0:4738b76d6b6a -0.05 by15%throughworkflow +cv-orange_ops 0:449e0d003818 -0.01 redesignandperformance +cv-orange_ops 0:7071b774bee5 0.04 management. +cv-orange_ops 0:93d8df801c78 0.28 99.2%inventoryaccuracy +cv-orange_ops 0:91021a576e54 0.05 maintainedinventoryaccuracy +cv-orange_ops 0:ab234354692f 0.10 above99%for12consecutive +cv-orange_ops 0:6d4b37f38385 0.14 monthsthroughcyclecount +cv-orange_ops 0:95eb3dca6f29 0.21 disciplineandprocesscontrol. +cv-orange_ops 0:6c9aed656984 0.45 zerolosttimeincidents +cv-orange_ops 0:af3bece11942 0.22 ledasafety-firstcultureresulting +cv-orange_ops 0:0be170a927a8 0.27 inzerolosttimeincidentsover24 +cv-orange_ops 0:326641f2b791 0.31 monthsacrossoperations. +cv-orange_ops 0:44ad72befc81 0.56 £280kcostsavings +cv-orange_ops 0:ccf85623aab7 0.33 delivered£280kinannualsavings +cv-orange_ops 0:b43687fef93a 0.37 byreducingwaste,lowering +cv-orange_ops 0:c63a4328568d 0.42 overtimeandrenegotiatingcarrier +cv-orange_ops 0:203fe55e82c0 0.46 contracts. +cv-orange_ops 0:68bf0109c6f8 0.54 education +cv-orange_ops 0:c075e52d1429 1.29 bsc(hons)logistics&supplychain +cv-orange_ops 0:288965a1f2c8 0.39 management +cv-orange_ops 0:4e3ad0b9c561 0.57 demontfortuniversity +cv-orange_ops 0:19c022f9ca15 0.61 leicester,uk +cv-orange_ops 0:2920a7fb42fc 0.64 2011–2014 +cv-orange_ops 0:e831beb04c00 0.62 certifications +cv-orange_ops 0:7bfe84d02a79 0.61 ioshmanagingsafely +cv-orange_ops 0:a13fc3060cc2 0.56 institutionofoccupationalsafetya +cv-orange_ops 0:cb9792111d09 0.57 leansixsigmayellowbelt +cv-orange_ops 0:da7ec3a3fba6 0.64 theknowledgeacademy–2020 +cv-orange_ops 0:8d34b20716e1 0.65 counterbalanceforklifttruckinstr +cv-orange_ops 0:8225f209e334 0.61 rtitb–2019 +cv-orange_ops 0:afcc559aff07 0.62 firstaidatwork +cv-orange_ops 0:2eff3c9d0161 0.60 stjohnambulance–2019 +cv-orange_ops 0:3c69d6bb4892 0.11 professionalprofile +cv-orange_ops 0:41fad67acd21 0.27 environments.proventrackrecordof +cv-orange_ops 0:360626c9f718 0.34 workexperience +cv-orange_ops 0:03f6d636aa6b 0.40 mar2021–present +cv-orange_ops 0:24c0d4863b50 0.31 expresslinkdistributionltd|birmi +cv-orange_ops 0:873c40886362 0.29 •overseedailywarehouseoperations +cv-orange_ops 0:e789c025e038 0.21 staffacrossinbound,storage,picki +cv-orange_ops 0:005412ac77a3 0.31 •ensuretimelyandaccurateorderful +cv-orange_ops 0:4e9c4aed8e43 0.28 •monitorandmaintaininventoryaccu +cv-orange_ops 0:1509c3cbd20f 0.32 variances. +cv-orange_ops 0:928837818f67 0.42 •analysekpisandoperationaldatato +cv-orange_ops 0:c8a3bdb04f89 0.31 •leadcontinuousimprovementinitia +cv-orange_ops 0:e28f5bf00122 0.47 productivity. +cv-orange_ops 0:bfb9273fea4d 0.37 warehouseteamleader +cv-orange_ops 0:9a8c213939be 0.44 may2017–feb2021 +cv-orange_ops 0:df1f256d362d 0.47 midlandgatelogistics|coventry,uk +cv-orange_ops 0:92d5e6992bc1 0.45 •ledateamof25warehouseoperatives +cv-orange_ops 0:ec21a3e83856 0.42 •managedinboundreceipts,putaway, +cv-orange_ops 0:ac1fa66fcf19 0.52 •maintainedaccuratestockrecordsa +cv-orange_ops 0:195624e8565f 0.49 •supportedtheimplementationofane +cv-orange_ops 0:dd2458930e31 0.47 •reducedpickingerrorsby23%throug +cv-orange_ops 0:81219b44ff52 0.46 warehousecoordinator +cv-orange_ops 0:eb5e95e8b64c 0.53 jun2015–apr2017 +cv-orange_ops 0:95f3c676853f 0.44 totalsupplysolutions|northampton +cv-orange_ops 0:3e147aae9ba8 0.54 •coordinateddailyoperationsandal +cv-orange_ops 0:b786faa44990 0.51 •monitoredstocklevelsandinitiate +cv-orange_ops 0:9806038f6123 0.49 •assistedwithkpireportingandperf +cv-orange_ops 0:c2ec5b8c794d 0.61 •ensuredcompliancewithcompanypol +cv-orange_ops 0:a691a9aab3fc 0.61 keykpisnapshot(recent12months) +cv-orange_ops 0:b5f6dbae04bd 0.46 15% +cv-orange_ops 0:e918ea42a538 -6.02 99.2% +cv-orange_ops 0:16906b6f3827 -6.02 98.4% +cv-orange_ops 0:bd1b16004d22 0.71 productivity +cv-orange_ops 0:b11a85b296a9 0.71 inventory +cv-orange_ops 0:b1e25044bf23 0.71 on-time +cv-orange_ops 0:05595e942a13 0.71 losttime +cv-orange_ops 0:e20a548f23fa 0.71 increase +cv-orange_ops 0:5879092f86c5 0.71 accuracy +cv-orange_ops 0:b4af39d5b65a 0.71 delivery +cv-orange_ops 0:a6d1765ab64d 0.71 incidents +cv-orange_ops 0:676dfdd56ad2 0.74 additionalinformation +cv-orange_ops 0:0945b35c95a8 0.90 systems:sapewm,manhattanwms,micr +cv-orange_ops 0:44aaa523dc2c 0.92 languages:english(native) +cv-orange_ops 0:724cc536a24c 0.96 drivinglicence:fullukdrivinglice +cv-orange_ops 0:9f0e43069d3f 0.98 interests:strengthtraining,motor cv-panel 0:8f1010120ed6 0.42 jordanrivera cv-panel 0:2d58f0cadc2d 0.20 platformengineer cv-panel 0:89ff05847e96 0.20 london,uk|+442055551000 diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv index 4f4c20644..35f57b959 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv @@ -16,7 +16,7 @@ cv-modern_professional 2 2 61 59 0.20 0.77 5 cv-monogram_sidebar 1 1 53 50 0.79 2.95 17 cv-navy_sidebar 1 1 71 69 0.13 0.22 0 cv-nordic_clean 1 1 63 58 0.22 0.61 0 -cv-orange_ops 1 2 108 6 3.95 3.95 5 +cv-orange_ops 1 1 108 99 0.42 0.71 2 cv-panel 1 1 79 73 0.43 0.94 0 cv-professional_sidebar 1 1 68 64 0.11 0.27 0 cv-serif_headline 1 1 103 103 0.51 1.05 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 ffff79263..e9af8186a 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 @@ -313,6 +313,22 @@ public final class DocxSemanticBackend implements SemanticBackend { /** Space the last body paragraph holds below itself, not yet written — see {@link #owePendingSpacingAfter}. */ private double pendingSpacingAfter; + /** + * How far the paragraph just written pulls the next one up into itself, by a negative bottom + * edge, in points: taken off the space written above the next paragraph, as the page sums + * the two edges. Where no paragraph takes it — before a table, at a cell's end, a page break + * or the document's end — it comes out of the space owed below instead. A pull no space can + * give stays unwritten, as an edge Word cannot write. Layers that overlap on the page take + * none of it from one another: the space between them is measured from their boxes. + */ + private double pullBelow; + + /** What of {@link #pullBelow} the space owed above a paragraph did not give, for its own top edge. */ + private double pullLeft; + + /** The paragraph {@link #pullLeft} is left for. */ + private XWPFParagraph pullLeftOn; + /** * How far the bottom border of the panel just written stands below its box beyond the space * the panel holds under itself, see {@link #writePanelPiece}. The next panel, paragraph or @@ -736,6 +752,9 @@ private void beginSection(SemanticSection section, int index) { } carriedSpacingBefore = 0; pendingSpacingAfter = 0; + pullBelow = 0; + pullLeft = 0; + pullLeftOn = null; borderBelow = 0; pendingItemSpacing = 0; anItemWasWritten = false; @@ -3999,8 +4018,13 @@ private XWPFParagraph newBodyParagraph(XWPFDocument document) { // Everything owed above this paragraph — the space the one before it holds below // itself, and any container edge — is written here, on one side of the gap. // A card's border standing below the card took as much of the gap (writePanelPiece). - double above = Math.max(0, carriedSpacingBefore + pendingSpacingAfter - borderBelow); + double owed = carriedSpacingBefore + pendingSpacingAfter - borderBelow; + double above = Math.max(0, owed - pullBelow); + // What the space owed cannot give of the pull, the paragraph's own top edge gives. + pullLeft = Math.max(0, pullBelow - Math.max(0, owed)); + pullLeftOn = pullLeft > 0 ? para : null; borderBelow = 0; + pullBelow = 0; // Text that hung below the band above this paragraph already took that much of the // gap (see writeLinePair); whatever the owed space cannot give back, the paragraph's // own margin gives (applyVerticalSpacing). @@ -4038,6 +4062,7 @@ private XWPFParagraph newBodyParagraph(XWPFDocument document) { private XWPFParagraph dropTheSpaceAtTheEnd(XWPFDocument document) { pendingSpacingAfter = 0; carriedSpacingBefore = 0; + pullBelow = 0; List body = document.getBodyElements(); if (!body.isEmpty() && body.get(body.size() - 1) instanceof XWPFTable table) { dropTheSpaceBelow(table); @@ -4499,10 +4524,13 @@ private void owePendingSpacingAfter(double points) { /** Writes the owed space onto the paragraph that owes it, for want of a later one. */ private void flushSpacingAfter() { - if (pendingSpacingAfter > 0 && lastBodyParagraph != null) { - addSpacing(lastBodyParagraph, 0, pendingSpacingAfter); + // A pull out of the paragraph above comes out of what it owes below, as the page sums them. + double owed = pendingSpacingAfter - pullBelow; + if (owed > 0 && lastBodyParagraph != null) { + addSpacing(lastBodyParagraph, 0, owed); } pendingSpacingAfter = 0; + pullBelow = 0; } /** @@ -4633,6 +4661,15 @@ private void measureAtWordsSize(XWPFParagraph para, ParagraphNode node, double r /** How far inside the page's measure, grown or shrunk as Word sets it, Word's is held, in points. */ private static final double WORDS_MEASURE_CLEARANCE = 1; + /** + * The share wider than the page sets it a line of one is given room for: Word sets a face + * other than the page's, or emboldens one, a little wider. {@code OrangeOps}' headings are + * Oswald SemiBold, a face whose file does not say it is bold; Word set "ACHIEVEMENTS" about + * 1% wider, past the box it fills, and broke it onto a second line — three such headings + * put the CV on two pages. + */ + private static final double ONE_LINE_FACE_SLACK = 0.03; + /** * The measure Word is to set a paragraph's text in, in points: the page's, as much wider or * narrower as Word sets the line that grows most, a point short of that in a paragraph of @@ -4652,8 +4689,10 @@ private void measureAtWordsSize(XWPFParagraph para, ParagraphNode node, double r * still has a point to spare, which wins where the two meet. A paragraph of one line broke * no word: it keeps its measure or the share it grows by, whichever is wider, so a line as * wide as its column is never narrowed onto two — {@code OrangeOps}' phone number broke in - * LibreOffice a point narrower. Without the page's lines — an export with no layout — the - * paragraph's runs are weighed by their letters.

+ * LibreOffice a point narrower — and room for its line a few hundredths wider + * ({@link #ONE_LINE_FACE_SLACK}), for a face Word sets wider than the page. Without the + * page's lines — an export with no layout — the paragraph's runs are weighed by their + * letters.

*/ private double wordsMeasure(ParagraphNode node, double room) { double share = Double.NaN; @@ -4686,13 +4725,14 @@ private double wordsMeasure(ParagraphNode node, double room) { if (Double.isNaN(share)) { return room * lettersShare(node); } + if (broken < 1) { + // One line breaks no word to keep out, and broken in Word it is a line more. + return Math.max(room, Math.max(room * share, fits * (1 + ONE_LINE_FACE_SLACK))); + } if (Math.abs(share - 1) < 1e-9) { // Word sets every line at the page's size: the page's measure is Word's. return room; } - if (broken < 1) { - return Math.max(room, Math.max(room * share, fits)); - } return Math.max(room * share - WORDS_MEASURE_CLEARANCE, fits + WORDS_MEASURE_CLEARANCE); } @@ -5333,8 +5373,20 @@ private void applyVerticalSpacing(XWPFParagraph target, DocumentNode source) { } hangingOver = null; hangingOverBy = 0; + if (target == pullLeftOn) { + before = Math.max(0, before - pullLeft); + } + pullLeftOn = null; + pullLeft = 0; addSpacing(target, before, 0); - owePendingSpacingAfter(source.margin().bottom() + source.padding().bottom()); + double below = source.margin().bottom() + source.padding().bottom(); + // OrangeOps' role bar is pulled 3.8pt up into its name's line; dropped, the pull set + // the bar and the whole page under it that much low in Word, and onto a second page. + if (below < 0) { + pullBelow -= below; + } else { + owePendingSpacingAfter(below); + } } /** @@ -7977,6 +8029,7 @@ private void drawLayersOverTheColumns(XWPFDocument document, List double carried = carriedSpacingBefore; double owed = pendingSpacingAfter; double hanging = hangingBelow; + double pull = pullBelow; double outerLeft = insetLeft; double outerRight = insetRight; overlayDepth++; @@ -7993,6 +8046,7 @@ private void drawLayersOverTheColumns(XWPFDocument document, List carriedSpacingBefore = carried; pendingSpacingAfter = owed; hangingBelow = hanging; + pullBelow = pull; insetLeft = outerLeft; insetRight = outerRight; } @@ -8070,6 +8124,7 @@ private void writeLayerColumns(XWPFDocument document, // What the layers above still owe below themselves is space // the page does not have: the gap to this one is the page's. pendingSpacingAfter = 0; + pullBelow = 0; resumeSpacing = plan.resume(node); } writeChildren(document, node.children(), spacingOf(node)); @@ -8154,6 +8209,7 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay if (index > 0 && !Double.isNaN(resume) && Double.isNaN(resumeSpacing)) { pendingSpacingAfter = 0; carriedSpacingBefore = 0; + pullBelow = 0; resumeSpacing = resume; } double bandLeft = insetLeft; @@ -8184,6 +8240,8 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay // The band measures its space below from its lowest text, a nested band's included: what // a band inside it left hanging is already in that number, and is not taken twice. double below = unwritten + band.below() + stack.margin().bottom(); + // The band measures that from its boxes on the page, a layer's pull already in it. + pullBelow = 0; pendingSpacingAfter = Math.max(0, below); hangingBelow = below < 0 ? -below : 0; } @@ -9363,6 +9421,7 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti XWPFParagraph previousParagraph = lastBodyParagraph; double previousCarried = carriedSpacingBefore; double previousOwed = pendingSpacingAfter; + double previousPull = pullBelow; double previousBorderBelow = borderBelow; double previousHangingBelow = hangingBelow; XWPFParagraph previousHangingOver = hangingOver; @@ -9376,6 +9435,7 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti tableCloser = null; carriedSpacingBefore = 0; pendingSpacingAfter = 0; + pullBelow = 0; borderBelow = 0; forgetTheHang(); // A cell's content is measured from the cell's own edge, which its margins already @@ -9402,6 +9462,7 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti lastBodyParagraph = previousParagraph; carriedSpacingBefore = previousCarried; pendingSpacingAfter = previousOwed; + pullBelow = previousPull; // A card's border below the last thing in a cell stands below the row too, where // Word makes the row as tall as its tallest cell; the cell beside it starts clear. borderBelow = Math.max(previousBorderBelow, borderBelow); @@ -9508,6 +9569,12 @@ private void writeSpacer(XWPFDocument document, SpacerNode node) throws Exceptio height = Math.max(0, height - hangingOverBy); forgetTheHang(); } + // So does a pull out of the paragraph above that the space above it did not give. + if (pullLeftOn == para) { + height = Math.max(0, height - pullLeft); + pullLeftOn = null; + pullLeft = 0; + } owePendingSpacingAfter(height); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverlayBandTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverlayBandTest.java index a8d761c2c..c8db032f2 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverlayBandTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxOverlayBandTest.java @@ -196,6 +196,67 @@ void aCellOfSpaceAndDrawingKeepsItsHeight() throws Exception { } } + @Test + void aLayersNegativeBottomEdgePullsNothingOutOfTheBand() throws Exception { + // The layers overlap on the page: a pull out of one's foot moves neither the next layer + // nor what follows the band, which the page measures from their boxes. + assertThat(beforesWithLayerBottoms(-4)).isEqualTo(beforesWithLayerBottoms(0)); + } + + @Test + void theLastLayersNegativeBottomEdgeIsTakenOnceBelowTheBand() throws Exception { + // The band measures the space below it from that layer's box: the text running 4pt past + // the stack's foot takes 4pt off the 10pt above the next paragraph, and no more. + DocumentSession session = GraphCompose.document().pageSize(300, 500).margin(DocumentInsets.of(20)).create(); + session.pageFlow(page -> page + .addLayerStack(stack -> stack.name("Stack") + .back(new SpacerNode("Space", 200, BADGE, DocumentInsets.zero(), DocumentInsets.zero())) + .layer(new ParagraphBuilder().name("Low").text("Low") + .margin(new DocumentInsets(0, 0, -4, 0)).build(), LayerAlign.BOTTOM_LEFT)) + .addParagraph(p -> p.text("Below").margin(DocumentInsets.top(10)))); + try (session; XWPFDocument document = new XWPFDocument(new ByteArrayInputStream( + session.export(new DocxSemanticBackend())))) { + XWPFParagraph below = document.getParagraphs().stream() + .filter(paragraph -> "Below".equals(paragraph.getText())).findFirst().orElseThrow(); + + assertThat(before(below)).isEqualTo((10 - 4) * 20L); + } + } + + @Test + void aLayersNegativeBottomEdgeInAShapeContainerMovesNothingBelowIt() throws Exception { + // The outline is its own size; the layer's shorter box is already in the space below it. + assertThat(beforesWithLayerBottoms(-4).get("After")).isEqualTo(beforesWithLayerBottoms(0).get("After")); + } + + private static java.util.Map beforesWithLayerBottoms(double bottom) throws Exception { + DocumentInsets edge = new DocumentInsets(0, 0, bottom, 0); + DocumentSession session = GraphCompose.document().pageSize(300, 500).margin(DocumentInsets.of(20)).create(); + session.pageFlow(page -> page + .addParagraph(p -> p.text("Above")) + .addLayerStack(stack -> stack.name("Stack") + .back(new SpacerNode("Space", 200, BADGE, DocumentInsets.zero(), DocumentInsets.zero())) + .layer(new ParagraphBuilder().name("Top").text("Top").margin(edge).build(), LayerAlign.TOP_LEFT) + .layer(new ParagraphBuilder().name("Low").text("Low").build(), LayerAlign.BOTTOM_LEFT)) + .addParagraph(p -> p.text("Below")) + .add(new com.demcha.compose.document.dsl.ShapeContainerBuilder() + .name("Header").rectangle(240, 60) + .clipPolicy(com.demcha.compose.document.style.ClipPolicy.OVERFLOW_VISIBLE) + .position(new ParagraphBuilder().name("Title").text("TITLE").margin(edge).build(), + 0, 0, LayerAlign.TOP_LEFT) + .build()) + .addParagraph(p -> p.text("After"))); + try (session; XWPFDocument document = new XWPFDocument(new ByteArrayInputStream( + session.export(new DocxSemanticBackend())))) { + java.util.Map befores = new java.util.LinkedHashMap<>(); + for (XWPFParagraph paragraph : document.getParagraphs()) { + befores.put(paragraph.getText(), before(paragraph)); + } + assertThat(befores).containsKeys("Low", "Below", "After"); + return befores; + } + } + private static long before(XWPFParagraph paragraph) { CTPPr properties = paragraph.getCTP().getPPr(); if (properties == null || !properties.isSetSpacing() || !properties.getSpacing().isSetBefore()) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelInsetTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelInsetTest.java index 0da68a562..41518b910 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelInsetTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelInsetTest.java @@ -33,7 +33,9 @@ void aPaddedPanelsTextIsHeldInByTheCellNotByAnIndent() throws Exception { CTPPr properties = cellParagraph(document, "Card title").getCTP().getPPr(); var margins = document.getTables().get(0).getRow(0).getCell(0).getCTTc().getTcPr().getTcMar(); - assertThat(properties == null || !properties.isSetInd()) + // A line of one as wide as its panel is given room past it on the right, and none + // of the padding. + assertThat(addsNoPadding(properties)) .as("the padding is the cell's margin; the text adds nothing to it") .isTrue(); assertThat(DocxTwips.of(margins.getLeft().getW())) @@ -170,6 +172,15 @@ void aListInAPanelStartsFromTheCellsEdge() throws Exception { } } + /** No left indent, and a right one only past the box: room for the line, none of the padding. */ + private static boolean addsNoPadding(CTPPr properties) { + if (properties == null || !properties.isSetInd()) { + return true; + } + var indent = properties.getInd(); + return !indent.isSetLeft() && (!indent.isSetRight() || DocxTwips.of(indent.getRight()) <= 0); + } + private static long firstCellMarginTwips(org.apache.poi.xwpf.usermodel.XWPFTable table) { var cell = table.getRow(0).getCell(0).getCTTc().getTcPr(); return DocxTwips.of(cell.getTcMar().getLeft().getW()); @@ -205,7 +216,7 @@ void aCellInsideAPaddedPanelStartsFromItsOwnEdge() throws Exception { XWPFParagraph inCell = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0); CTPPr properties = inCell.getCTP().getPPr(); - assertThat(properties == null || !properties.isSetInd()) + assertThat(addsNoPadding(properties)) .as("the cell's own margin keeps it clear; the panel around the table adds nothing") .isTrue(); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSpacingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSpacingTest.java index 387c7d091..6a529c86c 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSpacingTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSpacingTest.java @@ -479,6 +479,72 @@ private static byte[] pngBytes() { } } + @Test + void aNegativeBottomEdgePullsTheNextParagraphUpOutOfItsContainer() throws Exception { + // OrangeOps' role bar stands 3.8pt up inside its name's line, the name in a section of + // its own: the pull is taken off the space above the next paragraph, as the page sums it. + List body = bodyOf(page -> page + .addSection(masthead -> masthead.addParagraph(p -> p.text("Name") + .margin(new DocumentInsets(0, 0, -4, 0)))) + .addParagraph(p -> p.text("Role").margin(new DocumentInsets(10, 0, 0, 0)))); + + assertThat(after(body.get(0))).as("no space below the line pulled into").isZero(); + assertThat(before(body.get(1))).isEqualTo(Math.round((10 - 4) * TWIPS_PER_POINT)); + } + + @Test + void aPullNoSpaceAboveCanGiveStaysUnwritten() throws Exception { + List body = bodyOf(page -> page + .addParagraph(p -> p.text("Name").margin(new DocumentInsets(0, 0, -4, 0))) + .addParagraph(p -> p.text("Role").margin(new DocumentInsets(2, 0, 0, 0)))); + + assertThat(before(body.get(1))).as("Word has no negative space above a paragraph").isZero(); + } + + @Test + void aPullTheSpaceOwedAboveCannotGiveComesOutOfTheParagraphsOwnEdge() throws Exception { + // 2pt of the section's padding, less the 4pt pull, then the paragraph's own 10pt: 8. + List body = bodyOf(page -> page + .addSection(masthead -> masthead.padding(DocumentInsets.bottom(2)) + .addParagraph(p -> p.text("Name").margin(new DocumentInsets(0, 0, -4, 0)))) + .addParagraph(p -> p.text("Role").margin(new DocumentInsets(10, 0, 0, 0)))); + + assertThat(after(body.get(0))).isZero(); + assertThat(before(body.get(1))).isEqualTo(Math.round((2 - 4 + 10) * TWIPS_PER_POINT)); + } + + @Test + void aPullComesOutOfTheSpaceOwedBelowBeforeATable() throws Exception { + List body = bodyOf(page -> page + .addSection(masthead -> masthead.padding(DocumentInsets.bottom(10)) + .addParagraph(p -> p.text("Name").margin(new DocumentInsets(0, 0, -4, 0)))) + .addTable(t -> t.autoColumns(1).row("Cell"))); + + assertThat(after(body.get(0))).isEqualTo(Math.round((10 - 4) * TWIPS_PER_POINT)); + } + + @Test + void aPullComesOutOfASpacerUnderIt() throws Exception { + List body = bodyOf(page -> page + .addParagraph(p -> p.text("Name").margin(new DocumentInsets(0, 0, -4, 0))) + .addSpacer(spacer -> spacer.name("Gap").width(100).height(10)) + .addParagraph(p -> p.text("After"))); + + assertThat(before(body.get(1))).isEqualTo(Math.round((10 - 4) * TWIPS_PER_POINT)); + } + + @Test + void aPullStopsAtATable() throws Exception { + List body = bodyOf(page -> page + .addParagraph(p -> p.text("Name").margin(new DocumentInsets(0, 0, -4, 0))) + .addTable(t -> t.autoColumns(1).row("Cell")) + .addParagraph(p -> p.text("After").margin(new DocumentInsets(10, 0, 0, 0)))); + + assertThat(before(body.stream().filter(p -> p.getText().equals("After")).findFirst().orElseThrow())) + .as("the paragraph after the table keeps its own edge") + .isEqualTo(Math.round(10 * TWIPS_PER_POINT)); + } + private static long before(XWPFParagraph paragraph) { CTPPr properties = paragraph.getCTP().getPPr(); if (properties == null || !properties.isSetSpacing() || !properties.getSpacing().isSetBefore()) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java index c891e5801..265a1ce4f 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java @@ -54,6 +54,22 @@ void aSizeWordStatesAsItIsLeavesTheMeasureAlone() throws Exception { assertThat(indentOf(9.5, TextAlign.LEFT, LONG)).isZero(); } + @Test + void aLineOfOneAsWideAsItsBoxIsGivenRoomPastIt() throws Exception { + // OrangeOps' headings fill their boxes; Word set them a little wider and broke them onto + // a second line. A line of one breaks no word, so it is given a few hundredths more room. + try (XWPFDocument document = DocxExports.withLayout(400, 300, 30, page -> page + .addSection(s -> s.fillColor(com.demcha.compose.document.style.DocumentColor.rgb(230, 240, 255)) + .padding(com.demcha.compose.document.style.DocumentInsets.of(14)) + .addParagraph(p -> p.text("Card title"))))) { + XWPFParagraph heading = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0); + + assertThat(rightIndent(heading)).as("a little past the box, at the right").isNegative() + .isGreaterThan(-40); + } + assertThat(indentOf(9.5, TextAlign.LEFT, SHORT)).as("a line of one with room to spare").isZero(); + } + @Test void aCentredLineIsLeftWhereThePageSetsIt() throws Exception { assertThat(indentOf(7.8, TextAlign.CENTER, LONG)).isZero();