problems = ReceiptValidator.problems(c.r());
+ t.line("%-52s -> %s", c.name(), problems.isEmpty() ? "PASSES validation" : problems);
+ assertThat(!problems.isEmpty()).as(c.name()).isEqualTo(c.shouldBeCaught());
+ }
+ t.blank().line("The last two are wrong and pass: arithmetic cannot check spelling.");
+ }
+ }
+}
diff --git a/multimodal/src/test/java/com/ankurm/multimodal/WireFormatTest.java b/multimodal/src/test/java/com/ankurm/multimodal/WireFormatTest.java
new file mode 100644
index 0000000..584f716
--- /dev/null
+++ b/multimodal/src/test/java/com/ankurm/multimodal/WireFormatTest.java
@@ -0,0 +1,45 @@
+package com.ankurm.multimodal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.ankurm.multimodal.support.*;
+import com.ankurm.multimodal.support.Wire.Provider;
+import org.junit.jupiter.api.Test;
+import org.springframework.util.MimeTypeUtils;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+/** What each real Spring AI model puts on the wire for the SAME image, and that the bytes arrive intact. */
+class WireFormatTest {
+
+ private static final JsonMapper JSON = JsonMapper.builder().build();
+
+ @Test
+ void sameImageThreeWireFormats() throws Exception {
+ byte[] png = ReceiptImages.png(Fixtures.all().get(0), ReceiptImages.Look.CLEAN);
+ String sent = FakeVisionServerHashes.sha(png);
+ try (FakeVisionServer server = new FakeVisionServer(); Transcript t = new Transcript("01-wire-formats.txt",
+ "The same receipt image through three real Spring AI chat models (" + png.length + " bytes, PNG)")) {
+ for (Provider p : Wire.providers(server.url())) {
+ Receipt r = Wire.extractor(p.model()).extractOnce(png, MimeTypeUtils.IMAGE_PNG);
+ assertThat(r.total()).isEqualByComparingTo("13.87");
+ FakeVisionServer.Seen seen = server.seen().getLast();
+ JsonNode req = JSON.readTree(seen.requestJson());
+ JsonNode msg = switch (p.name()) {
+ case "openai" -> req.path("messages").get(1).path("content");
+ case "anthropic" -> req.path("messages").get(0).path("content");
+ default -> req.path("messages").get(1);
+ };
+ t.line("%-9s image as sent:", p.name());
+ t.line(" %s", Wire.brief(msg));
+ t.line(" mime label: %s | bytes the server decoded: %d | sha256 matches: %s", seen.mimeAsSent(),
+ seen.bytes(), seen.sha256().equals(sent));
+ assertThat(seen.sha256()).isEqualTo(sent);
+ assertThat(seen.bytes()).isEqualTo(png.length);
+ }
+ long distinct = server.seen().stream().map(FakeVisionServer.Seen::sha256).distinct().count();
+ t.blank().line("distinct image hashes seen by the server across the three providers: %d", distinct);
+ assertThat(distinct).isEqualTo(1);
+ }
+ }
+}
diff --git a/multimodal/src/test/java/com/ankurm/multimodal/support/FakeVisionServer.java b/multimodal/src/test/java/com/ankurm/multimodal/support/FakeVisionServer.java
new file mode 100644
index 0000000..4860ed2
--- /dev/null
+++ b/multimodal/src/test/java/com/ankurm/multimodal/support/FakeVisionServer.java
@@ -0,0 +1,267 @@
+package com.ankurm.multimodal.support;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.imageio.ImageIO;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+import tools.jackson.databind.node.ArrayNode;
+import tools.jackson.databind.node.ObjectNode;
+
+/**
+ * A stand-in for three vision APIs on one port: OpenAI chat completions, Anthropic messages and
+ * Ollama chat. The REAL Spring AI model classes talk to it, so the request each one builds is the
+ * request it would send to the real service.
+ *
+ * It is not a language model. It pulls the image out of whatever request shape arrived, decodes
+ * it, runs the {@code tesseract} OCR program on the pixels, and parses the text with a few regular
+ * expressions into the receipt JSON. So it genuinely depends on what is in the image, but its
+ * accuracy is the accuracy of an OCR engine plus a hand-written parser, NOT of any vision model.
+ * Treat every accuracy number it produces as a property of this pipeline's plumbing and of OCR,
+ * never as a statement about GPT, Claude or Gemini.
+ */
+public class FakeVisionServer implements AutoCloseable {
+
+ /** What the server actually received for one request. */
+ public record Seen(String provider, String mimeAsSent, int bytes, String sha256, int width, int height,
+ String requestJson, String promptText) {
+ }
+
+ private static final JsonMapper JSON = JsonMapper.builder().build();
+
+ private final HttpServer server;
+
+ private final List seen = new CopyOnWriteArrayList<>();
+
+ private volatile String lastOcr = "";
+
+ private volatile boolean misreadFirstAttempt;
+
+ /** Simulation switch: add 0.10 to the total unless the prompt says a previous attempt had problems. */
+ public FakeVisionServer misreadFirstAttempt(boolean on) {
+ this.misreadFirstAttempt = on;
+ return this;
+ }
+
+ public FakeVisionServer() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/v1/chat/completions", ex -> handle(ex, "openai"));
+ server.createContext("/v1/messages", ex -> handle(ex, "anthropic"));
+ server.createContext("/api/chat", ex -> handle(ex, "ollama"));
+ server.start();
+ }
+
+ public String url() {
+ return "http://127.0.0.1:" + server.getAddress().getPort();
+ }
+
+ public List seen() {
+ return seen;
+ }
+
+ public String lastOcr() {
+ return lastOcr;
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+
+ private void handle(HttpExchange ex, String provider) throws IOException {
+ String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ JsonNode req = JSON.readTree(body);
+ String mime = "?";
+ String b64 = null;
+ StringBuilder prompt = new StringBuilder();
+ for (JsonNode m : req.path("messages")) {
+ if (provider.equals("ollama")) {
+ for (JsonNode img : m.path("images")) {
+ b64 = img.asString();
+ mime = "(none: Ollama sends bare base64)";
+ }
+ if (m.path("content").isString()) {
+ prompt.append(m.path("content").asString()).append('\n');
+ }
+ continue;
+ }
+ JsonNode content = m.path("content");
+ if (content.isString()) {
+ prompt.append(content.asString()).append('\n');
+ continue;
+ }
+ for (JsonNode part : content) {
+ String type = part.path("type").asString();
+ if (type.equals("text")) {
+ prompt.append(part.path("text").asString()).append('\n');
+ } else if (type.equals("image_url")) {
+ String url = part.path("image_url").path("url").asString();
+ Matcher dm = Pattern.compile("^data:([^;]+);base64,(.*)$", Pattern.DOTALL).matcher(url);
+ if (dm.matches()) {
+ mime = dm.group(1);
+ b64 = dm.group(2);
+ } else {
+ mime = "URL: " + url;
+ }
+ } else if (type.equals("image")) {
+ if (part.path("source").path("type").asString().equals("url")) {
+ mime = "URL: " + part.path("source").path("url").asString();
+ } else {
+ mime = part.path("source").path("media_type").asString();
+ b64 = part.path("source").path("data").asString();
+ }
+ }
+ }
+ }
+ String answer;
+ if (b64 == null) {
+ answer = "{}";
+ seen.add(new Seen(provider, mime, 0, "", 0, 0, body, prompt.toString()));
+ } else {
+ byte[] png;
+ try {
+ png = Base64.getDecoder().decode(b64);
+ } catch (IllegalArgumentException notBase64) {
+ png = null;
+ }
+ BufferedImage img = png == null ? null : ImageIO.read(new ByteArrayInputStream(png));
+ if (img == null) {
+ String shown = b64.length() > 70 ? b64.substring(0, 70) + "..." : b64;
+ seen.add(new Seen(provider, "NOT AN IMAGE: " + shown, 0, "", 0, 0, body, prompt.toString()));
+ send(ex, provider, "{}");
+ return;
+ }
+ seen.add(new Seen(provider, mime, png.length, sha256(png), img.getWidth(), img.getHeight(), body,
+ prompt.toString()));
+ lastOcr = ocr(png);
+ answer = parse(lastOcr);
+ if (misreadFirstAttempt && !prompt.toString().contains("previous attempt")) {
+ ObjectNode n = (ObjectNode) JSON.readTree(answer);
+ n.put("total", n.path("total").decimalValue().add(new java.math.BigDecimal("0.10")));
+ answer = JSON.writeValueAsString(n);
+ }
+ }
+ send(ex, provider, answer);
+ }
+
+ private void send(HttpExchange ex, String provider, String answer) throws IOException {
+ String reply = switch (provider) {
+ case "openai" -> "{\"id\":\"chatcmpl-fake\",\"object\":\"chat.completion\",\"created\":1700000000,"
+ + "\"model\":\"gpt-4o-mini-2024-07-18\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\","
+ + "\"content\":" + JSON.writeValueAsString(answer) + "},\"finish_reason\":\"stop\"}],"
+ + "\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":10,\"total_tokens\":20}}";
+ case "anthropic" -> "{\"id\":\"msg_fake\",\"type\":\"message\",\"role\":\"assistant\","
+ + "\"model\":\"claude-sonnet-4-5\",\"content\":[{\"type\":\"text\",\"text\":"
+ + JSON.writeValueAsString(answer) + "}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,"
+ + "\"usage\":{\"input_tokens\":10,\"output_tokens\":10}}";
+ default -> "{\"model\":\"llava\",\"created_at\":\"2026-01-01T00:00:00Z\",\"message\":{\"role\":\"assistant\","
+ + "\"content\":" + JSON.writeValueAsString(answer) + "},\"done\":true,\"done_reason\":\"stop\","
+ + "\"prompt_eval_count\":10,\"eval_count\":10}";
+ };
+ ex.getResponseHeaders().add("Content-Type", "application/json");
+ byte[] out = reply.getBytes(StandardCharsets.UTF_8);
+ ex.sendResponseHeaders(200, out.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(out);
+ }
+ }
+
+ static String sha256(byte[] data) {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /** Runs tesseract on the image bytes. */
+ public static String ocr(byte[] png) {
+ try {
+ Path f = Files.createTempFile("receipt", ".png");
+ Files.write(f, png);
+ Process p = new ProcessBuilder("tesseract", f.toString(), "stdout", "--psm", "6")
+ .redirectError(ProcessBuilder.Redirect.DISCARD).start();
+ String text = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ p.waitFor(60, TimeUnit.SECONDS);
+ Files.delete(f);
+ return text;
+ } catch (IOException | InterruptedException e) {
+ throw new IllegalStateException("tesseract failed", e);
+ }
+ }
+
+ private static final Pattern DATE = Pattern.compile("(\\d{4}-\\d{2}-\\d{2})");
+ private static final Pattern CUR = Pattern.compile("CURRENCY:\\s*([A-Z]{3})");
+ private static final Pattern LINE = Pattern.compile("^(.+?)\\s+(\\d+)\\s*[xX]\\s*([\\d.,]+)\\s+([\\d.,]+)\\s*$");
+ private static final Pattern SUB = Pattern.compile("^SUBTOTAL\\s+([\\d.,]+)");
+ private static final Pattern TAX = Pattern.compile("^TAX\\s+([\\d.,]+)");
+ private static final Pattern TOT = Pattern.compile("^TOTAL\\s+([\\d.,]+)");
+
+ /** Turns OCR text into the receipt JSON; fields it cannot read are null. */
+ public static String parse(String text) {
+ ObjectNode r = JSON.createObjectNode();
+ r.putNull("merchant");
+ r.putNull("date");
+ r.putNull("currency");
+ ArrayNode items = r.putArray("items");
+ r.putNull("subtotal");
+ r.putNull("tax");
+ r.putNull("total");
+ List lines = new ArrayList<>();
+ for (String s : text.split("\\R")) {
+ if (!s.isBlank()) {
+ lines.add(s.strip());
+ }
+ }
+ if (!lines.isEmpty()) {
+ r.put("merchant", lines.get(0));
+ }
+ for (String s : lines) {
+ Matcher m;
+ if ((m = DATE.matcher(s)).find()) {
+ r.put("date", m.group(1));
+ } else if ((m = CUR.matcher(s)).find()) {
+ r.put("currency", m.group(1));
+ } else if ((m = SUB.matcher(s)).find()) {
+ put(r, "subtotal", m.group(1));
+ } else if ((m = TAX.matcher(s)).find()) {
+ put(r, "tax", m.group(1));
+ } else if ((m = TOT.matcher(s)).find()) {
+ put(r, "total", m.group(1));
+ } else if ((m = LINE.matcher(s)).matches()) {
+ ObjectNode it = items.addObject();
+ it.put("description", m.group(1).strip());
+ it.put("quantity", Integer.parseInt(m.group(2)));
+ put(it, "unitPrice", m.group(3));
+ put(it, "lineTotal", m.group(4));
+ }
+ }
+ return JSON.writeValueAsString(r);
+ }
+
+ private static void put(ObjectNode n, String field, String num) {
+ try {
+ n.put(field, new java.math.BigDecimal(num.replace(',', '.')));
+ } catch (NumberFormatException e) {
+ n.putNull(field);
+ }
+ }
+}
diff --git a/multimodal/src/test/java/com/ankurm/multimodal/support/FakeVisionServerHashes.java b/multimodal/src/test/java/com/ankurm/multimodal/support/FakeVisionServerHashes.java
new file mode 100644
index 0000000..333dad0
--- /dev/null
+++ b/multimodal/src/test/java/com/ankurm/multimodal/support/FakeVisionServerHashes.java
@@ -0,0 +1,11 @@
+package com.ankurm.multimodal.support;
+
+public final class FakeVisionServerHashes {
+
+ private FakeVisionServerHashes() {
+ }
+
+ public static String sha(byte[] data) {
+ return FakeVisionServer.sha256(data);
+ }
+}
diff --git a/multimodal/src/test/java/com/ankurm/multimodal/support/Fixtures.java b/multimodal/src/test/java/com/ankurm/multimodal/support/Fixtures.java
new file mode 100644
index 0000000..b56b951
--- /dev/null
+++ b/multimodal/src/test/java/com/ankurm/multimodal/support/Fixtures.java
@@ -0,0 +1,47 @@
+package com.ankurm.multimodal.support;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import com.ankurm.multimodal.Receipt;
+import com.ankurm.multimodal.Receipt.Line;
+
+/** Ten ground-truth receipts, written by hand. Each is internally consistent. */
+public final class Fixtures {
+
+ private Fixtures() {
+ }
+
+ private static BigDecimal d(String s) {
+ return new BigDecimal(s);
+ }
+
+ private static Line line(String name, int q, String unit) {
+ BigDecimal u = d(unit);
+ return new Line(name, q, u, u.multiply(BigDecimal.valueOf(q)));
+ }
+
+ private static Receipt receipt(String merchant, String date, String cur, String tax, Line... lines) {
+ BigDecimal sub = BigDecimal.ZERO;
+ for (Line l : lines) {
+ sub = sub.add(l.lineTotal());
+ }
+ BigDecimal t = d(tax);
+ return new Receipt(merchant, LocalDate.parse(date), cur, List.of(lines), sub, t, sub.add(t));
+ }
+
+ public static List all() {
+ return List.of(
+ receipt("CORNER CAFE", "2026-03-14", "USD", "1.12", line("Flat White", 2, "4.50"), line("Croissant", 1, "3.75")),
+ receipt("GREEN GROCER", "2026-03-15", "USD", "2.40", line("Apples", 3, "1.20"), line("Bread", 2, "2.95"), line("Milk", 1, "1.89")),
+ receipt("BOOK NOOK", "2026-03-16", "GBP", "4.00", line("Novel", 2, "9.99"), line("Bookmark", 4, "1.50")),
+ receipt("PIZZA ROMA", "2026-03-17", "EUR", "3.20", line("Margherita", 2, "8.50"), line("Cola", 3, "2.50"), line("Tiramisu", 1, "5.00")),
+ receipt("HARDWARE HUB", "2026-03-18", "USD", "6.18", line("Screws", 5, "2.10"), line("Hammer", 1, "14.99"), line("Tape", 2, "3.49")),
+ receipt("CHAI POINT", "2026-03-19", "INR", "18.00", line("Masala Chai", 4, "40.00"), line("Samosa", 6, "15.00")),
+ receipt("PET PLACE", "2026-03-20", "USD", "3.05", line("Dog Food", 1, "24.99"), line("Chew Toy", 2, "6.50")),
+ receipt("FLOWER BAR", "2026-03-21", "EUR", "2.70", line("Tulips", 3, "7.00"), line("Vase", 1, "12.00")),
+ receipt("TECH DEPOT", "2026-03-22", "USD", "7.59", line("USB Cable", 3, "9.99"), line("Mouse", 1, "19.50"), line("Hub", 1, "29.00")),
+ receipt("NIGHT MARKET", "2026-03-23", "GBP", "1.45", line("Noodles", 2, "6.75"), line("Tea", 2, "2.20")));
+ }
+}
diff --git a/multimodal/src/test/java/com/ankurm/multimodal/support/Models.java b/multimodal/src/test/java/com/ankurm/multimodal/support/Models.java
new file mode 100644
index 0000000..b73c2f0
--- /dev/null
+++ b/multimodal/src/test/java/com/ankurm/multimodal/support/Models.java
@@ -0,0 +1,42 @@
+package com.ankurm.multimodal.support;
+
+import org.springframework.ai.anthropic.AnthropicChatModel;
+import org.springframework.ai.anthropic.AnthropicChatOptions;
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.ollama.OllamaChatModel;
+import org.springframework.ai.ollama.api.OllamaApi;
+import org.springframework.ai.ollama.api.OllamaChatOptions;
+import org.springframework.ai.openai.OpenAiChatModel;
+import org.springframework.ai.openai.OpenAiChatOptions;
+
+/** The three REAL Spring AI chat models, pointed at the fake server. */
+public final class Models {
+
+ private Models() {
+ }
+
+ public static ChatModel openai(String baseUrl) {
+ return OpenAiChatModel.builder()
+ .options(OpenAiChatOptions.builder().baseUrl(baseUrl + "/v1").apiKey("test").model("gpt-4o-mini").build())
+ .build();
+ }
+
+ public static ChatModel anthropic(String baseUrl) {
+ return AnthropicChatModel.builder()
+ .options(AnthropicChatOptions.builder().baseUrl(baseUrl).apiKey("test").model("claude-sonnet-4-5")
+ .maxTokens(1024).build())
+ .build();
+ }
+
+ public static ChatModel ollama(String baseUrl) {
+ return OllamaChatModel.builder()
+ .ollamaApi(OllamaApi.builder().baseUrl(baseUrl).build())
+ .options(OllamaChatOptions.builder().model("llava").build())
+ .build();
+ }
+
+ public static ChatClient client(ChatModel m) {
+ return ChatClient.create(m);
+ }
+}
diff --git a/multimodal/src/test/java/com/ankurm/multimodal/support/Transcript.java b/multimodal/src/test/java/com/ankurm/multimodal/support/Transcript.java
new file mode 100644
index 0000000..a6a669f
--- /dev/null
+++ b/multimodal/src/test/java/com/ankurm/multimodal/support/Transcript.java
@@ -0,0 +1,47 @@
+package com.ankurm.multimodal.support;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Writes a numbered transcript under {@code output/} (repository root, not {@code docs/}) and
+ * echoes it to the console. Every console block quoted in the article comes out of one of these
+ * files verbatim.
+ */
+public final class Transcript implements AutoCloseable {
+
+ private final Path path;
+ private final StringWriter buffer = new StringWriter();
+ private final PrintWriter out = new PrintWriter(buffer);
+
+ public Transcript(String fileName, String title) {
+ this.path = Path.of("output", fileName);
+ out.println("# " + title);
+ out.println();
+ }
+
+ public Transcript line(String format, Object... args) {
+ out.println(args.length == 0 ? format : String.format(format, args));
+ return this;
+ }
+
+ public Transcript blank() {
+ out.println();
+ return this;
+ }
+
+ @Override
+ public void close() {
+ out.flush();
+ try {
+ Files.createDirectories(path.getParent());
+ Files.writeString(path, buffer.toString());
+ } catch (IOException e) {
+ throw new IllegalStateException("could not write " + path, e);
+ }
+ System.out.print(buffer);
+ }
+}
diff --git a/multimodal/src/test/java/com/ankurm/multimodal/support/Wire.java b/multimodal/src/test/java/com/ankurm/multimodal/support/Wire.java
new file mode 100644
index 0000000..a934456
--- /dev/null
+++ b/multimodal/src/test/java/com/ankurm/multimodal/support/Wire.java
@@ -0,0 +1,73 @@
+package com.ankurm.multimodal.support;
+
+import java.util.List;
+
+import com.ankurm.multimodal.ReceiptExtractor;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.util.MimeTypeUtils;
+
+/** Shared test helpers. */
+public final class Wire {
+
+ public record Provider(String name, ChatModel model) {
+ }
+
+ private Wire() {
+ }
+
+ public static List providers(String url) {
+ return List.of(new Provider("openai", Models.openai(url)), new Provider("anthropic", Models.anthropic(url)),
+ new Provider("ollama", Models.ollama(url)));
+ }
+
+ public static ReceiptExtractor extractor(ChatModel m) {
+ return new ReceiptExtractor(Models.client(m));
+ }
+
+ /** Replaces any long base64 run with a short placeholder so a request can be printed. */
+ public static String elide(String json) {
+ java.util.regex.Matcher m = java.util.regex.Pattern.compile("[A-Za-z0-9+/=]{200,}").matcher(json);
+ StringBuilder sb = new StringBuilder();
+ while (m.find()) {
+ m.appendReplacement(sb, "<" + m.group().length() + " base64 characters>");
+ }
+ m.appendTail(sb);
+ return sb.toString();
+ }
+
+ /** Shortens every long string inside a JSON value (prompt text, base64) so a request fits on a line. */
+ public static String brief(tools.jackson.databind.JsonNode n) {
+ tools.jackson.databind.json.JsonMapper m = tools.jackson.databind.json.JsonMapper.builder().build();
+ return briefNode(m, n).toString();
+ }
+
+ private static tools.jackson.databind.JsonNode briefNode(tools.jackson.databind.json.JsonMapper m, tools.jackson.databind.JsonNode n) {
+ if (n.isString()) {
+ String s = n.asString();
+ if (s.matches("[A-Za-z0-9+/=]{200,}")) {
+ return m.getNodeFactory().stringNode("<" + s.length() + " base64 characters>");
+ }
+ java.util.regex.Matcher dm = java.util.regex.Pattern.compile("(?s)data:([^;]+);base64,([A-Za-z0-9+/=]+)").matcher(s);
+ String cut = dm.matches() ? "data:" + dm.group(1) + ";base64,<" + dm.group(2).length() + " base64 characters>" : s;
+ if (dm.matches()) {
+ return m.getNodeFactory().stringNode(cut);
+ }
+ return m.getNodeFactory().stringNode(cut.length() > 60 ? cut.substring(0, 40).replace("\n", " ") + "... (" + cut.length() + " characters)" : cut);
+ }
+ if (n.isObject()) {
+ tools.jackson.databind.node.ObjectNode o = m.createObjectNode();
+ n.properties().forEach(e -> o.set(e.getKey(), briefNode(m, e.getValue())));
+ return o;
+ }
+ if (n.isArray()) {
+ tools.jackson.databind.node.ArrayNode a = m.createArrayNode();
+ n.forEach(x -> a.add(briefNode(m, x)));
+ return a;
+ }
+ return n;
+ }
+
+ public static String pngMime() {
+ return MimeTypeUtils.IMAGE_PNG_VALUE;
+ }
+}