Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/build-natives.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions lib/src/main/java/dev/foliopdf/Document.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
}
Expand Down
19 changes: 19 additions & 0 deletions lib/src/main/java/dev/foliopdf/Font.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
50 changes: 50 additions & 0 deletions lib/src/main/java/dev/foliopdf/Paragraph.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
87 changes: 87 additions & 0 deletions lib/src/main/java/dev/foliopdf/internal/FolioNative.java
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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));

Expand Down
Binary file modified lib/src/main/resources/natives/linux-aarch64/libfolio.so
Binary file not shown.
Binary file modified lib/src/main/resources/natives/linux-x86_64/libfolio.so
Binary file not shown.
Binary file modified lib/src/main/resources/natives/macos-aarch64/libfolio.dylib
Binary file not shown.
Binary file modified lib/src/main/resources/natives/macos-x86_64/libfolio.dylib
Binary file not shown.
Binary file modified lib/src/main/resources/natives/windows-x86_64/folio.dll
Binary file not shown.
92 changes: 92 additions & 0 deletions lib/src/test/java/dev/foliopdf/Folio091BindingsTest.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading