diff --git a/.github/workflows/build-natives.yml b/.github/workflows/build-natives.yml index 9d0832a..b163b7c 100644 --- a/.github/workflows/build-natives.yml +++ b/.github/workflows/build-natives.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.25' - name: Clone folio engine run: git clone --depth 1 --branch ${{ github.event.inputs.folio_ref }} https://github.com/${{ env.FOLIO_REPO }}.git folio-engine - name: Build libfolio.dylib (arm64) @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.25' - name: Clone folio engine run: git clone --depth 1 --branch ${{ github.event.inputs.folio_ref }} https://github.com/${{ env.FOLIO_REPO }}.git folio-engine - name: Build libfolio.dylib (x86_64) @@ -54,7 +54,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.25' - name: Clone folio engine run: git clone --depth 1 --branch ${{ github.event.inputs.folio_ref }} https://github.com/${{ env.FOLIO_REPO }}.git folio-engine - name: Build libfolio.so (x86_64) @@ -72,7 +72,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.25' - name: Install cross-compiler run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu - name: Clone folio engine @@ -92,7 +92,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: '1.25' - name: Install cross-compiler run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 - name: Clone folio engine diff --git a/CHANGELOG.md b/CHANGELOG.md index 73e5c37..ddb4516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-06-11 + +### Added + +- Bundled folio engine bumped **v0.7.1 → v0.9.1**, bringing the cumulative + 0.8.0 / 0.9.0 / 0.9.1 engine work into the SDK: deterministic/reproducible + output, `context`-aware render paths, untrusted-input resource guards, a typed + error taxonomy, sfnt-free font metrics with TrueType Collection loading, + GPOS / CJK / Unicode-normalization shaping fixes, and the 0.9.1 + rendering-correctness pass — `border-radius`, `@page` / `position: fixed` / + margin boxes, list pagination and multi-level CSS-counter numbering, table + `rowspan`, and a from-scratch fix of the QR encoder. See the folio + v0.8.0–v0.9.1 release notes for the full list +- `Document.Builder.language(String)` — sets the PDF catalog `/Lang` (BCP-47) + and drives TrueType-collection face selection for embedded CJK fonts +- `Font.parseForLanguage(byte[], String)` — parses a font/TTC selecting the face + that matches a BCP-47 tag (Simplified Chinese, Traditional Chinese, Japanese, + Korean), falling back to face 0 for an empty or unrecognised tag +- `Paragraph.measureLines(double)` and `Paragraph.measureHeight(double)` — + measure the wrapped line count and height at a given width without rendering +- `Paragraph.splitAfterLine(int, double)` returning `Paragraph.Split(head, tail)` + — splits a paragraph after the n-th line to fit content to a region and flow + the remainder onto the next page or column +- Native-library CI (`build-natives.yml`) now builds the engine with Go 1.25, + required by folio v0.9.1 + ## [0.2.0] - 2026-04-23 ### Added diff --git a/lib/src/main/java/dev/foliopdf/Document.java b/lib/src/main/java/dev/foliopdf/Document.java index 53b8dcc..9db496b 100644 --- a/lib/src/main/java/dev/foliopdf/Document.java +++ b/lib/src/main/java/dev/foliopdf/Document.java @@ -870,6 +870,7 @@ public static final class Builder { private PageSize pageSize; private String title; private String author; + private String language; private double marginTop = -1; private double marginRight = -1; private double marginBottom = -1; @@ -926,6 +927,19 @@ public Builder author(String author) { return this; } + /** + * Sets the document language as a BCP-47 tag (e.g. {@code "en-US"}, + * {@code "zh-CN"}). It is written to the PDF catalog {@code /Lang} entry + * and informs TrueType-collection face selection for embedded CJK fonts. + * + * @param language the BCP-47 language tag + * @return this builder, for chaining + */ + public Builder language(String language) { + this.language = language; + return this; + } + /** * Sets the page margins in points. * @@ -973,6 +987,9 @@ public Document build() { if (author != null) { FolioNative.documentSetAuthor(h, author); } + if (language != null) { + FolioNative.documentSetLanguage(h, language); + } if (marginTop >= 0) { FolioNative.documentSetMargins(h, marginTop, marginRight, marginBottom, marginLeft); } diff --git a/lib/src/main/java/dev/foliopdf/Font.java b/lib/src/main/java/dev/foliopdf/Font.java index 6e731d4..95738cb 100644 --- a/lib/src/main/java/dev/foliopdf/Font.java +++ b/lib/src/main/java/dev/foliopdf/Font.java @@ -196,6 +196,25 @@ public static Font parseTTF(byte[] data) { return new Font(h, true); } + /** + * Parses a font from raw bytes, selecting the face that matches a BCP-47 + * language tag from a TrueType Collection (e.g. {@code "zh-CN"}, {@code "ja"}, + * {@code "ko"}). For pan-CJK collections this picks the Simplified Chinese, + * Japanese, or Korean face accordingly; an empty or unrecognised tag falls back + * to face 0, matching {@link #parseTTF(byte[])}. The returned font owns its + * handle and should be closed when no longer needed. + * + * @param data the raw font (TTF/TTC) bytes + * @param lang the BCP-47 language tag used to select the face + * @return a new {@link Font} backed by the selected face + * @throws FolioException if the bytes cannot be parsed as a valid font + */ + public static Font parseForLanguage(byte[] data, String lang) { + long h = FolioNative.fontParseForLanguage(data, lang); + if (h == 0) throw new FolioException("Failed to parse font for language '" + lang + "': " + FolioNative.lastError()); + return new Font(h, true); + } + /** * Returns the native handle for this font. * diff --git a/lib/src/main/java/dev/foliopdf/Paragraph.java b/lib/src/main/java/dev/foliopdf/Paragraph.java index 6b3413a..ee479cb 100644 --- a/lib/src/main/java/dev/foliopdf/Paragraph.java +++ b/lib/src/main/java/dev/foliopdf/Paragraph.java @@ -237,6 +237,56 @@ public Paragraph setDirection(Direction direction) { return this; } + /** + * Measures how many lines this paragraph wraps to at the given width, + * without rendering it. + * + * @param maxWidth the available width in points + * @return the number of wrapped lines + * @since 0.9.1 + */ + public int measureLines(double maxWidth) { + return FolioNative.paragraphMeasureLines(handle.get(), maxWidth); + } + + /** + * Measures the rendered height of this paragraph at the given width, + * without rendering it. + * + * @param maxWidth the available width in points + * @return the height in points + * @since 0.9.1 + */ + public double measureHeight(double maxWidth) { + return FolioNative.paragraphMeasureHeight(handle.get(), maxWidth); + } + + /** + * Splits this paragraph after the n-th wrapped line at the given width, + * returning the head (first {@code n} lines) and tail (remainder) as two new + * paragraphs. Useful for fitting content to a fixed area and flowing the rest + * onto the next page or column. + * + * @param n the number of lines to keep in the head fragment + * @param maxWidth the available width in points + * @return a {@link Split} holding the head and tail paragraphs + * @since 0.9.1 + */ + public Split splitAfterLine(int n, double maxWidth) { + long[] parts = FolioNative.paragraphSplitAfterLine(handle.get(), n, maxWidth); + return new Split(new Paragraph(parts[0]), new Paragraph(parts[1])); + } + + /** + * The result of {@link #splitAfterLine(int, double)}: the {@code head} + * fragment (the kept lines) and the {@code tail} fragment (the remainder). + * Both are independent paragraphs backed by their own native handles, freed + * automatically once they become unreachable. + * + * @since 0.9.1 + */ + public record Split(Paragraph head, Paragraph tail) {} + /** * Returns the native handle for this paragraph. * diff --git a/lib/src/main/java/dev/foliopdf/internal/FolioNative.java b/lib/src/main/java/dev/foliopdf/internal/FolioNative.java index ec524cd..51f3da2 100644 --- a/lib/src/main/java/dev/foliopdf/internal/FolioNative.java +++ b/lib/src/main/java/dev/foliopdf/internal/FolioNative.java @@ -185,6 +185,22 @@ public static void documentSetAuthor(long doc, String author) { }); } + private static final MethodHandle document_set_language = downcall("folio_document_set_language", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)); + + public static void documentSetLanguage(long doc, String lang) { + lockedVoid(() -> { + try (var arena = Arena.ofConfined()) { + int rc = (int) document_set_language.invokeExact(doc, arena.allocateFrom(lang)); + checkResult(rc, "documentSetLanguage"); + } catch (FolioException e) { + throw e; + } catch (Throwable t) { + throw new FolioException("documentSetLanguage failed", t); + } + }); + } + private static final MethodHandle document_set_margins = downcall("folio_document_set_margins", FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.JAVA_DOUBLE, ValueLayout.JAVA_DOUBLE, @@ -505,6 +521,62 @@ public static long paragraphNewEmbedded(String text, long font, double fontSize) }); } + private static final MethodHandle paragraph_measure_lines = downcall("folio_paragraph_measure_lines", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.JAVA_DOUBLE)); + + public static int paragraphMeasureLines(long para, double maxWidth) { + return locked(() -> { + try { + int lines = (int) paragraph_measure_lines.invokeExact(para, maxWidth); + if (lines < 0) { + checkResult(lines, "paragraphMeasureLines"); + } + return lines; + } catch (FolioException e) { + throw e; + } catch (Throwable t) { + throw new FolioException("paragraphMeasureLines failed", t); + } + }); + } + + private static final MethodHandle paragraph_measure_height = downcall("folio_paragraph_measure_height", + FunctionDescriptor.of(ValueLayout.JAVA_DOUBLE, ValueLayout.JAVA_LONG, ValueLayout.JAVA_DOUBLE)); + + public static double paragraphMeasureHeight(long para, double maxWidth) { + return locked(() -> { + try { + return (double) paragraph_measure_height.invokeExact(para, maxWidth); + } catch (Throwable t) { + throw new FolioException("paragraphMeasureHeight failed", t); + } + }); + } + + private static final MethodHandle paragraph_split_after_line = downcall("folio_paragraph_split_after_line", + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, + ValueLayout.JAVA_DOUBLE, ValueLayout.ADDRESS, ValueLayout.ADDRESS)); + + /** + * Splits a paragraph after the n-th line at the given width, returning the + * two new paragraph handles as {@code [head, tail]}. + */ + public static long[] paragraphSplitAfterLine(long para, int n, double maxWidth) { + return locked(() -> { + try (var arena = Arena.ofConfined()) { + MemorySegment outHead = arena.allocate(ValueLayout.JAVA_LONG); + MemorySegment outTail = arena.allocate(ValueLayout.JAVA_LONG); + int rc = (int) paragraph_split_after_line.invokeExact(para, n, maxWidth, outHead, outTail); + checkResult(rc, "paragraphSplitAfterLine"); + return new long[] { outHead.get(ValueLayout.JAVA_LONG, 0), outTail.get(ValueLayout.JAVA_LONG, 0) }; + } catch (FolioException e) { + throw e; + } catch (Throwable t) { + throw new FolioException("paragraphSplitAfterLine failed", t); + } + }); + } + private static final MethodHandle paragraph_set_align = downcall("folio_paragraph_set_align", FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT)); @@ -1051,6 +1123,21 @@ public static long fontParseTTF(byte[] data) { }); } + private static final MethodHandle font_parse_for_language = downcall("folio_font_parse_for_language", + FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS, ValueLayout.JAVA_INT, ValueLayout.ADDRESS)); + + public static long fontParseForLanguage(byte[] data, String lang) { + return locked(() -> { + try (var arena = Arena.ofConfined()) { + MemorySegment seg = arena.allocate(ValueLayout.JAVA_BYTE, data.length); + MemorySegment.copy(data, 0, seg, ValueLayout.JAVA_BYTE, 0, data.length); + return (long) font_parse_for_language.invokeExact(seg, data.length, arena.allocateFrom(lang)); + } catch (Throwable t) { + throw new FolioException("fontParseForLanguage failed", t); + } + }); + } + private static final MethodHandle font_free = downcall("folio_font_free", FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG)); diff --git a/lib/src/main/resources/natives/linux-aarch64/libfolio.so b/lib/src/main/resources/natives/linux-aarch64/libfolio.so index bb105e5..2477927 100644 Binary files a/lib/src/main/resources/natives/linux-aarch64/libfolio.so and b/lib/src/main/resources/natives/linux-aarch64/libfolio.so differ diff --git a/lib/src/main/resources/natives/linux-x86_64/libfolio.so b/lib/src/main/resources/natives/linux-x86_64/libfolio.so index 2aab123..da3a6a9 100644 Binary files a/lib/src/main/resources/natives/linux-x86_64/libfolio.so and b/lib/src/main/resources/natives/linux-x86_64/libfolio.so differ diff --git a/lib/src/main/resources/natives/macos-aarch64/libfolio.dylib b/lib/src/main/resources/natives/macos-aarch64/libfolio.dylib index dd74d3d..7c594d2 100644 Binary files a/lib/src/main/resources/natives/macos-aarch64/libfolio.dylib and b/lib/src/main/resources/natives/macos-aarch64/libfolio.dylib differ diff --git a/lib/src/main/resources/natives/macos-x86_64/libfolio.dylib b/lib/src/main/resources/natives/macos-x86_64/libfolio.dylib index 06a0285..24ac860 100644 Binary files a/lib/src/main/resources/natives/macos-x86_64/libfolio.dylib and b/lib/src/main/resources/natives/macos-x86_64/libfolio.dylib differ diff --git a/lib/src/main/resources/natives/windows-x86_64/folio.dll b/lib/src/main/resources/natives/windows-x86_64/folio.dll index 174baea..d1c3f9f 100644 Binary files a/lib/src/main/resources/natives/windows-x86_64/folio.dll and b/lib/src/main/resources/natives/windows-x86_64/folio.dll differ diff --git a/lib/src/test/java/dev/foliopdf/Folio091BindingsTest.java b/lib/src/test/java/dev/foliopdf/Folio091BindingsTest.java new file mode 100644 index 0000000..ea70b47 --- /dev/null +++ b/lib/src/test/java/dev/foliopdf/Folio091BindingsTest.java @@ -0,0 +1,92 @@ +package dev.foliopdf; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** Smoke tests for the folio v0.9.1 bindings added to the SDK. */ +class Folio091BindingsTest { + + @Test + void documentLanguageRendersValidPdf(@TempDir Path tempDir) throws Exception { + Path output = tempDir.resolve("lang.pdf"); + try (var doc = Document.builder() + .pageSize(PageSize.A4) + .language("zh-CN") + .build()) { + doc.add(Paragraph.of("Document with a /Lang entry.")); + doc.save(output.toString()); + } + byte[] bytes = Files.readAllBytes(output); + assertTrue(bytes.length > 100); + assertEquals("%PDF", new String(bytes, 0, 4)); + } + + @Test + void paragraphMeasureLinesAndHeight() { + // A long paragraph in a narrow column must wrap to several lines with + // positive height; the same text in a wide column wraps to fewer lines. + var p = Paragraph.of( + "This paragraph is deliberately long so that it wraps onto multiple " + + "lines when laid out in a narrow column, letting us assert the measured " + + "line count and height without rendering a page."); + + int narrowLines = p.measureLines(120); + double narrowHeight = p.measureHeight(120); + int wideLines = p.measureLines(500); + + assertTrue(narrowLines > 1, "expected multiple wrapped lines in a narrow column"); + assertTrue(narrowHeight > 0, "expected positive measured height"); + assertTrue(wideLines >= 1 && wideLines < narrowLines, + "a wider column should wrap to fewer lines"); + } + + @Test + void paragraphSplitAfterLineProducesHeadAndTail() { + var p = Paragraph.of( + "Splitting a paragraph after a fixed number of lines lets a caller fit " + + "content to a region and flow the remainder elsewhere. This text is long " + + "enough to span several lines in a narrow column so the split has a tail."); + + double width = 140; + int total = p.measureLines(width); + assertTrue(total >= 3, "fixture should wrap to at least 3 lines, got " + total); + + Paragraph.Split split = p.splitAfterLine(2, width); + assertNotNull(split.head()); + assertNotNull(split.tail()); + assertNotEquals(0, split.head().handle()); + assertNotEquals(0, split.tail().handle()); + + // The head keeps the first 2 lines; head + tail account for all lines. + assertEquals(2, split.head().measureLines(width)); + assertEquals(total, split.head().measureLines(width) + split.tail().measureLines(width)); + } + + @Test + void fontParseForLanguageSelectsAFace() throws Exception { + String fontPath = findSystemFont(); + if (fontPath == null) return; // no system font available on this runner + byte[] data = Files.readAllBytes(Path.of(fontPath)); + try (Font font = Font.parseForLanguage(data, "ja")) { + assertNotEquals(0, font.handle()); + } + } + + private static String findSystemFont() { + String[] candidates = { + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "C:\\Windows\\Fonts\\arial.ttf", + }; + for (String p : candidates) { + if (new File(p).exists()) return p; + } + return null; + } +}