diff --git a/CHANGELOG.md b/CHANGELOG.md index a34e704d..976fa343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ follow semantic versioning; release dates are ISO 8601. regression baselines, and reusable `Subheadline` / `SectionHeader.flatSpacedCaps` widget support. +### Bug fixes + +- **`PageBackgroundFill` y-coordinate.** A partial-height page-background + fill (`heightRatio < 1.0`) was painted from the page **bottom** upward + instead of from the `yRatio` top edge the API documents, so a band with + `yRatio = 0` rendered at the bottom of the page. Fills now convert the + top-down ratios to the PDF bottom-up origin correctly + (`y = (1 - yRatio - heightRatio) * pageHeight`); full-page and + full-height column fills are unchanged. Adds top-/bottom-/mid-band + regression tests. + ## v1.6.4 — 2026-05-22 Bug fix + structured-block patch. Adds two new public Block types — diff --git a/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java b/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java index 5c964690..68f0c43c 100644 --- a/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java +++ b/src/main/java/com/demcha/compose/document/api/DocumentPageBackgrounds.java @@ -45,12 +45,24 @@ static LayoutGraph apply(LayoutGraph base, List fills) { for (int page = 0; page < base.totalPages(); page++) { for (int i = 0; i < fills.size(); i++) { PageBackgroundFill fill = fills.get(i); + // Fill ratios are top-down (yRatio 0.0 = top edge) but + // PlacedFragment.y is the PDF-native bottom-left origin + // (y grows up — see PdfShapeFragmentRenderHandler, which + // calls addRect(x, y, w, h) with (x, y) as the bottom-left). + // A band occupies [yRatio, yRatio + heightRatio] measured + // from the top, so its bottom edge measured from the page + // bottom is (1 - yRatio - heightRatio) * pageHeight. For a + // full-height fill (heightRatio 1.0) this is 0.0 — identical + // to the previous behaviour, so existing full-page/column + // fills are unaffected. + double fragmentY = + (1.0 - fill.yRatio() - fill.heightRatio()) * pageHeight; combined.add(new PlacedFragment( "@page-background[" + page + "][" + i + "]", 0, page, fill.xRatio() * pageWidth, - fill.yRatio() * pageHeight, + fragmentY, fill.widthRatio() * pageWidth, fill.heightRatio() * pageHeight, com.demcha.compose.engine.components.style.Margin.zero(), diff --git a/src/main/java/com/demcha/compose/document/api/PageBackgroundFill.java b/src/main/java/com/demcha/compose/document/api/PageBackgroundFill.java index 3d7ae990..8b73efc3 100644 --- a/src/main/java/com/demcha/compose/document/api/PageBackgroundFill.java +++ b/src/main/java/com/demcha/compose/document/api/PageBackgroundFill.java @@ -29,9 +29,13 @@ * narrow accent column over a full-page tint.

* * @param xRatio 0.0 = left edge, 1.0 = right edge - * @param yRatio 0.0 = top edge, 1.0 = bottom edge + * @param yRatio top edge of the fill: 0.0 = page top, 1.0 = page + * bottom. The fill extends downward from here by + * {@code heightRatio}. * @param widthRatio width as a fraction of the canvas width (0..1] - * @param heightRatio height as a fraction of the canvas height (0..1] + * @param heightRatio height as a fraction of the canvas height (0..1]. + * Keep {@code yRatio + heightRatio <= 1.0} so the fill + * stays within the page. * @param color fill color (required) */ public record PageBackgroundFill(double xRatio, diff --git a/src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java b/src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java index 6cb8c6ee..b4cae9b2 100644 --- a/src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java +++ b/src/test/java/com/demcha/compose/document/api/PageBackgroundTest.java @@ -256,6 +256,91 @@ void pageBackgroundFillFactoryHelpersComputeRectsCorrectly() { .isEqualTo(new PageBackgroundFill(0.25, 0.0, 0.5, 1.0, c)); } + // -- Partial-height band placement (y-coordinate regression) --------- + // yRatio is top-down (0.0 = page top) but PlacedFragment.y is PDF + // bottom-up, so a partial band must convert via + // (1 - yRatio - heightRatio) * pageHeight. Pre-fix these collapsed to + // y == 0 because every factory used heightRatio == 1; these assert real + // partial bands land at the correct vertical position. + + @Test + void topBandAppearsAtTopOfPage() { + DocumentColor band = DocumentColor.of(Color.DARK_GRAY); + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 300) + .margin(DocumentInsets.zero()) + .pageBackgrounds(List.of( + new PageBackgroundFill(0.0, 0.0, 1.0, 0.16, band))) + .create()) { + + session.add(new SpacerNode("Block", 200, 80, + DocumentInsets.zero(), DocumentInsets.zero())); + List bg = session.layoutGraph().fragments().stream() + .filter(this::isPageBackgroundFragment) + .toList(); + + assertThat(bg).hasSize(1); + // yRatio 0 = page top → bottom-left at (1 - 0 - 0.16) * 300 = 252, + // NOT 0 (the bottom, which was the pre-fix behaviour). + assertThat(bg.get(0).y()).isCloseTo(252.0, within(EPS)); + assertThat(bg.get(0).height()).isCloseTo(48.0, within(EPS)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void bottomBandAppearsAtBottomOfPage() { + DocumentColor band = DocumentColor.of(Color.DARK_GRAY); + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 300) + .margin(DocumentInsets.zero()) + .pageBackgrounds(List.of( + new PageBackgroundFill(0.0, 0.84, 1.0, 0.16, band))) + .create()) { + + session.add(new SpacerNode("Block", 200, 80, + DocumentInsets.zero(), DocumentInsets.zero())); + List bg = session.layoutGraph().fragments().stream() + .filter(this::isPageBackgroundFragment) + .toList(); + + assertThat(bg).hasSize(1); + // yRatio 0.84 + heightRatio 0.16 = 1.0 → bottom edge flush with + // the page bottom: (1 - 0.84 - 0.16) * 300 = 0. + assertThat(bg.get(0).y()).isCloseTo(0.0, within(EPS)); + assertThat(bg.get(0).height()).isCloseTo(48.0, within(EPS)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void midPageBandLandsAtCorrectVerticalPosition() { + DocumentColor band = DocumentColor.of(Color.DARK_GRAY); + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 300) + .margin(DocumentInsets.zero()) + .pageBackgrounds(List.of( + new PageBackgroundFill(0.0, 0.4, 1.0, 0.2, band))) + .create()) { + + session.add(new SpacerNode("Block", 200, 80, + DocumentInsets.zero(), DocumentInsets.zero())); + List bg = session.layoutGraph().fragments().stream() + .filter(this::isPageBackgroundFragment) + .toList(); + + assertThat(bg).hasSize(1); + // Band spanning 40%..60% from the top → bottom-left at + // (1 - 0.4 - 0.2) * 300 = 120, height 0.2 * 300 = 60. + assertThat(bg.get(0).y()).isCloseTo(120.0, within(EPS)); + assertThat(bg.get(0).height()).isCloseTo(60.0, within(EPS)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + private boolean isPageBackgroundFragment(PlacedFragment fragment) { return fragment.payload() instanceof ShapeFragmentPayload payload && payload.fillColor() != null