Skip to content

Commit feeb665

Browse files
authored
fix(engine): correct PageBackgroundFill y-coordinate for partial-height bands (#75)
A page-background fill with heightRatio < 1.0 was painted from the page bottom upward instead of from the documented yRatio top edge, so a band with yRatio=0 rendered at the bottom of the page. PlacedFragment.y is the PDF-native bottom-left origin (y grows up — see PdfShapeFragmentRenderHandler), so the top-down ratios now convert via y = (1 - yRatio - heightRatio) * pageHeight. Full-page and full-height column fills resolve to y=0, unchanged, so existing fills and visual baselines are unaffected. Adds top/bottom/mid-band regression tests — partial-height placement was previously untested because every factory helper uses heightRatio=1, where top and bottom are indistinguishable.
1 parent 4fdf2cb commit feeb665

4 files changed

Lines changed: 115 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ follow semantic versioning; release dates are ISO 8601.
1212
regression baselines, and reusable `Subheadline` /
1313
`SectionHeader.flatSpacedCaps` widget support.
1414

15+
### Bug fixes
16+
17+
- **`PageBackgroundFill` y-coordinate.** A partial-height page-background
18+
fill (`heightRatio < 1.0`) was painted from the page **bottom** upward
19+
instead of from the `yRatio` top edge the API documents, so a band with
20+
`yRatio = 0` rendered at the bottom of the page. Fills now convert the
21+
top-down ratios to the PDF bottom-up origin correctly
22+
(`y = (1 - yRatio - heightRatio) * pageHeight`); full-page and
23+
full-height column fills are unchanged. Adds top-/bottom-/mid-band
24+
regression tests.
25+
1526
## v1.6.4 — 2026-05-22
1627

1728
Bug fix + structured-block patch. Adds two new public Block types —

src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,24 @@ static LayoutGraph apply(LayoutGraph base, List<PageBackgroundFill> fills) {
4545
for (int page = 0; page < base.totalPages(); page++) {
4646
for (int i = 0; i < fills.size(); i++) {
4747
PageBackgroundFill fill = fills.get(i);
48+
// Fill ratios are top-down (yRatio 0.0 = top edge) but
49+
// PlacedFragment.y is the PDF-native bottom-left origin
50+
// (y grows up — see PdfShapeFragmentRenderHandler, which
51+
// calls addRect(x, y, w, h) with (x, y) as the bottom-left).
52+
// A band occupies [yRatio, yRatio + heightRatio] measured
53+
// from the top, so its bottom edge measured from the page
54+
// bottom is (1 - yRatio - heightRatio) * pageHeight. For a
55+
// full-height fill (heightRatio 1.0) this is 0.0 — identical
56+
// to the previous behaviour, so existing full-page/column
57+
// fills are unaffected.
58+
double fragmentY =
59+
(1.0 - fill.yRatio() - fill.heightRatio()) * pageHeight;
4860
combined.add(new PlacedFragment(
4961
"@page-background[" + page + "][" + i + "]",
5062
0,
5163
page,
5264
fill.xRatio() * pageWidth,
53-
fill.yRatio() * pageHeight,
65+
fragmentY,
5466
fill.widthRatio() * pageWidth,
5567
fill.heightRatio() * pageHeight,
5668
com.demcha.compose.engine.components.style.Margin.zero(),

src/main/java/com/demcha/compose/document/api/PageBackgroundFill.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,13 @@
2929
* narrow accent column over a full-page tint.</p>
3030
*
3131
* @param xRatio 0.0 = left edge, 1.0 = right edge
32-
* @param yRatio 0.0 = top edge, 1.0 = bottom edge
32+
* @param yRatio top edge of the fill: 0.0 = page top, 1.0 = page
33+
* bottom. The fill extends downward from here by
34+
* {@code heightRatio}.
3335
* @param widthRatio width as a fraction of the canvas width (0..1]
34-
* @param heightRatio height as a fraction of the canvas height (0..1]
36+
* @param heightRatio height as a fraction of the canvas height (0..1].
37+
* Keep {@code yRatio + heightRatio <= 1.0} so the fill
38+
* stays within the page.
3539
* @param color fill color (required)
3640
*/
3741
public record PageBackgroundFill(double xRatio,

src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,91 @@ void pageBackgroundFillFactoryHelpersComputeRectsCorrectly() {
256256
.isEqualTo(new PageBackgroundFill(0.25, 0.0, 0.5, 1.0, c));
257257
}
258258

259+
// -- Partial-height band placement (y-coordinate regression) ---------
260+
// yRatio is top-down (0.0 = page top) but PlacedFragment.y is PDF
261+
// bottom-up, so a partial band must convert via
262+
// (1 - yRatio - heightRatio) * pageHeight. Pre-fix these collapsed to
263+
// y == 0 because every factory used heightRatio == 1; these assert real
264+
// partial bands land at the correct vertical position.
265+
266+
@Test
267+
void topBandAppearsAtTopOfPage() {
268+
DocumentColor band = DocumentColor.of(Color.DARK_GRAY);
269+
try (DocumentSession session = GraphCompose.document()
270+
.pageSize(400, 300)
271+
.margin(DocumentInsets.zero())
272+
.pageBackgrounds(List.of(
273+
new PageBackgroundFill(0.0, 0.0, 1.0, 0.16, band)))
274+
.create()) {
275+
276+
session.add(new SpacerNode("Block", 200, 80,
277+
DocumentInsets.zero(), DocumentInsets.zero()));
278+
List<PlacedFragment> bg = session.layoutGraph().fragments().stream()
279+
.filter(this::isPageBackgroundFragment)
280+
.toList();
281+
282+
assertThat(bg).hasSize(1);
283+
// yRatio 0 = page top → bottom-left at (1 - 0 - 0.16) * 300 = 252,
284+
// NOT 0 (the bottom, which was the pre-fix behaviour).
285+
assertThat(bg.get(0).y()).isCloseTo(252.0, within(EPS));
286+
assertThat(bg.get(0).height()).isCloseTo(48.0, within(EPS));
287+
} catch (Exception e) {
288+
throw new RuntimeException(e);
289+
}
290+
}
291+
292+
@Test
293+
void bottomBandAppearsAtBottomOfPage() {
294+
DocumentColor band = DocumentColor.of(Color.DARK_GRAY);
295+
try (DocumentSession session = GraphCompose.document()
296+
.pageSize(400, 300)
297+
.margin(DocumentInsets.zero())
298+
.pageBackgrounds(List.of(
299+
new PageBackgroundFill(0.0, 0.84, 1.0, 0.16, band)))
300+
.create()) {
301+
302+
session.add(new SpacerNode("Block", 200, 80,
303+
DocumentInsets.zero(), DocumentInsets.zero()));
304+
List<PlacedFragment> bg = session.layoutGraph().fragments().stream()
305+
.filter(this::isPageBackgroundFragment)
306+
.toList();
307+
308+
assertThat(bg).hasSize(1);
309+
// yRatio 0.84 + heightRatio 0.16 = 1.0 → bottom edge flush with
310+
// the page bottom: (1 - 0.84 - 0.16) * 300 = 0.
311+
assertThat(bg.get(0).y()).isCloseTo(0.0, within(EPS));
312+
assertThat(bg.get(0).height()).isCloseTo(48.0, within(EPS));
313+
} catch (Exception e) {
314+
throw new RuntimeException(e);
315+
}
316+
}
317+
318+
@Test
319+
void midPageBandLandsAtCorrectVerticalPosition() {
320+
DocumentColor band = DocumentColor.of(Color.DARK_GRAY);
321+
try (DocumentSession session = GraphCompose.document()
322+
.pageSize(400, 300)
323+
.margin(DocumentInsets.zero())
324+
.pageBackgrounds(List.of(
325+
new PageBackgroundFill(0.0, 0.4, 1.0, 0.2, band)))
326+
.create()) {
327+
328+
session.add(new SpacerNode("Block", 200, 80,
329+
DocumentInsets.zero(), DocumentInsets.zero()));
330+
List<PlacedFragment> bg = session.layoutGraph().fragments().stream()
331+
.filter(this::isPageBackgroundFragment)
332+
.toList();
333+
334+
assertThat(bg).hasSize(1);
335+
// Band spanning 40%..60% from the top → bottom-left at
336+
// (1 - 0.4 - 0.2) * 300 = 120, height 0.2 * 300 = 60.
337+
assertThat(bg.get(0).y()).isCloseTo(120.0, within(EPS));
338+
assertThat(bg.get(0).height()).isCloseTo(60.0, within(EPS));
339+
} catch (Exception e) {
340+
throw new RuntimeException(e);
341+
}
342+
}
343+
259344
private boolean isPageBackgroundFragment(PlacedFragment fragment) {
260345
return fragment.payload() instanceof ShapeFragmentPayload payload
261346
&& payload.fillColor() != null

0 commit comments

Comments
 (0)