From 7e79e3016357831de4b5c5b90db9c2bc231d0124 Mon Sep 17 00:00:00 2001 From: per Date: Fri, 14 Aug 2026 21:14:01 +0200 Subject: [PATCH 01/17] Fix safe library output handling --- gui/release.md | 10 ++++ .../se/alipsa/md2pdf/gui/MarkdownToPdf.java | 2 + lib/release.md | 8 ++- .../main/java/se/alipsa/md2pdf/ImageUtil.java | 3 +- .../java/se/alipsa/md2pdf/Md2PdfEngine.java | 51 ++++++++++++++++--- .../test/alipsa/md2pdf/ConcurrencyTest.java | 29 +++++++---- .../test/alipsa/md2pdf/ImageUtilTest.java | 14 +++++ .../md2pdf/LoggingConfigurationTest.java | 39 ++++++++++++++ .../java/test/alipsa/md2pdf/OutputTest.java | 17 +++++++ release.md | 3 ++ 10 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 lib/src/test/java/test/alipsa/md2pdf/LoggingConfigurationTest.java diff --git a/gui/release.md b/gui/release.md index fbd9fd8..0610392 100644 --- a/gui/release.md +++ b/gui/release.md @@ -1,6 +1,16 @@ # MarkdownToPdf GUI Release History (Note, dates are in yyyy-MM-dd format) +## 0.2.0 (2026-08-14) +- Added a pluggable file-access layer for sandboxed distributions. Stored projects are retained + when their files are temporarily inaccessible, and the application can ask to locate an + inaccessible project Markdown file and remember the new location. +- App-store distributions can disable GitHub update checks with + `-Dmd2pdf.update.enabled=false`; this also hides the related **Help** menu items. +- **View external** now lets sandboxed distributions choose where to save the rendered PDF before + opening it in the system viewer. PDF export and file dialogs also handle inaccessible or stale + initial directories more safely. + ## 0.1.1 (2026-08-11) - Added an update check: **Help > Check for Updates…** asks GitHub Releases whether a newer version ships an archive for this platform, and offers to open the release page. It also diff --git a/gui/src/main/java/se/alipsa/md2pdf/gui/MarkdownToPdf.java b/gui/src/main/java/se/alipsa/md2pdf/gui/MarkdownToPdf.java index f7c926c..a9ebf04 100644 --- a/gui/src/main/java/se/alipsa/md2pdf/gui/MarkdownToPdf.java +++ b/gui/src/main/java/se/alipsa/md2pdf/gui/MarkdownToPdf.java @@ -56,6 +56,7 @@ import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.core.Appender; import org.apache.logging.log4j.core.appender.FileAppender; +import se.alipsa.md2pdf.Md2PdfEngine; import se.alipsa.md2pdf.Md2PdfException; import se.alipsa.md2pdf.gui.fs.FileAccess; import se.alipsa.md2pdf.gui.fs.FileAccessBroker; @@ -130,6 +131,7 @@ public MarkdownToPdf() {} * @param args command-line arguments (unused) */ public static void main(String[] args) { + Md2PdfEngine.configureOpenHtmlToPdfLogging(); configureJava2dUiScale(); showStartupSplash(); try { diff --git a/lib/release.md b/lib/release.md index 07d259c..51c175f 100644 --- a/lib/release.md +++ b/lib/release.md @@ -1,6 +1,12 @@ # MarkdownToPdf Runtime Release History (Note, dates are in yyyy-MM-dd format) +## 0.2.0 (2026-08-14) +- PDF file output now preserves an existing destination if rendering fails. +- Constructing an engine no longer changes JVM-global OpenHTMLtoPDF logging; applications can + explicitly enable the provided SLF4J bridge during startup. +- Image data-URL type detection now handles uppercase file extensions consistently across locales. + ## 0.1.1 (2026-08-11) - **Breaking:** renamed `Md2PdfEngine.Job` to `Md2PdfEngine.Renderer`. Code that named the type explicitly must be updated; the fluent `engine.markdown(...)` chain is unchanged. @@ -10,4 +16,4 @@ - jsoup 1.22.2 -> 1.23.1 ## 0.1.0 (2026-05-17) -- Initial release \ No newline at end of file +- Initial release diff --git a/lib/src/main/java/se/alipsa/md2pdf/ImageUtil.java b/lib/src/main/java/se/alipsa/md2pdf/ImageUtil.java index 90540d3..86a9799 100644 --- a/lib/src/main/java/se/alipsa/md2pdf/ImageUtil.java +++ b/lib/src/main/java/se/alipsa/md2pdf/ImageUtil.java @@ -6,6 +6,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Base64; +import java.util.Locale; /** Image utilities */ public class ImageUtil { @@ -127,7 +128,7 @@ private static InputStream locateResource(String resource, Class clazz) } private static String getMediaType(String resource) { - String res = resource.toLowerCase(); + String res = resource.toLowerCase(Locale.ROOT); if (res.endsWith("png")) { return "image/png"; } diff --git a/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java b/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java index 9181a03..6292683 100644 --- a/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java +++ b/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java @@ -8,9 +8,11 @@ import java.io.*; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -144,7 +146,6 @@ public Md2PdfEngine() { } private Md2PdfEngine(Builder builder) { - XRLog.setLoggerImpl(new Slf4jXRLogger()); var parserBuilder = Parser.builder(); var rendererBuilder = HtmlRenderer.builder().softbreak(builder.softbreak); if (builder.tables) { @@ -165,6 +166,16 @@ public static Builder builder() { return new Builder(); } + /** + * Configure OpenHTMLtoPDF to route its logging through SLF4J. + * + *

OpenHTMLtoPDF logging is JVM-global, so applications should call this deliberately during + * their startup rather than having an engine instance replace an existing logger unexpectedly. + */ + public static void configureOpenHtmlToPdfLogging() { + XRLog.setLoggerImpl(new Slf4jXRLogger()); + } + /** Builder for configuring Markdown parsing and HTML rendering options. */ public static class Builder { @@ -685,14 +696,42 @@ public byte[] toPdf() throws Md2PdfException { * @throws Md2PdfException if rendering or writing fails */ public void toPdf(File file) throws Md2PdfException { - try (BufferedOutputStream fos = - new BufferedOutputStream(Files.newOutputStream(file.toPath()))) { + Path target = Objects.requireNonNull(file, "file").toPath().toAbsolutePath(); + Path temporary; + try { + temporary = Files.createTempFile(target.getParent(), ".md2pdf-", ".pdf"); + } catch (IOException e) { + throw new Md2PdfException(e); + } + try { + writePdf(temporary); + replaceFile(temporary, target); + log.debug("toPdf: Wrote {}", target); + } catch (IOException e) { + throw new Md2PdfException(e); + } finally { + try { + Files.deleteIfExists(temporary); + } catch (IOException e) { + log.warn("Failed to remove temporary PDF {}", temporary, e); + } + } + } + + private void writePdf(Path path) throws Md2PdfException, IOException { + try (BufferedOutputStream fos = new BufferedOutputStream(Files.newOutputStream(path))) { String html = buildHtml(); String xhtml = htmlToXhtml(html); xhtmlToPdf(xhtml, fos, baseUri, fonts, metadata); - log.debug("toPdf: Wrote {}", file.getAbsolutePath()); - } catch (IOException e) { - throw new Md2PdfException(e); + } + } + + private static void replaceFile(Path source, Path target) throws IOException { + try { + Files.move( + source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); } } diff --git a/lib/src/test/java/test/alipsa/md2pdf/ConcurrencyTest.java b/lib/src/test/java/test/alipsa/md2pdf/ConcurrencyTest.java index dde829a..38eec77 100644 --- a/lib/src/test/java/test/alipsa/md2pdf/ConcurrencyTest.java +++ b/lib/src/test/java/test/alipsa/md2pdf/ConcurrencyTest.java @@ -5,20 +5,23 @@ import com.openhtmltopdf.util.XRLog; import java.io.IOException; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import se.alipsa.md2pdf.Md2PdfEngine; import se.alipsa.md2pdf.Md2PdfException; public class ConcurrencyTest { + @TempDir Path tempDir; + static { // silence openhtmltopdf noice XRLog.setLevel(XRLog.LOAD, Level.WARNING); @@ -27,11 +30,11 @@ public class ConcurrencyTest { } @Test - public void testSameInstanceConcurrently() throws InterruptedException, IOException { + public void testSameInstanceConcurrently() throws Exception { Md2PdfEngine engine = new Md2PdfEngine(); - Path path1 = Paths.get("target/blue.pdf"); - Path path2 = Paths.get("target/yellow.pdf"); + Path path1 = tempDir.resolve("blue.pdf"); + Path path2 = tempDir.resolve("yellow.pdf"); String md1 = """ @@ -63,10 +66,12 @@ public void testSameInstanceConcurrently() throws InterruptedException, IOExcept throw new RuntimeException(e); } }; - executorService.submit(task1); - executorService.submit(task2); + Future future1 = executorService.submit(task1); + Future future2 = executorService.submit(task2); executorService.shutdown(); assertTrue(executorService.awaitTermination(3, TimeUnit.SECONDS)); + future1.get(); + future2.get(); assertTrue(path1.toFile().exists()); assertTrue(path2.toFile().exists()); assertTrue(extractContent(path1).contains("Blue Circle")); @@ -74,9 +79,9 @@ public void testSameInstanceConcurrently() throws InterruptedException, IOExcept } @Test - public void testDifferentInstancesConcurrently() throws InterruptedException, IOException { - Path path1 = Paths.get("target/blue2.pdf"); - Path path2 = Paths.get("target/yellow2.pdf"); + public void testDifferentInstancesConcurrently() throws Exception { + Path path1 = tempDir.resolve("blue2.pdf"); + Path path2 = tempDir.resolve("yellow2.pdf"); String md1 = """ @@ -110,10 +115,12 @@ public void testDifferentInstancesConcurrently() throws InterruptedException, IO throw new RuntimeException(e); } }; - executorService.submit(task1); - executorService.submit(task2); + Future future1 = executorService.submit(task1); + Future future2 = executorService.submit(task2); executorService.shutdown(); assertTrue(executorService.awaitTermination(3, TimeUnit.SECONDS)); + future1.get(); + future2.get(); assertTrue(path1.toFile().exists()); assertTrue(path2.toFile().exists()); assertTrue(extractContent(path1).contains("Blue Circle")); diff --git a/lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java b/lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java index 46bb658..6fd36bf 100644 --- a/lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java +++ b/lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java @@ -1,13 +1,16 @@ package test.alipsa.md2pdf; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.Locale; import org.junit.jupiter.api.Test; import se.alipsa.md2pdf.ImageUtil; +import se.alipsa.md2pdf.Md2PdfException; public class ImageUtilTest { @@ -35,4 +38,15 @@ void testAsDataUrlFromUrl() throws Exception { assertTrue(dataUrl.startsWith("data:image/png;base64,")); assertTrue(Base64.getDecoder().decode(dataUrl.substring(dataUrl.indexOf(',') + 1)).length > 0); } + + @Test + void testMediaTypeDetectionIsLocaleIndependent() { + Locale originalLocale = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertThrows(Md2PdfException.class, () -> ImageUtil.asDataUrl("/not-present.GIF")); + } finally { + Locale.setDefault(originalLocale); + } + } } diff --git a/lib/src/test/java/test/alipsa/md2pdf/LoggingConfigurationTest.java b/lib/src/test/java/test/alipsa/md2pdf/LoggingConfigurationTest.java new file mode 100644 index 0000000..42f2747 --- /dev/null +++ b/lib/src/test/java/test/alipsa/md2pdf/LoggingConfigurationTest.java @@ -0,0 +1,39 @@ +package test.alipsa.md2pdf; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; + +import com.openhtmltopdf.util.XRLog; +import com.openhtmltopdf.util.XRLogger; +import org.junit.jupiter.api.Test; +import se.alipsa.md2pdf.Md2PdfEngine; +import se.alipsa.md2pdf.Slf4jXRLogger; + +class LoggingConfigurationTest { + + @Test + void engineConstructionDoesNotReplaceConfiguredLogger() { + XRLogger original = XRLog.getLoggerImpl(); + XRLogger configured = new Slf4jXRLogger(); + XRLog.setLoggerImpl(configured); + try { + new Md2PdfEngine(); + + assertSame(configured, XRLog.getLoggerImpl()); + } finally { + XRLog.setLoggerImpl(original); + } + } + + @Test + void loggingBridgeIsEnabledExplicitly() { + XRLogger original = XRLog.getLoggerImpl(); + try { + Md2PdfEngine.configureOpenHtmlToPdfLogging(); + + assertInstanceOf(Slf4jXRLogger.class, XRLog.getLoggerImpl()); + } finally { + XRLog.setLoggerImpl(original); + } + } +} diff --git a/lib/src/test/java/test/alipsa/md2pdf/OutputTest.java b/lib/src/test/java/test/alipsa/md2pdf/OutputTest.java index 78e207d..b075afa 100644 --- a/lib/src/test/java/test/alipsa/md2pdf/OutputTest.java +++ b/lib/src/test/java/test/alipsa/md2pdf/OutputTest.java @@ -1,12 +1,15 @@ package test.alipsa.md2pdf; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.OutputStream; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import org.apache.pdfbox.Loader; @@ -15,6 +18,7 @@ import org.apache.pdfbox.pdmodel.PDResources; import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import se.alipsa.md2pdf.Md2PdfEngine; import se.alipsa.md2pdf.Md2PdfException; @@ -22,6 +26,8 @@ public class OutputTest { static Md2PdfEngine engine = new Md2PdfEngine(); + @TempDir Path tempDir; + @Test public void TestRenderToHtml() throws Md2PdfException { String md = @@ -57,6 +63,17 @@ public void testRenderToFile() throws Md2PdfException { assertTrue(file.length() > 0, "File length should be greater than 0"); } + @Test + void testFailedRenderPreservesExistingPdfFile() throws IOException { + Path output = tempDir.resolve("existing.pdf"); + Files.writeString(output, "existing document"); + + assertThrows( + NullPointerException.class, () -> engine.markdown((String) null).toPdf(output.toFile())); + + assertEquals("existing document", Files.readString(output)); + } + @Test public void testRenderToByteArray() throws Md2PdfException { String md = diff --git a/release.md b/release.md index 729526f..41132f0 100644 --- a/release.md +++ b/release.md @@ -6,6 +6,9 @@ the published modules are recorded in their own changelogs: How a release is cut is documented in [`docs/release-process.md`](docs/release-process.md). +## 0.2.0 (2026-08-14) +- No changes to the shared build, CI or release tooling. + ## 0.1.1 (2026-08-11) - Updated build tooling - spotless 3.5.1 -> 3.9.0 (google-java-format 1.22.0 -> 1.28.0) From 135b8f07b40b83434b138e33c4508e1246da1819 Mon Sep 17 00:00:00 2001 From: per Date: Fri, 14 Aug 2026 22:00:09 +0200 Subject: [PATCH 02/17] Address library output review findings --- lib/release.md | 4 +- .../java/se/alipsa/md2pdf/Md2PdfEngine.java | 57 +++++++++++++++++- .../test/alipsa/md2pdf/ImageUtilTest.java | 8 +-- .../md2pdf/LoggingConfigurationTest.java | 8 ++- .../java/test/alipsa/md2pdf/OutputTest.java | 60 +++++++++++++++++++ lib/src/test/resources/images/sample.PNG | 1 + 6 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 lib/src/test/resources/images/sample.PNG diff --git a/lib/release.md b/lib/release.md index 51c175f..3d8c112 100644 --- a/lib/release.md +++ b/lib/release.md @@ -2,7 +2,9 @@ (Note, dates are in yyyy-MM-dd format) ## 0.2.0 (2026-08-14) -- PDF file output now preserves an existing destination if rendering fails. +- PDF file output now preserves an existing destination if rendering fails, keeps existing POSIX + permissions when replacing a file, and still supports a pre-created writable destination when + its parent does not permit temporary files. - Constructing an engine no longer changes JVM-global OpenHTMLtoPDF logging; applications can explicitly enable the provided SLF4J bridge during startup. - Image data-URL type detection now handles uppercase file extensions consistently across locales. diff --git a/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java b/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java index 6292683..b673251 100644 --- a/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java +++ b/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java @@ -10,9 +10,12 @@ import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermissions; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -692,19 +695,31 @@ public byte[] toPdf() throws Md2PdfException { /** * Render the job to a PDF file. * + *

When the parent directory permits it, this renders to a temporary sibling and atomically + * replaces the destination only after success. If a pre-existing destination is writable but + * its directory does not allow temporary files, the PDF is rendered in memory and written + * directly to that file. That fallback preserves the file's attributes but cannot be atomic if + * writing the rendered bytes fails. + * * @param file the file to write the PDF to * @throws Md2PdfException if rendering or writing fails */ public void toPdf(File file) throws Md2PdfException { Path target = Objects.requireNonNull(file, "file").toPath().toAbsolutePath(); + Path parent = target.getParent(); + if (parent == null) { + throw new Md2PdfException("Cannot write a PDF to filesystem root " + target); + } Path temporary; try { - temporary = Files.createTempFile(target.getParent(), ".md2pdf-", ".pdf"); - } catch (IOException e) { - throw new Md2PdfException(e); + temporary = createTemporaryPdf(parent); + } catch (IOException temporaryFailure) { + writePdfDirectly(target, temporaryFailure); + return; } try { writePdf(temporary); + copyTargetPermissions(target, temporary); replaceFile(temporary, target); log.debug("toPdf: Wrote {}", target); } catch (IOException e) { @@ -718,6 +733,29 @@ public void toPdf(File file) throws Md2PdfException { } } + private void writePdfDirectly(Path target, IOException temporaryFailure) + throws Md2PdfException { + byte[] pdf = toPdf(); + try { + Files.write(target, pdf); + log.debug("toPdf: Wrote {} without a temporary sibling", target); + } catch (IOException directWriteFailure) { + directWriteFailure.addSuppressed(temporaryFailure); + throw new Md2PdfException(directWriteFailure); + } + } + + private static Path createTemporaryPdf(Path parent) throws IOException { + if (Files.getFileAttributeView(parent, PosixFileAttributeView.class) != null) { + return Files.createTempFile( + parent, + ".md2pdf-", + ".pdf", + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-rw-rw-"))); + } + return Files.createTempFile(parent, ".md2pdf-", ".pdf"); + } + private void writePdf(Path path) throws Md2PdfException, IOException { try (BufferedOutputStream fos = new BufferedOutputStream(Files.newOutputStream(path))) { String html = buildHtml(); @@ -735,6 +773,19 @@ private static void replaceFile(Path source, Path target) throws IOException { } } + private static void copyTargetPermissions(Path target, Path temporary) throws IOException { + PosixFileAttributeView targetAttributes = + Files.getFileAttributeView(target, PosixFileAttributeView.class); + if (targetAttributes == null) { + return; + } + try { + Files.setPosixFilePermissions(temporary, targetAttributes.readAttributes().permissions()); + } catch (NoSuchFileException ignored) { + // The destination did not exist, so the temporary file keeps the platform default mode. + } + } + /** * Render the job to a PDF at the given path. * diff --git a/lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java b/lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java index 6fd36bf..bca5184 100644 --- a/lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java +++ b/lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java @@ -1,7 +1,6 @@ package test.alipsa.md2pdf; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.URL; @@ -10,7 +9,6 @@ import java.util.Locale; import org.junit.jupiter.api.Test; import se.alipsa.md2pdf.ImageUtil; -import se.alipsa.md2pdf.Md2PdfException; public class ImageUtilTest { @@ -40,11 +38,13 @@ void testAsDataUrlFromUrl() throws Exception { } @Test - void testMediaTypeDetectionIsLocaleIndependent() { + void testMediaTypeDetectionIsLocaleIndependent() throws Exception { Locale originalLocale = Locale.getDefault(); try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); - assertThrows(Md2PdfException.class, () -> ImageUtil.asDataUrl("/not-present.GIF")); + String dataUrl = ImageUtil.asDataUrl("/images/sample.PNG"); + + assertTrue(dataUrl.startsWith("data:image/png;base64,")); } finally { Locale.setDefault(originalLocale); } diff --git a/lib/src/test/java/test/alipsa/md2pdf/LoggingConfigurationTest.java b/lib/src/test/java/test/alipsa/md2pdf/LoggingConfigurationTest.java index 42f2747..b9f4042 100644 --- a/lib/src/test/java/test/alipsa/md2pdf/LoggingConfigurationTest.java +++ b/lib/src/test/java/test/alipsa/md2pdf/LoggingConfigurationTest.java @@ -21,7 +21,9 @@ void engineConstructionDoesNotReplaceConfiguredLogger() { assertSame(configured, XRLog.getLoggerImpl()); } finally { - XRLog.setLoggerImpl(original); + if (original != null) { + XRLog.setLoggerImpl(original); + } } } @@ -33,7 +35,9 @@ void loggingBridgeIsEnabledExplicitly() { assertInstanceOf(Slf4jXRLogger.class, XRLog.getLoggerImpl()); } finally { - XRLog.setLoggerImpl(original); + if (original != null) { + XRLog.setLoggerImpl(original); + } } } } diff --git a/lib/src/test/java/test/alipsa/md2pdf/OutputTest.java b/lib/src/test/java/test/alipsa/md2pdf/OutputTest.java index b075afa..e8ea523 100644 --- a/lib/src/test/java/test/alipsa/md2pdf/OutputTest.java +++ b/lib/src/test/java/test/alipsa/md2pdf/OutputTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.io.ByteArrayOutputStream; import java.io.File; @@ -12,6 +13,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Set; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; @@ -74,6 +78,62 @@ void testFailedRenderPreservesExistingPdfFile() throws IOException { assertEquals("existing document", Files.readString(output)); } + @Test + void testFilesystemRootReportsMd2PdfException() { + File root = File.listRoots()[0]; + + assertThrows(Md2PdfException.class, () -> engine.markdown("# Report").toPdf(root)); + } + + @Test + void testPdfFilePreservesExistingPosixPermissions() throws Exception { + assumeTrue( + Files.getFileAttributeView(tempDir, java.nio.file.attribute.PosixFileAttributeView.class) + != null); + Path output = tempDir.resolve("existing.pdf"); + Set permissions = PosixFilePermissions.fromString("rw-r-----"); + Files.writeString(output, "existing document"); + Files.setPosixFilePermissions(output, permissions); + + engine.markdown("# Report").toPdf(output); + + assertEquals(permissions, Files.getPosixFilePermissions(output)); + } + + @Test + void testPdfFileUsesPlatformDefaultPermissionsForNewFile() throws Exception { + assumeTrue( + Files.getFileAttributeView(tempDir, java.nio.file.attribute.PosixFileAttributeView.class) + != null); + Path reference = tempDir.resolve("reference.txt"); + Path output = tempDir.resolve("new.pdf"); + Files.writeString(reference, "reference"); + + engine.markdown("# Report").toPdf(output); + + assertEquals(Files.getPosixFilePermissions(reference), Files.getPosixFilePermissions(output)); + } + + @Test + void testPdfFileFallsBackToDirectWriteWhenParentCannotCreateFiles() throws Exception { + assumeTrue( + Files.getFileAttributeView(tempDir, java.nio.file.attribute.PosixFileAttributeView.class) + != null); + Path parent = Files.createDirectory(tempDir.resolve("restricted")); + Path output = parent.resolve("existing.pdf"); + Set originalPermissions = Files.getPosixFilePermissions(parent); + Files.writeString(output, "existing document"); + try { + Files.setPosixFilePermissions(parent, PosixFilePermissions.fromString("r-x------")); + + engine.markdown("# Report").toPdf(output); + } finally { + Files.setPosixFilePermissions(parent, originalPermissions); + } + + assertTrue(Files.size(output) > "existing document".length()); + } + @Test public void testRenderToByteArray() throws Md2PdfException { String md = diff --git a/lib/src/test/resources/images/sample.PNG b/lib/src/test/resources/images/sample.PNG new file mode 100644 index 0000000..ee8c1ee --- /dev/null +++ b/lib/src/test/resources/images/sample.PNG @@ -0,0 +1 @@ +fixture From 433b1fe8439a5df95985b34316d1cde7adf0e0b4 Mon Sep 17 00:00:00 2001 From: per Date: Fri, 14 Aug 2026 22:17:36 +0200 Subject: [PATCH 03/17] Preserve PDF destination identity --- lib/release.md | 6 +- .../java/se/alipsa/md2pdf/Md2PdfEngine.java | 85 ++----------------- .../java/test/alipsa/md2pdf/OutputTest.java | 33 +++++-- 3 files changed, 34 insertions(+), 90 deletions(-) diff --git a/lib/release.md b/lib/release.md index 3d8c112..0026afe 100644 --- a/lib/release.md +++ b/lib/release.md @@ -2,9 +2,9 @@ (Note, dates are in yyyy-MM-dd format) ## 0.2.0 (2026-08-14) -- PDF file output now preserves an existing destination if rendering fails, keeps existing POSIX - permissions when replacing a file, and still supports a pre-created writable destination when - its parent does not permit temporary files. +- PDF file output now renders completely before opening its destination, so a rendering failure + preserves an existing file. Writing directly also preserves destination links and attributes and + supports pre-created writable files in restricted directories. - Constructing an engine no longer changes JVM-global OpenHTMLtoPDF logging; applications can explicitly enable the provided SLF4J bridge during startup. - Image data-URL type detection now handles uppercase file extensions consistently across locales. diff --git a/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java b/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java index b673251..b039ff8 100644 --- a/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java +++ b/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java @@ -8,14 +8,9 @@ import java.io.*; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; -import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.nio.file.attribute.PosixFileAttributeView; -import java.nio.file.attribute.PosixFilePermissions; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -695,11 +690,10 @@ public byte[] toPdf() throws Md2PdfException { /** * Render the job to a PDF file. * - *

When the parent directory permits it, this renders to a temporary sibling and atomically - * replaces the destination only after success. If a pre-existing destination is writable but - * its directory does not allow temporary files, the PDF is rendered in memory and written - * directly to that file. That fallback preserves the file's attributes but cannot be atomic if - * writing the rendered bytes fails. + *

The document is rendered completely before the destination is opened. This preserves an + * existing file if rendering fails, and writes through symbolic and hard links without + * replacing their directory entries. As with any direct file write, a failure while writing the + * completed PDF can leave the destination partially written. * * @param file the file to write the PDF to * @throws Md2PdfException if rendering or writing fails @@ -710,79 +704,12 @@ public void toPdf(File file) throws Md2PdfException { if (parent == null) { throw new Md2PdfException("Cannot write a PDF to filesystem root " + target); } - Path temporary; - try { - temporary = createTemporaryPdf(parent); - } catch (IOException temporaryFailure) { - writePdfDirectly(target, temporaryFailure); - return; - } + byte[] pdf = toPdf(); try { - writePdf(temporary); - copyTargetPermissions(target, temporary); - replaceFile(temporary, target); + Files.write(target, pdf); log.debug("toPdf: Wrote {}", target); } catch (IOException e) { throw new Md2PdfException(e); - } finally { - try { - Files.deleteIfExists(temporary); - } catch (IOException e) { - log.warn("Failed to remove temporary PDF {}", temporary, e); - } - } - } - - private void writePdfDirectly(Path target, IOException temporaryFailure) - throws Md2PdfException { - byte[] pdf = toPdf(); - try { - Files.write(target, pdf); - log.debug("toPdf: Wrote {} without a temporary sibling", target); - } catch (IOException directWriteFailure) { - directWriteFailure.addSuppressed(temporaryFailure); - throw new Md2PdfException(directWriteFailure); - } - } - - private static Path createTemporaryPdf(Path parent) throws IOException { - if (Files.getFileAttributeView(parent, PosixFileAttributeView.class) != null) { - return Files.createTempFile( - parent, - ".md2pdf-", - ".pdf", - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-rw-rw-"))); - } - return Files.createTempFile(parent, ".md2pdf-", ".pdf"); - } - - private void writePdf(Path path) throws Md2PdfException, IOException { - try (BufferedOutputStream fos = new BufferedOutputStream(Files.newOutputStream(path))) { - String html = buildHtml(); - String xhtml = htmlToXhtml(html); - xhtmlToPdf(xhtml, fos, baseUri, fonts, metadata); - } - } - - private static void replaceFile(Path source, Path target) throws IOException { - try { - Files.move( - source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException e) { - Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); - } - } - - private static void copyTargetPermissions(Path target, Path temporary) throws IOException { - PosixFileAttributeView targetAttributes = - Files.getFileAttributeView(target, PosixFileAttributeView.class); - if (targetAttributes == null) { - return; - } - try { - Files.setPosixFilePermissions(temporary, targetAttributes.readAttributes().permissions()); - } catch (NoSuchFileException ignored) { - // The destination did not exist, so the temporary file keeps the platform default mode. } } diff --git a/lib/src/test/java/test/alipsa/md2pdf/OutputTest.java b/lib/src/test/java/test/alipsa/md2pdf/OutputTest.java index e8ea523..937995f 100644 --- a/lib/src/test/java/test/alipsa/md2pdf/OutputTest.java +++ b/lib/src/test/java/test/alipsa/md2pdf/OutputTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.io.ByteArrayOutputStream; @@ -115,23 +116,39 @@ void testPdfFileUsesPlatformDefaultPermissionsForNewFile() throws Exception { } @Test - void testPdfFileFallsBackToDirectWriteWhenParentCannotCreateFiles() throws Exception { + void testPdfFileWritesThroughSymbolicLink() throws Exception { assumeTrue( Files.getFileAttributeView(tempDir, java.nio.file.attribute.PosixFileAttributeView.class) != null); - Path parent = Files.createDirectory(tempDir.resolve("restricted")); - Path output = parent.resolve("existing.pdf"); - Set originalPermissions = Files.getPosixFilePermissions(parent); + Path target = tempDir.resolve("target.pdf"); + Path link = tempDir.resolve("report.pdf"); + Files.writeString(target, "existing document"); + Files.createSymbolicLink(link, target.getFileName()); + + engine.markdown("# Report").toPdf(link); + + assertTrue(Files.isSymbolicLink(link)); + assertTrue(Files.size(target) > "existing document".length()); + } + + @Test + void testReadOnlyPdfFileIsNotReplaced() throws Exception { + assumeTrue( + Files.getFileAttributeView(tempDir, java.nio.file.attribute.PosixFileAttributeView.class) + != null); + Path output = tempDir.resolve("readonly.pdf"); + Set originalPermissions = PosixFilePermissions.fromString("rw-r--r--"); Files.writeString(output, "existing document"); + Files.setPosixFilePermissions(output, PosixFilePermissions.fromString("r--r--r--")); try { - Files.setPosixFilePermissions(parent, PosixFilePermissions.fromString("r-x------")); + assumeFalse(Files.isWritable(output)); - engine.markdown("# Report").toPdf(output); + assertThrows(Md2PdfException.class, () -> engine.markdown("# Report").toPdf(output)); } finally { - Files.setPosixFilePermissions(parent, originalPermissions); + Files.setPosixFilePermissions(output, originalPermissions); } - assertTrue(Files.size(output) > "existing document".length()); + assertEquals("existing document", Files.readString(output)); } @Test From 04415d9db5e4944f1bd822b00c05f5b28e165800 Mon Sep 17 00:00:00 2001 From: per Date: Fri, 14 Aug 2026 22:32:56 +0200 Subject: [PATCH 04/17] Exercise Turkish image type regression --- lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java | 4 ++-- lib/src/test/resources/images/{sample.PNG => sample.GIF} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename lib/src/test/resources/images/{sample.PNG => sample.GIF} (100%) diff --git a/lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java b/lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java index bca5184..322adc7 100644 --- a/lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java +++ b/lib/src/test/java/test/alipsa/md2pdf/ImageUtilTest.java @@ -42,9 +42,9 @@ void testMediaTypeDetectionIsLocaleIndependent() throws Exception { Locale originalLocale = Locale.getDefault(); try { Locale.setDefault(Locale.forLanguageTag("tr-TR")); - String dataUrl = ImageUtil.asDataUrl("/images/sample.PNG"); + String dataUrl = ImageUtil.asDataUrl("/images/sample.GIF"); - assertTrue(dataUrl.startsWith("data:image/png;base64,")); + assertTrue(dataUrl.startsWith("data:image/gif;base64,")); } finally { Locale.setDefault(originalLocale); } diff --git a/lib/src/test/resources/images/sample.PNG b/lib/src/test/resources/images/sample.GIF similarity index 100% rename from lib/src/test/resources/images/sample.PNG rename to lib/src/test/resources/images/sample.GIF From 9c6b186afc40a4f50d493dc5920b39c2c4425830 Mon Sep 17 00:00:00 2001 From: per Date: Fri, 14 Aug 2026 22:52:30 +0200 Subject: [PATCH 05/17] Document explicit runtime logging --- lib/README.md | 13 +++++++++++++ lib/release.md | 8 ++++---- .../main/java/se/alipsa/md2pdf/Md2PdfEngine.java | 3 +++ .../alipsa/md2pdf/LoggingConfigurationTest.java | 13 +++++++------ 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/lib/README.md b/lib/README.md index 7c52437..75ccdbf 100644 --- a/lib/README.md +++ b/lib/README.md @@ -80,6 +80,19 @@ String html = job.toHtml(); // string job.toHtml(Path.of("out.html")); // file ``` +File output renders the complete PDF in memory before opening the destination, so a rendering +failure cannot overwrite an existing file. Use `toPdf(OutputStream)` when avoiding that final byte +array is important. + +## Logging + +**Upgrading from 0.1.1:** constructing an engine no longer changes OpenHTMLtoPDF's JVM-global +logger. To route its diagnostics through SLF4J, opt in once during application startup: + +```java +Md2PdfEngine.configureOpenHtmlToPdfLogging(); +``` + ## Styling Use `css(...)` to **replace** the default stylesheet entirely: diff --git a/lib/release.md b/lib/release.md index 0026afe..7ef43fe 100644 --- a/lib/release.md +++ b/lib/release.md @@ -3,10 +3,10 @@ ## 0.2.0 (2026-08-14) - PDF file output now renders completely before opening its destination, so a rendering failure - preserves an existing file. Writing directly also preserves destination links and attributes and - supports pre-created writable files in restricted directories. -- Constructing an engine no longer changes JVM-global OpenHTMLtoPDF logging; applications can - explicitly enable the provided SLF4J bridge during startup. + preserves an existing file. +- **Breaking:** constructing an engine no longer changes JVM-global OpenHTMLtoPDF logging. To + retain the bundled SLF4J bridge, call `Md2PdfEngine.configureOpenHtmlToPdfLogging()` during + application startup. - Image data-URL type detection now handles uppercase file extensions consistently across locales. ## 0.1.1 (2026-08-11) diff --git a/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java b/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java index b039ff8..a8235a9 100644 --- a/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java +++ b/lib/src/main/java/se/alipsa/md2pdf/Md2PdfEngine.java @@ -695,6 +695,9 @@ public byte[] toPdf() throws Md2PdfException { * replacing their directory entries. As with any direct file write, a failure while writing the * completed PDF can leave the destination partially written. * + *

File output retains the completed PDF in memory. Use {@link #toPdf(OutputStream)} when + * avoiding that final byte array is important. + * * @param file the file to write the PDF to * @throws Md2PdfException if rendering or writing fails */ diff --git a/lib/src/test/java/test/alipsa/md2pdf/LoggingConfigurationTest.java b/lib/src/test/java/test/alipsa/md2pdf/LoggingConfigurationTest.java index b9f4042..35663b8 100644 --- a/lib/src/test/java/test/alipsa/md2pdf/LoggingConfigurationTest.java +++ b/lib/src/test/java/test/alipsa/md2pdf/LoggingConfigurationTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertSame; +import com.openhtmltopdf.util.JDKXRLogger; import com.openhtmltopdf.util.XRLog; import com.openhtmltopdf.util.XRLogger; import org.junit.jupiter.api.Test; @@ -21,9 +22,7 @@ void engineConstructionDoesNotReplaceConfiguredLogger() { assertSame(configured, XRLog.getLoggerImpl()); } finally { - if (original != null) { - XRLog.setLoggerImpl(original); - } + restoreLogger(original); } } @@ -35,9 +34,11 @@ void loggingBridgeIsEnabledExplicitly() { assertInstanceOf(Slf4jXRLogger.class, XRLog.getLoggerImpl()); } finally { - if (original != null) { - XRLog.setLoggerImpl(original); - } + restoreLogger(original); } } + + private static void restoreLogger(XRLogger original) { + XRLog.setLoggerImpl(original != null ? original : new JDKXRLogger()); + } } From c849a4bae3c5456a0c3875f535e5a6d6965a902d Mon Sep 17 00:00:00 2001 From: per Date: Fri, 14 Aug 2026 22:55:37 +0200 Subject: [PATCH 06/17] Fix GUI locale-sensitive CSS handling --- gui/release.md | 1 + .../java/se/alipsa/md2pdf/gui/HtmlUtils.java | 3 ++- .../se/alipsa/md2pdf/model/StyleProfile.java | 5 +++-- .../se/alipsa/md2pdf/gui/HtmlUtilsTest.java | 18 ++++++++++++++++++ .../alipsa/md2pdf/gui/StyleProfileTest.java | 14 ++++++++++++++ 5 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 gui/src/test/java/se/alipsa/md2pdf/gui/HtmlUtilsTest.java diff --git a/gui/release.md b/gui/release.md index 0610392..bcd20f9 100644 --- a/gui/release.md +++ b/gui/release.md @@ -10,6 +10,7 @@ - **View external** now lets sandboxed distributions choose where to save the rendered PDF before opening it in the system viewer. PDF export and file dialogs also handle inaccessible or stale initial directories more safely. +- CSS style parsing and syntax-highlight injection now behave consistently in every system locale. ## 0.1.1 (2026-08-11) - Added an update check: **Help > Check for Updates…** asks GitHub Releases whether a newer diff --git a/gui/src/main/java/se/alipsa/md2pdf/gui/HtmlUtils.java b/gui/src/main/java/se/alipsa/md2pdf/gui/HtmlUtils.java index 7d45547..2fdd047 100644 --- a/gui/src/main/java/se/alipsa/md2pdf/gui/HtmlUtils.java +++ b/gui/src/main/java/se/alipsa/md2pdf/gui/HtmlUtils.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.Locale; /** Utilities for post-processing HTML strings loaded into WebView previews. */ class HtmlUtils { @@ -31,7 +32,7 @@ private HtmlUtils() {} */ static String injectSyntaxHighlighting(String html) { if (html == null) return ""; - int idx = html.toLowerCase().lastIndexOf(""); + int idx = html.toLowerCase(Locale.ROOT).lastIndexOf(""); if (idx >= 0) { return html.substring(0, idx) + HIGHLIGHT_INJECTION + html.substring(idx); } diff --git a/gui/src/main/java/se/alipsa/md2pdf/model/StyleProfile.java b/gui/src/main/java/se/alipsa/md2pdf/model/StyleProfile.java index fefd308..368681b 100644 --- a/gui/src/main/java/se/alipsa/md2pdf/model/StyleProfile.java +++ b/gui/src/main/java/se/alipsa/md2pdf/model/StyleProfile.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Properties; @@ -212,7 +213,7 @@ private static List extractBlocks(String css) { } private static String normalizeSelector(String sel) { - return sel.replaceAll("\\s+", " ").trim().toLowerCase(); + return sel.replaceAll("\\s+", " ").trim().toLowerCase(Locale.ROOT); } private static Map parseDeclarations(String decls) { @@ -222,7 +223,7 @@ private static Map parseDeclarations(String decls) { if (decl.isEmpty()) continue; int colon = decl.indexOf(':'); if (colon < 0) continue; - String prop = decl.substring(0, colon).trim().toLowerCase(); + String prop = decl.substring(0, colon).trim().toLowerCase(Locale.ROOT); String value = decl.substring(colon + 1).trim(); map.put(prop, value); } diff --git a/gui/src/test/java/se/alipsa/md2pdf/gui/HtmlUtilsTest.java b/gui/src/test/java/se/alipsa/md2pdf/gui/HtmlUtilsTest.java new file mode 100644 index 0000000..08581f7 --- /dev/null +++ b/gui/src/test/java/se/alipsa/md2pdf/gui/HtmlUtilsTest.java @@ -0,0 +1,18 @@ +package se.alipsa.md2pdf.gui; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class HtmlUtilsTest { + + @Test + void syntaxHighlightingPreservesHeadWithTurkishCapitalI() { + String html = ""; + + String highlighted = HtmlUtils.injectSyntaxHighlighting(html); + + assertTrue(highlighted.contains("")); + assertTrue(highlighted.indexOf("