diff --git a/CHANGELOG.md b/CHANGELOG.md
index f271ee31c..d8a544bbc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,20 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **Text seated off its baseline sits there in Word, and text hanging below its band takes its
+ overhang from the gap under it.** A paragraph set with `TextVerticalAlign.TOP`, `CENTER` or
+ `BOTTOM` was written on its baseline: `LumaStudioInvoice`'s "INVOICE" is set against the top
+ of a line far taller than its capitals, and stood 20pt low in Word with its rule through the
+ letters, and the lockup's "L" stood on the "&Co." set under it. Its own runs, a link's
+ included, are now raised or lowered in the line (`w:position`, added to a picture's own raise)
+ by the PDF backend's seating correction, from the fonts the layout measured with — one shift
+ for the paragraph, where the page seats each line by its own. A band whose lowest written block
+ runs past its foot — that title line is 13pt deeper than its title block — gave the overhang no
+ room at all, and the invoice's details and everything under them stood that much lower, its
+ notes on a second page; the overhang now comes out of the gap under the band, as a
+ title-and-dates line's already did, and once only when the band holds another. In Word
+ `LumaStudioInvoice` fits on one page, its median drift falling from 14.5pt to 1.2 and its p90
+ from 15.4 to 2.1; its long variant's from 8.5 to 1.9 and from 15.3 to 5.8.
- **Text laid over the flow stands where the page sets it in Word.** A layer stack or a shape
container the page gives no room — its margins take back its whole height — was drawn where the
page puts it, but its text was written in the flow. `LumaStudioInvoice`'s sidebar is pulled up
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index cebf221dd..8a0170f81 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -62,7 +62,7 @@ Payload records live in `core` under
| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
-| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's |
+| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own |
| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding that widens the run on the page are not in the file, and the export records both. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; its description is the text it stands for or empty) |
diff --git a/docs/architecture/package-map.md b/docs/architecture/package-map.md
index 67602848d..ceaf99fdd 100644
--- a/docs/architecture/package-map.md
+++ b/docs/architecture/package-map.md
@@ -24,7 +24,7 @@ ships them differs.
| `graph-compose-core` | The lean engine: `com.demcha.compose`, the canonical `document.*` authoring surface (`api` / `dsl` / `node` / `style` / `table` / `snapshot`), `document.showcase` (`FontShowcase`), the `document.backend.fixed` SPI seam, the public `document.backend.fixed.pdf.options` records, `document.layout`, `font.*`, and the internal `engine.*` foundation. |
| `graph-compose-render-pdf` | The PDFBox backend: `document.backend.fixed.pdf.**` (the `PdfFixedLayoutBackend` impl + handlers) and the `engine.render.pdf.**` render tree. Registers the PDF `FixedLayoutBackendProvider` / `FontMetricsProvider`. |
| `graph-compose-render-pptx` | The POI XSLF backend: `document.backend.fixed.pptx.**` (the `PptxFixedLayoutBackend` impl + handlers), registering the `"pptx"` `FixedLayoutBackendProvider`. Also carries the older `document.backend.semantic.pptx` manifest exporter. Depends on `graph-compose-render-pdf` for shared font measurement and the clip raster pass. |
-| `graph-compose-render-docx` | The POI semantic exporter — `document.backend.semantic.docx`. Brings `graph-compose-render-pdf` at compile scope, because opening a session needs its font-metrics provider and a barcode is drawn with its matrix encoder. |
+| `graph-compose-render-docx` | The POI semantic exporter — `document.backend.semantic.docx`. Brings `graph-compose-render-pdf` at compile scope, because opening a session needs its font-metrics provider, a barcode is drawn with its matrix encoder, and text seated off its baseline is moved by its seating correction over the same measurement fonts. |
| `graph-compose-templates` | The built-in preset families — `document.templates.**`. |
| `graph-compose-testing` | Consumer test support — `com.demcha.compose.testing.**`. |
| `graph-compose` | Back-compat wrapper: an empty jar over `graph-compose-core` + `graph-compose-render-pdf`. |
diff --git a/knowledge/api/excluded.json b/knowledge/api/excluded.json
index 65a67cfff..8fc227e4e 100644
--- a/knowledge/api/excluded.json
+++ b/knowledge/api/excluded.json
@@ -3,7 +3,7 @@
"verifiedAgainst": "2.5.0-SNAPSHOT",
"generator": "knowledge/tools/api-surface/extract-api.mjs",
"note": "Public types and members deliberately kept out of every surface. An exclusion nobody can see is indistinguishable from a bug, so each one records why.",
- "count": 187,
+ "count": 188,
"excluded": [
{
"binaryName": "com.demcha.compose.document.backend.fixed.pdf.handlers.BarcodeMatrices",
@@ -26,6 +26,13 @@
"artifact": "graph-compose-render-pdf",
"reason": "type @Internal"
},
+ {
+ "binaryName": "com.demcha.compose.document.backend.fixed.pdf.handlers.ParagraphSeating",
+ "package": "com.demcha.compose.document.backend.fixed.pdf.handlers",
+ "kind": "class",
+ "artifact": "graph-compose-render-pdf",
+ "reason": "type @Internal"
+ },
{
"binaryName": "com.demcha.compose.document.backend.fixed.pptx.handlers.PptxChromeRenderer",
"package": "com.demcha.compose.document.backend.fixed.pptx.handlers",
diff --git a/render-docx/pom.xml b/render-docx/pom.xml
index 8cc18bbe8..9fa0864c7 100644
--- a/render-docx/pom.xml
+++ b/render-docx/pom.xml
@@ -93,7 +93,8 @@
there is no working DOCX export without it. Compile scope, as the PPTX backend
carries it: a barcode is written as a picture of the same ZXing matrix the PDF
and PPTX backends draw, through render-pdf's BarcodeMatrices, so the three
- cannot encode one symbol differently. -->
+ cannot encode one symbol differently; and text seated off its baseline is moved
+ by render-pdf's ParagraphSeating over the same measurement fonts. -->
A line's height is written as the page's; where in it the text sits is not. + * {@code LumaStudioInvoice}'s title sets "INVOICE" against the top of a line far taller + * than its capitals, and Word set it on the line's foot: 20pt low, its rule through the + * letters and everything under it lower. Its lockup's "L" stood on the "&Co." set under + * it.
+ * + *Only this paragraph's runs move — a line pair writes another's in the same Word + * paragraph — and a picture's own raise is added to, as the page moves a picture with its + * line's seated baseline. The room made for a picture in the line is its unseated reach. A + * page zone's paragraph has no laid-out lines here, and is written on its baseline.
+ * + * @param runsBefore how many runs the Word paragraph held before this one's were written + */ + private void seatInTheLine(XWPFParagraph para, ParagraphNode node, int runsBefore) { + long halfPoints = Math.round(seatShift(node) * HALF_POINTS_PER_POINT); + if (halfPoints == 0) { + return; + } + List{@code LumaStudioInvoice}'s title sets "INVOICE" against the top of a line far taller than + * its capitals ({@link TextVerticalAlign#TOP}), in a title block 13pt shorter than that line. In + * Word the word stood on the line's foot, 20pt low, with its rule through the letters, and the + * line's overhang pushed the invoice's details and everything under them lower, onto a second + * page.
+ */ +class DocxVerticalSeatTest { + + /** A face whose capitals stand below its ascent, as {@code LumaStudioInvoice}'s title's do. */ + private static final DocumentTextStyle DISPLAY = DocumentTextStyle.builder() + .fontName(com.demcha.compose.font.FontName.SPECTRAL).size(30).color(DocumentColor.BLACK).build(); + + @Test + void textSeatedOffItsBaselineIsMovedInItsLine() throws Exception { + int top = position(TextVerticalAlign.TOP); + int bottom = position(TextVerticalAlign.BOTTOM); + + assertThat(top).as("raised to the line's top").isPositive(); + assertThat(bottom).as("lowered to the line's foot").isNegative(); + assertThat(position(TextVerticalAlign.CENTER)).as("halfway between the two, the page's own midpoint") + .isCloseTo((top + bottom) / 2, org.assertj.core.data.Offset.offset(1)); + assertThat(position(TextVerticalAlign.DEFAULT)).as("on its baseline, as written before").isZero(); + } + + @Test + void aBandInsideABandHangsOnlyOnce() throws Exception { + // A title block whose line hangs below it, laid in a taller stack: the outer stack measures + // its space below from that same line, so what the inner one left hanging is not taken + // from the gap a second time — the gap is what it is with the title block tall enough. + assertThat(beforeTheStack(20)).as("the inner block too short for its line") + .isCloseTo(beforeTheStack(60), org.assertj.core.data.Offset.offset(2L)); + } + + @Test + void aLineRunningPastItsBandsFootHangsBelowIt() throws Exception { + // A title set at the top of its block, a rule drawn at the block's foot: the gap under + // the title is what the block leaves below its line, and its margin. A block shorter + // than the line leaves less than nothing, which the margin gives up. + long hanging = beforeTheDetails(20, 40); + long clear = beforeTheDetails(60, 40); + + assertThat(hanging).as("a block 40pt shorter, a gap 40pt shorter — the line's overhang taken from it") + .isCloseTo(clear - 40L * 20, org.assertj.core.data.Offset.offset(2L)); + assertThat(hanging).as("and less than the margin alone").isLessThan(40L * 20); + } + + @Test + void anOverhangDeeperThanTheBandsMarginLeavesNoGapAtAll() throws Exception { + // The line hangs further below the block than its 5pt margin reaches: the paragraph after + // it starts at once. + assertThat(beforeTheDetails(20, 5)).isZero(); + } + + @Test + void onlyTheSeatedHalfOfALineMovesInIt() throws Exception { + // A label and its value set as one line: the value is seated at its line's top, the + // label on its baseline, and the label stays there. + DocumentNode row = new ShapeContainerBuilder().name("Row") + .rectangle(300, 40).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(new ParagraphBuilder().name("Label").text("Label").build(), 0, 0, LayerAlign.CENTER_LEFT) + .position(new ParagraphBuilder().name("Value").text("Value").textStyle(DISPLAY) + .verticalAlign(TextVerticalAlign.TOP).build(), 0, 0, LayerAlign.CENTER_RIGHT) + .build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 40, page -> page.add(row))) { + XWPFParagraph line = document.getParagraphs().stream() + .filter(paragraph -> paragraph.getText().contains("Value")).findFirst().orElseThrow(); + + assertThat(line.getText()).as("one line").contains("Label"); + var label = line.getRuns().stream().filter(run -> run.text().contains("Label")).findFirst().orElseThrow(); + var value = line.getRuns().stream().filter(run -> run.text().contains("Value")).findFirst().orElseThrow(); + assertThat(positionOf(label.getCTR())).as("the label on its baseline").isZero(); + assertThat(positionOf(value.getCTR())).as("the value raised").isPositive(); + } + } + + /** The space written above the paragraph after a stack holding a title block of the given height. */ + private static long beforeTheStack(double titleBlockHeight) throws Exception { + DocumentNode titleBlock = new ShapeContainerBuilder().name("TitleBlock") + .rectangle(200, titleBlockHeight).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(new ParagraphBuilder().name("Title").text("INVOICE").textStyle(DISPLAY).build(), + 0, 0, LayerAlign.TOP_LEFT) + .position(new LineBuilder().name("TitleRule").horizontal(40).thickness(1) + .color(DocumentColor.BLACK).build(), 0, 0, LayerAlign.BOTTOM_LEFT) + .build(); + DocumentNode stack = new com.demcha.compose.document.dsl.LayerStackBuilder().name("Header") + .layer(new com.demcha.compose.document.dsl.ShapeBuilder().name("Backdrop").size(300, 90) + .fillColor(DocumentColor.rgb(230, 230, 230)).build(), LayerAlign.TOP_LEFT) + .layer(titleBlock, LayerAlign.TOP_LEFT) + .margin(new DocumentInsets(0, 0, 8, 0)) + .build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 40, page -> page + .add(stack).addParagraph("INVOICE NO."))) { + XWPFParagraph details = document.getParagraphs().stream() + .filter(paragraph -> paragraph.getText().equals("INVOICE NO.")).findFirst().orElseThrow(); + var properties = details.getCTP().getPPr(); + return properties == null || !properties.isSetSpacing() || !properties.getSpacing().isSetBefore() + ? 0 : DocxTwips.of(properties.getSpacing().getBefore()); + } + } + + @Test + void aSeatedLinksTextMovesWithIt() throws Exception { + // An internal link's runs are not among a paragraph's runs in POI; they are its text too. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 40, page -> page + .addParagraph(p -> p.text("Target").anchor("target")) + .addParagraph(p -> p.text("INVOICE").textStyle(DISPLAY).verticalAlign(TextVerticalAlign.TOP) + .linkTo("target")))) { + var link = document.getParagraphs().stream() + .filter(paragraph -> paragraph.getCTP().sizeOfHyperlinkArray() > 0).findFirst().orElseThrow() + .getCTP().getHyperlinkArray(0); + + assertThat(link.sizeOfRArray()).isPositive(); + assertThat(positionOf(link.getRArray(0))).as("the linked text raised").isPositive(); + } + } + + private static int positionOf(org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run) { + var properties = run.getRPr(); + return properties == null || properties.sizeOfPositionArray() == 0 ? 0 + : ((Number) properties.getPositionArray(0).getVal()).intValue(); + } + + /** The run position a 30pt word seated as given is written with, in half-points. */ + private static int position(TextVerticalAlign seat) throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 40, page -> page + .addParagraph(p -> p.text("INVOICE").textStyle(DISPLAY).lineSpacing(20).verticalAlign(seat)))) { + XWPFParagraph title = document.getParagraphs().stream() + .filter(paragraph -> paragraph.getText().equals("INVOICE")).findFirst().orElseThrow(); + var properties = title.getRuns().get(0).getCTR().getRPr(); + return properties == null || properties.sizeOfPositionArray() == 0 ? 0 + : ((Number) properties.getPositionArray(0).getVal()).intValue(); + } + } + + /** The space written above the paragraph after a title block of the given height and margin. */ + private static long beforeTheDetails(double blockHeight, double margin) throws Exception { + DocumentNode block = new ShapeContainerBuilder().name("TitleBlock") + .rectangle(200, blockHeight).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(new ParagraphBuilder().name("Title").text("INVOICE").textStyle(DISPLAY).build(), + 0, 0, LayerAlign.TOP_LEFT) + .position(new LineBuilder().name("TitleRule").horizontal(40).thickness(1) + .color(DocumentColor.BLACK).build(), 0, 0, LayerAlign.BOTTOM_LEFT) + .margin(new DocumentInsets(0, 0, margin, 0)) + .build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 40, page -> page + .add(block).addParagraph("INVOICE NO."))) { + XWPFParagraph details = document.getParagraphs().stream() + .filter(paragraph -> paragraph.getText().equals("INVOICE NO.")).findFirst().orElseThrow(); + var properties = details.getCTP().getPPr(); + return properties == null || !properties.isSetSpacing() || !properties.getSpacing().isSetBefore() + ? 0 : DocxTwips.of(properties.getSpacing().getBefore()); + } + } +} diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/ParagraphSeating.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/ParagraphSeating.java new file mode 100644 index 000000000..833c7a070 --- /dev/null +++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/ParagraphSeating.java @@ -0,0 +1,67 @@ +package com.demcha.compose.document.backend.fixed.pdf.handlers; + +import com.demcha.compose.document.api.Internal; +import com.demcha.compose.document.layout.payloads.ParagraphLine; +import com.demcha.compose.document.layout.payloads.ParagraphSpan; +import com.demcha.compose.document.layout.payloads.ParagraphTextSpan; +import com.demcha.compose.document.node.TextVerticalAlign; +import com.demcha.compose.engine.render.pdf.PdfFont; +import com.demcha.compose.font.FontLibrary; + +/** + * Where a paragraph's line seats its text by its cap band, off the baseline the layout gave it. + * + *Shared by the PDF and PPTX backends, which draw the line there, and the DOCX backend, which + * raises the same text in a Word line by as much — so the three cannot drift apart.
+ */ +@Internal +public final class ParagraphSeating { + + private ParagraphSeating() { + } + + /** + * Baseline correction that seats a line by its cap band within the line box, used for the + * non-default {@link TextVerticalAlign} modes. Derived purely from font metrics — no magic + * offset — so it scales with font size: + * + *The cap height is read from the line's first text span; an image-only line is left + * untouched.
+ * + * @param line the measured line + * @param fonts the fonts the line was measured with + * @param align how the paragraph seats its text + * @return points to add to the baseline Y (positive raises the text) + */ + public static double shift(ParagraphLine line, FontLibrary fonts, TextVerticalAlign align) { + for (ParagraphSpan span : line.spans()) { + if (span instanceof ParagraphTextSpan textSpan) { + PdfFont font = fonts.getFont(textSpan.textStyle().fontName(), PdfFont.class).orElse(null); + if (font == null) { + return 0.0; + } + double capHeight = font.getCapHeight(textSpan.textStyle()); + double ascent = line.textAscent(); + double descent = line.baselineOffsetFromBottom(); + double leading = Math.max(0.0, line.textLineHeight() - ascent - descent); + double capTopToBoxTop = ascent + leading - capHeight; + return switch (align) { + case TOP -> capTopToBoxTop; + case CENTER -> (capTopToBoxTop - descent) / 2.0; + case BOTTOM -> -descent; + case DEFAULT -> 0.0; + }; + } + } + return 0.0; + } +} diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java index 3ae1096d0..c09cf2793 100644 --- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java +++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java @@ -42,48 +42,6 @@ public final class PdfParagraphFragmentRenderHandler public PdfParagraphFragmentRenderHandler() { } - /** - * Baseline correction that seats a line by its cap band within the line box, - * used for the non-default {@link TextVerticalAlign} modes. Derived purely - * from font metrics — no magic offset — so it scales with font size: - * - *The cap height is read from the line's first text span; an image-only - * line is left untouched.
- * - * @return points to add to the baseline Y (positive raises the text) - */ - private static double verticalSeatShift(ParagraphLine line, FontLibrary fonts, TextVerticalAlign align) { - for (ParagraphSpan span : line.spans()) { - if (span instanceof ParagraphTextSpan textSpan) { - PdfFont font = fonts.getFont(textSpan.textStyle().fontName(), PdfFont.class).orElse(null); - if (font == null) { - return 0.0; - } - double capHeight = font.getCapHeight(textSpan.textStyle()); - double ascent = line.textAscent(); - double descent = line.baselineOffsetFromBottom(); - double leading = Math.max(0.0, line.textLineHeight() - ascent - descent); - double capTopToBoxTop = ascent + leading - capHeight; - return switch (align) { - case TOP -> capTopToBoxTop; - case CENTER -> (capTopToBoxTop - descent) / 2.0; - case BOTTOM -> -descent; - case DEFAULT -> 0.0; - }; - } - } - return 0.0; - } - private static double resolveImageBottom(ParagraphImageSpan imageSpan, double baselineY, double textAscent, @@ -350,7 +308,7 @@ public void render(PlacedFragment fragment, double baselineY = ParagraphLineGeometry.baselineY( lineTop, resolvedLineHeight, line.baselineOffsetFromBottom()); if (payload.verticalAlign() != TextVerticalAlign.DEFAULT) { - baselineY += verticalSeatShift(line, fonts, payload.verticalAlign()); + baselineY += ParagraphSeating.shift(line, fonts, payload.verticalAlign()); } double lineX = ParagraphLineGeometry.lineStartX( payload.align(), innerX, innerWidth, line.width()); diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxParagraphFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxParagraphFragmentRenderHandler.java index d3768581b..34d20a42b 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxParagraphFragmentRenderHandler.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxParagraphFragmentRenderHandler.java @@ -54,7 +54,8 @@ public void render(PlacedFragment fragment, double baselineY = ParagraphLineGeometry.baselineY( cursorTop, line.lineHeight(), line.baselineOffsetFromBottom()); if (payload.verticalAlign() != TextVerticalAlign.DEFAULT) { - baselineY += verticalSeatShift(line, fonts, payload.verticalAlign()); + baselineY += com.demcha.compose.document.backend.fixed.pdf.handlers.ParagraphSeating + .shift(line, fonts, payload.verticalAlign()); } double lineX = ParagraphLineGeometry.lineStartX( payload.align(), innerX, innerWidth, line.width()); @@ -453,28 +454,4 @@ private static PdfFont.VerticalMetrics verticalMetrics(FontLibrary fonts, return font.verticalMetrics(span.textStyle()); } - private static double verticalSeatShift(ParagraphLine line, - FontLibrary fonts, - TextVerticalAlign align) { - for (ParagraphSpan span : line.spans()) { - if (span instanceof ParagraphTextSpan textSpan) { - PdfFont font = fonts.getFont(textSpan.textStyle().fontName(), PdfFont.class).orElse(null); - if (font == null) { - return 0; - } - double capHeight = font.getCapHeight(textSpan.textStyle()); - double ascent = line.textAscent(); - double descent = line.baselineOffsetFromBottom(); - double leading = Math.max(0, line.textLineHeight() - ascent - descent); - double capTopToBoxTop = ascent + leading - capHeight; - return switch (align) { - case TOP -> capTopToBoxTop; - case CENTER -> (capTopToBoxTop - descent) / 2.0; - case BOTTOM -> -descent; - case DEFAULT -> 0; - }; - } - } - return 0; - } }