diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/docx/DocxConverter.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/docx/DocxConverter.java index 5f5a107f..dd4b006b 100644 --- a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/docx/DocxConverter.java +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/docx/DocxConverter.java @@ -24,7 +24,7 @@ public static byte[] convert(byte[] input, ConversionOptions options) throws Min MiniPdfException.Kind.INVALID_INPUT, "DOCX package does not contain word/document.xml")); DocxContent content = readDocument(documentXml); - if (content.tableCount() == 3 && content.drawingCount() == 1 && content.pages().size() == 1) { + if (content.tableCount() > 0) { byte[] structured = PoiDocxRenderer.render(input, options, content.pageSize()); if (structured != null) { return structured; diff --git a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/docx/PoiDocxRenderer.java b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/docx/PoiDocxRenderer.java index 90a00055..14074691 100644 --- a/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/docx/PoiDocxRenderer.java +++ b/minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/docx/PoiDocxRenderer.java @@ -34,9 +34,13 @@ final class PoiDocxRenderer { private static final float DEFAULT_MARGIN = 54.0f; private static final float DEFAULT_FONT_SIZE = 11.0f; + private static final float DEFAULT_TABLE_FONT_SIZE = 10.5f; private static final float CELL_HORIZONTAL_PADDING = 5.4f; private static final float CELL_VERTICAL_PADDING = 2.0f; private static final float EMUS_PER_POINT = 12_700.0f; + private static final Pattern PAGE_FIELD = Pattern.compile("(?i)^PAGE(?:\\s|$)"); + private static final Pattern CACHED_PAGE_NUMBER = Pattern.compile("^(.*?)(\\d+)(.*)$"); + private static final AutoSpacing DEFAULT_AUTO_SPACING = new AutoSpacing(true, true); private PoiDocxRenderer() { } @@ -86,20 +90,65 @@ static byte[] render(byte[] input, ConversionOptions options, PageSize documentP if (paragraphFont == null) { paragraphFont = font; } + List> latinText = List.of(paragraphText.get(0).stream() + .map(PoiDocxRenderer::latinText) + .toList()); + PDFont timesFont = SimplePdfTextRenderer.loadSystemFont( + output, + latinText, + "times.ttf"); + if (timesFont == null) { + timesFont = font; + } + PDFont arialFont = SimplePdfTextRenderer.loadSystemFont( + output, + latinText, + "arial.ttf"); + if (arialFont == null) { + arialFont = timesFont; + } + PDFont kaiFont = SimplePdfTextRenderer.loadSystemFont( + output, + paragraphText, + "simkai.ttf"); + PDFont fangSongFont = SimplePdfTextRenderer.loadSystemFont( + output, + paragraphText, + "simfang.ttf"); + ParagraphFonts paragraphFonts = new ParagraphFonts( + paragraphFont, + font, + paragraphFont, + kaiFont == null ? paragraphFont : kaiFont, + fangSongFont == null ? paragraphFont : fangSongFont, + timesFont, + arialFont, + defaultFontFamily(source, XWPFRun.FontCharRange.ascii), + defaultFontFamily(source, XWPFRun.FontCharRange.eastAsia)); - float margin = pageMargin(source, DEFAULT_MARGIN); - PageContext context = new PageContext(output, pageSize, margin, 10.0f); - for (var element : source.getBodyElements()) { + boolean landscape = pageSize.width() > pageSize.height(); + PageMargins margins = pageMargins(source, DEFAULT_MARGIN, landscape); + float linePitch = documentLinePitch(source); + PageNumberFooter pageNumberFooter = pageNumberFooter(source); + PageContext context = new PageContext( + output, + pageSize, + margins.left(), + margins.top(), + margins.bottom(), + linePitch, + margins.firstPageTopOffset(), + pageNumberFooter == null ? null : timesFont, + pageNumberFooter, + pageFooterDistance(source, margins.bottom())); + for (var element : source.getBodyElements()) { if (element instanceof XWPFParagraph paragraph) { - renderParagraph(context, paragraph, paragraphFont); + renderParagraph(context, paragraph, paragraphFonts); } else if (element instanceof XWPFTable table) { - renderTable(context, table, font, boldFont); + renderTable(context, table, paragraphFonts, boldFont); } } context.close(); - if (context.pageCount > 1) { - return null; - } ByteArrayOutputStream bytes = new ByteArrayOutputStream(); output.save(bytes); return bytes.toByteArray(); @@ -111,25 +160,101 @@ static byte[] render(byte[] input, ConversionOptions options, PageSize documentP } } - private static void renderParagraph(PageContext context, XWPFParagraph paragraph, PDFont font) + private static void renderParagraph( + PageContext context, + XWPFParagraph paragraph, + ParagraphFonts fonts) throws IOException { float fontSize = paragraphFontSize(paragraph, DEFAULT_FONT_SIZE); context.moveDown(twipsToPoints(paragraph.getSpacingBefore())); - context.ensureSpace(fontSize * 1.2f); - float baseline = context.y - fontSize; - String text = paragraph.getText(); - if (!text.isEmpty()) { - float width = textWidth(font, text, fontSize); - float x = context.margin + twipsToPoints(paragraph.getIndentationLeft()); + String text = renderableText(paragraph.getText()); + float leftIndent = indentationToPoints(paragraph.getIndentationLeft()); + float rightIndent = indentationToPoints(paragraph.getIndentationRight()); + float availableWidth = context.pageSize.width() - context.margin * 2.0f - leftIndent - rightIndent; + AutoSpacing autoSpacing = paragraphAutoSpacing(paragraph); + List segments = paragraphSegments(paragraph, fonts, fontSize); + float segmentWidth = 0.0f; + for (RunSegment segment : segments) { + segmentWidth += segment.leadingSpacing() + + textWidth(segment.font(), segment.text(), segment.fontSize(), autoSpacing); + } + boolean hasPictures = paragraph.getRuns().stream() + .anyMatch(run -> !run.getEmbeddedPictures().isEmpty()); + if (!segments.isEmpty() + && !hasPictures + && segments.stream().map(RunSegment::text).reduce("", String::concat).equals(text) + && segmentWidth <= availableWidth) { + float maxFontSize = segments.stream() + .map(RunSegment::fontSize) + .max(Float::compare) + .orElse(fontSize); + float naturalLineHeight = maxFontSize * 1.2f; + float lineHeight = gridLineHeight(naturalLineHeight, context.linePitch); + context.ensureSpace(lineHeight); + float leading = Math.max(0.0f, lineHeight - naturalLineHeight) / 2.0f; + float baseline = context.y - leading - maxFontSize; + float x = context.margin + leftIndent; if (paragraph.getAlignment() == ParagraphAlignment.CENTER) { - x = (context.pageSize.width() - width) / 2.0f; + x = centeredTextX( + context.pageSize.width(), leftIndent, rightIndent, segmentWidth); } else if (paragraph.getAlignment() == ParagraphAlignment.RIGHT) { - x = context.pageSize.width() - context.margin - width; + x = context.pageSize.width() - context.margin - rightIndent - segmentWidth; + } + for (RunSegment segment : segments) { + x += segment.leadingSpacing(); + showText( + context.content, + segment.font(), + segment.fontSize(), + segment.text(), + x, + baseline, + autoSpacing); + x += textWidth(segment.font(), segment.text(), segment.fontSize(), autoSpacing); } - showText(context.content, font, fontSize, text, x, baseline); + context.y -= lineHeight; + context.moveDown(twipsToPoints(paragraph.getSpacingAfter())); + if (startsNewSection(paragraph)) { + context.newPage(); + } + return; + } + PDFont font = wrappingFont(fonts, text); + List lines = wrap(font, text, fontSize, availableWidth, autoSpacing); + float lineHeight = gridLineHeight(fontSize * 1.2f, context.linePitch); + float firstBaseline = 0.0f; + for (int index = 0; index < lines.size(); index++) { + context.ensureSpace(lineHeight); + float baseline = context.y - fontSize; + if (index == 0) { + firstBaseline = baseline; + } + String line = lines.get(index); + float width = textWidth(font, line, fontSize, autoSpacing); + float x = context.margin + leftIndent; + if (paragraph.getAlignment() == ParagraphAlignment.CENTER) { + x = centeredTextX(context.pageSize.width(), leftIndent, rightIndent, width); + } else if (paragraph.getAlignment() == ParagraphAlignment.RIGHT) { + x = context.pageSize.width() - context.margin - rightIndent - width; + } + showText(context.content, font, fontSize, line, x, baseline, autoSpacing); + context.y -= lineHeight; + } + renderPictures(context, paragraph, firstBaseline); + context.moveDown(twipsToPoints(paragraph.getSpacingAfter())); + if (startsNewSection(paragraph)) { + context.newPage(); } - renderPictures(context, paragraph, baseline); - context.y = baseline - fontSize * 0.2f - twipsToPoints(paragraph.getSpacingAfter()); + } + + private static boolean startsNewSection(XWPFParagraph paragraph) { + if (!paragraph.getCTP().isSetPPr() || !paragraph.getCTP().getPPr().isSetSectPr()) { + return false; + } + var section = paragraph.getCTP().getPPr().getSectPr(); + return !section.isSetType() + || section.getType().getVal() == null + || !"continuous".equals(section.getType().getVal().toString()); } private static void renderPictures(PageContext context, XWPFParagraph paragraph, float baseline) @@ -145,10 +270,15 @@ private static void renderPictures(PageContext context, XWPFParagraph paragraph, width = 52.0f; height = 32.0f; } - PDImageXObject image = PDImageXObject.createFromByteArray( - context.document, - picture.getPictureData().getData(), - picture.getDescription()); + PDImageXObject image; + try { + image = PDImageXObject.createFromByteArray( + context.document, + picture.getPictureData().getData(), + picture.getDescription()); + } catch (IOException | IllegalArgumentException exception) { + continue; + } float marginLeft = stylePoints(run.getCTR().xmlText(), "margin-left"); float x = Float.isNaN(marginLeft) ? context.pageSize.width() - context.margin - width @@ -161,22 +291,29 @@ private static void renderPictures(PageContext context, XWPFParagraph paragraph, private static void renderTable( PageContext context, XWPFTable table, - PDFont font, + ParagraphFonts fonts, PDFont boldFont) throws IOException { + PDFont font = fonts.simSun(); float tableWidth = twipsToPoints(table.getWidth()); if (tableWidth <= 0.0f) { tableWidth = context.pageSize.width() - context.margin * 2.0f; } float x = (context.pageSize.width() - tableWidth) / 2.0f; boolean compact = tableWidth < 200.0f; + boolean landscape = context.pageSize.width() > context.pageSize.height(); List columnWidths = columnWidths(table, tableWidth); - float verticalPadding = compact ? 0.0f : columnWidths.size() > 2 ? 2.3f : 1.5f; + float rowHeightPadding = compact ? 0.0f : columnWidths.size() > 2 + ? landscape ? 1.6f : 2.3f + : 1.5f; List rowHeights = new ArrayList<>(); for (XWPFTableRow row : table.getRows()) { - rowHeights.add(rowHeight(row, font, columnWidths, compact, verticalPadding)); + rowHeights.add(rowHeight( + row, fonts, boldFont, columnWidths, compact, rowHeightPadding, context.linePitch)); } float tableHeight = sum(rowHeights, rowHeights.size()); - float spacingBefore = compact ? 4.0f : table.getRows().size() == 1 ? 6.0f : 3.0f; + float spacingBefore = compact ? 4.0f : table.getRows().size() == 1 + ? 6.0f + : landscape ? 0.0f : 3.0f; context.moveDown(spacingBefore); context.ensureSpace(tableHeight); @@ -191,7 +328,7 @@ private static void renderTable( if (width <= 0.0f) { width = column < columnWidths.size() ? columnWidths.get(column) : tableWidth; } - drawCellBorder(context, cell, cellX, rowTop, width, rowHeight); + drawCellBorder(context, table, cell, cellX, rowTop, width, rowHeight); cellX += width; while (column < columnWidths.size() && cellX > x + sum(columnWidths, column + 1) - 0.5f) { column++; @@ -240,13 +377,12 @@ private static void renderTable( drawCellText( context, cell, - font, + fonts, boldFont, cellX, rowTop, width, height, - isMergeRestart(cell), compact); } cellX += width; @@ -284,6 +420,7 @@ private static void drawCompactMergeLine( private static void drawCellBorder( PageContext context, + XWPFTable table, XWPFTableCell cell, float x, float top, @@ -297,19 +434,40 @@ private static void drawCellBorder( return; } var borders = cell.getCTTc().getTcPr().getTcBorders(); - if (borders.isSetTop() && isVisibleBorder(borders.getTop().getVal().toString())) { - drawBorder(context, x, top, x + width, top, borders.getTop().getVal().toString()); + var tableBorders = table.getCTTbl().getTblPr().isSetTblBorders() + ? table.getCTTbl().getTblPr().getTblBorders() + : null; + String topStyle = borders.isSetTop() + ? borders.getTop().getVal().toString() + : tableBorders != null && tableBorders.isSetInsideH() + ? tableBorders.getInsideH().getVal().toString() + : null; + String leftStyle = borders.isSetLeft() + ? borders.getLeft().getVal().toString() + : tableBorders != null && tableBorders.isSetInsideV() + ? tableBorders.getInsideV().getVal().toString() + : null; + String bottomStyle = borders.isSetBottom() + ? borders.getBottom().getVal().toString() + : tableBorders != null && tableBorders.isSetInsideH() + ? tableBorders.getInsideH().getVal().toString() + : null; + String rightStyle = borders.isSetRight() + ? borders.getRight().getVal().toString() + : tableBorders != null && tableBorders.isSetInsideV() + ? tableBorders.getInsideV().getVal().toString() + : null; + if (isVisibleBorder(topStyle)) { + drawBorder(context, x, top, x + width, top, topStyle); } - if (borders.isSetLeft() && isVisibleBorder(borders.getLeft().getVal().toString())) { - drawBorder(context, x, top, x, top - height, borders.getLeft().getVal().toString()); + if (isVisibleBorder(leftStyle)) { + drawBorder(context, x, top, x, top - height, leftStyle); } - if (borders.isSetBottom() && isVisibleBorder(borders.getBottom().getVal().toString())) { - drawBorder(context, x, top - height, x + width, top - height, - borders.getBottom().getVal().toString()); + if (isVisibleBorder(bottomStyle)) { + drawBorder(context, x, top - height, x + width, top - height, bottomStyle); } - if (borders.isSetRight() && isVisibleBorder(borders.getRight().getVal().toString())) { - drawBorder(context, x + width, top, x + width, top - height, - borders.getRight().getVal().toString()); + if (isVisibleBorder(rightStyle)) { + drawBorder(context, x + width, top, x + width, top - height, rightStyle); } } @@ -330,28 +488,27 @@ private static void drawBorder( } private static boolean isVisibleBorder(String style) { - return !"nil".equalsIgnoreCase(style) && !"none".equalsIgnoreCase(style); + return style != null && !"nil".equalsIgnoreCase(style) && !"none".equalsIgnoreCase(style); } private static void drawCellText( PageContext context, XWPFTableCell cell, - PDFont font, + ParagraphFonts fonts, PDFont boldFont, float x, float top, float width, float height, - boolean merged, boolean compact) throws IOException { - PDFont cellFont = isCellBold(cell) ? boldFont : font; - float fontSize = cellFontSize(cell, 9.0f); + PDFont cellFont = resolvedCellFont(cell, fonts, boldFont); + float fontSize = cellFontSize(cell, DEFAULT_TABLE_FONT_SIZE); float horizontalPadding = compact ? 0.0f : CELL_HORIZONTAL_PADDING; float verticalPadding = compact ? 0.0f : CELL_VERTICAL_PADDING; List lines = cellLines(cell, cellFont, fontSize, width, horizontalPadding); - float lineHeight = fontSize * 1.35f; + float lineHeight = gridLineHeight(fontSize * 1.35f, context.linePitch); float baseline = top - verticalPadding - paragraphSpacingBefore(cell) - fontSize; - if (merged && cell.getVerticalAlignment() == XWPFTableCell.XWPFVertAlign.CENTER) { + if (cell.getVerticalAlignment() == XWPFTableCell.XWPFVertAlign.CENTER) { float textHeight = fontSize + Math.max(0, lines.size() - 1) * lineHeight; baseline = top - (height - textHeight) / 2.0f - fontSize; } @@ -376,7 +533,7 @@ private static void drawCellText( context.content.fill(); context.content.setNonStrokingColor(0.0f, 0.0f, 0.0f); } - showText(context.content, cellFont, fontSize, line, lineX, baseline); + showText(context.content, cellFont, fontSize, line, lineX, baseline); baseline -= lineHeight; } } @@ -415,10 +572,12 @@ private static List columnWidths(XWPFTable table, float tableWidth) { private static float rowHeight( XWPFTableRow row, - PDFont font, + ParagraphFonts fonts, + PDFont boldFont, List widths, boolean compact, - float verticalPadding) throws IOException { + float rowHeightPadding, + float linePitch) throws IOException { float height = twipsToPoints(row.getHeight()); int column = 0; for (XWPFTableCell cell : row.getTableCells()) { @@ -430,15 +589,17 @@ private static float rowHeight( if (width <= 0.0f && column < widths.size()) { width = widths.get(column); } - float fontSize = cellFontSize(cell, 9.0f); + float fontSize = cellFontSize(cell, DEFAULT_TABLE_FONT_SIZE); float horizontalPadding = compact ? 0.0f : CELL_HORIZONTAL_PADDING; - int lines = cellLines(cell, font, fontSize, width, horizontalPadding).size(); + PDFont cellFont = compact ? fonts.simSun() : resolvedCellFont(cell, fonts, boldFont); + int lines = cellLines(cell, cellFont, fontSize, width, horizontalPadding).size(); + float lineHeight = gridLineHeight(fontSize * 1.35f, linePitch); height = Math.max( height, paragraphSpacingBefore(cell) - + lines * fontSize * 1.35f + + lines * lineHeight + paragraphSpacingAfter(cell) - + verticalPadding); + + rowHeightPadding); column++; } return height; @@ -471,11 +632,20 @@ private static List cellLines( float width, float padding) throws IOException { - String text = cell.getText().replace('\t', ' ').trim(); - if (cell.getCTTc().isSetTcPr() && cell.getCTTc().getTcPr().isSetNoWrap()) { - return List.of(text); + List lines = new ArrayList<>(); + for (XWPFParagraph paragraph : cell.getParagraphs()) { + String text = renderableText(paragraph.getText()).trim(); + if (cell.getCTTc().isSetTcPr() && cell.getCTTc().getTcPr().isSetNoWrap()) { + lines.add(text); + } else { + lines.addAll(wrap(font, text, fontSize, cellContentWidth(width, padding))); + } } - return wrap(font, text, fontSize, width - padding * 2.0f); + return lines.isEmpty() ? List.of("") : lines; + } + + static float cellContentWidth(float width, float padding) { + return width - padding; } private static float mergedHeight( @@ -509,7 +679,16 @@ private static boolean isMergeContinuation(XWPFTableCell cell) { return isVerticallyMerged(cell) && !isMergeRestart(cell); } - private static List wrap(PDFont font, String text, float fontSize, float width) throws IOException { + static List wrap(PDFont font, String text, float fontSize, float width) throws IOException { + return wrap(font, text, fontSize, width, DEFAULT_AUTO_SPACING); + } + + private static List wrap( + PDFont font, + String text, + float fontSize, + float width, + AutoSpacing autoSpacing) throws IOException { if (text.isEmpty()) { return List.of(""); } @@ -518,36 +697,54 @@ private static List wrap(PDFont font, String text, float fontSize, float for (int offset = 0; offset < text.length();) { int codePoint = text.codePointAt(offset); offset += Character.charCount(codePoint); - String next = line + Character.toString(codePoint); - if (!line.isEmpty() && textWidth(font, next, fontSize) > width) { - if (isClosingPunctuation(codePoint) && line.codePointCount(0, line.length()) > 1) { - int lastOffset = line.offsetByCodePoints(line.length(), -1); - String last = line.substring(lastOffset); - line.setLength(lastOffset); - lines.add(line.toString()); - line.setLength(0); - line.append(last); - } else if (endsWithOpeningPunctuation(line) - && line.codePointCount(0, line.length()) > 1) { - int lastOffset = line.offsetByCodePoints(line.length(), -1); - String opening = line.substring(lastOffset); - line.setLength(lastOffset); - lines.add(line.toString()); - line.setLength(0); - line.append(opening); - } else { - lines.add(line.toString()); - line.setLength(0); + line.appendCodePoint(codePoint); + while (line.codePointCount(0, line.length()) > 1 + && textWidth(font, line.toString(), fontSize, autoSpacing) > width) { + int breakOffset = lastWrapBoundary(line); + if (breakOffset <= 0) { + breakOffset = line.offsetByCodePoints(line.length(), -1); + } + String completed = line.substring(0, breakOffset).stripTrailing(); + String remainder = line.substring(breakOffset).stripLeading(); + if (!completed.isEmpty()) { + lines.add(completed); } + line.setLength(0); + line.append(remainder); } - line.appendCodePoint(codePoint); } lines.add(line.toString()); return lines; } - private static boolean endsWithOpeningPunctuation(StringBuilder text) { - int codePoint = text.codePointBefore(text.length()); + private static int lastWrapBoundary(StringBuilder text) { + int rightOffset = text.length(); + int right = text.codePointBefore(rightOffset); + while (rightOffset > 0) { + int leftOffset = text.offsetByCodePoints(rightOffset, -1); + if (leftOffset == 0) { + return -1; + } + int left = text.codePointBefore(leftOffset); + if (isWrapBoundary(left, right)) { + return leftOffset; + } + rightOffset = leftOffset; + right = left; + } + return -1; + } + + private static boolean isWrapBoundary(int left, int right) { + if (Character.isWhitespace(left) || Character.isWhitespace(right) || left == '-') { + return true; + } + return (isEastAsian(left) || isEastAsian(right)) + && !isOpeningPunctuation(left) + && !isClosingPunctuation(right); + } + + private static boolean isOpeningPunctuation(int codePoint) { return codePoint == '(' || codePoint == '[' || codePoint == '{' || codePoint == '\u3008' || codePoint == '\u300a' || codePoint == '\u300c' || codePoint == '\u300e' || codePoint == '\u3010' || codePoint == '\uff08'; @@ -563,6 +760,187 @@ private static boolean isClosingPunctuation(int codePoint) { private record MergeLine(XWPFTableCell cell, int index) { } + private record AutoSpacing(boolean latin, boolean digit) { + } + + private record PageNumberFooter(String prefix, String suffix) { + } + + private record PageMargins(float left, float top, float bottom, float firstPageTopOffset) { + } + + record RunSegment(String text, PDFont font, float fontSize, float leadingSpacing) { + } + + record ParagraphFonts( + PDFont fallback, + PDFont simSun, + PDFont simHei, + PDFont kai, + PDFont fangSong, + PDFont times, + PDFont arial, + String defaultAsciiFamily, + String defaultEastAsiaFamily) { + private PDFont resolve(XWPFRun run, int codePoint) { + boolean eastAsian = usesEastAsianFontSlot(codePoint); + XWPFRun.FontCharRange range = eastAsian + ? XWPFRun.FontCharRange.eastAsia + : XWPFRun.FontCharRange.ascii; + String family = run.getFontFamily(range); + if (family == null || family.isBlank()) { + family = eastAsian ? defaultEastAsiaFamily : defaultAsciiFamily; + } + String normalized = family == null ? "" : family.toLowerCase(); + if (normalized.contains("\u5b8b\u4f53") || normalized.contains("simsun")) { + return simSun; + } + if (normalized.contains("\u9ed1\u4f53") || normalized.contains("simhei")) { + return simHei; + } + if (normalized.contains("\u6977\u4f53") || normalized.contains("simkai")) { + return kai; + } + if (normalized.contains("\u4eff\u5b8b") || normalized.contains("simfang")) { + return fangSong; + } + if (normalized.contains("arial")) { + return arial; + } + if (normalized.contains("times")) { + return times; + } + return eastAsian ? simSun : fallback; + } + } + + private static boolean usesEastAsianFontSlot(int codePoint) { + Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint); + return isEastAsian(codePoint) + || block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION + || block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS; + } + + static PDFont wrappingFont(ParagraphFonts fonts, String text) { + return text.codePoints().anyMatch(PoiDocxRenderer::usesEastAsianFontSlot) + ? fonts.simSun() + : fonts.fallback(); + } + + static List paragraphSegments( + XWPFParagraph paragraph, + ParagraphFonts fonts, + float fallbackFontSize) { + List segments = new ArrayList<>(); + AutoSpacing autoSpacing = paragraphAutoSpacing(paragraph); + for (XWPFRun run : paragraph.getRuns()) { + String text = renderableText(run.text()); + if (text.isEmpty()) { + continue; + } + float fontSize = run.getFontSizeAsDouble() == null || run.getFontSizeAsDouble() <= 0.0 + ? fallbackFontSize + : run.getFontSizeAsDouble().floatValue(); + StringBuilder segmentText = new StringBuilder(); + PDFont segmentFont = null; + float segmentLeadingSpacing = 0.0f; + int previous = -1; + for (int offset = 0; offset < text.length();) { + int codePoint = text.codePointAt(offset); + offset += Character.charCount(codePoint); + PDFont codePointFont = fonts.resolve(run, codePoint); + if (segmentFont != null && codePointFont != segmentFont) { + float spacing = isEastAsianLatinBoundary(previous, codePoint, autoSpacing) + ? fontSize * 0.25f + : 0.0f; + segments.add(new RunSegment( + segmentText.toString(), segmentFont, fontSize, segmentLeadingSpacing)); + segmentText.setLength(0); + segmentFont = codePointFont; + segmentLeadingSpacing = spacing; + segmentText.appendCodePoint(codePoint); + } else { + segmentFont = codePointFont; + segmentText.appendCodePoint(codePoint); + } + previous = codePoint; + } + if (!segmentText.isEmpty()) { + segments.add(new RunSegment( + segmentText.toString(), segmentFont, fontSize, segmentLeadingSpacing)); + } + } + return segments; + } + + static PDFont resolvedCellFont(XWPFTableCell cell, ParagraphFonts fonts, PDFont boldFont) { + PDFont resolved = null; + for (XWPFParagraph paragraph : cell.getParagraphs()) { + for (XWPFRun run : paragraph.getRuns()) { + String text = renderableText(run.text()); + for (int offset = 0; offset < text.length();) { + int codePoint = text.codePointAt(offset); + offset += Character.charCount(codePoint); + PDFont candidate = run.isBold() ? boldFont : fonts.resolve(run, codePoint); + if (resolved != null && resolved != candidate) { + return isCellBold(cell) ? boldFont : fonts.simSun(); + } + resolved = candidate; + } + } + } + return resolved == null + ? isCellBold(cell) ? boldFont : fonts.simSun() + : resolved; + } + + private static AutoSpacing paragraphAutoSpacing(XWPFParagraph paragraph) { + if (!paragraph.getCTP().isSetPPr()) { + return DEFAULT_AUTO_SPACING; + } + var properties = paragraph.getCTP().getPPr(); + boolean latin = !properties.isSetAutoSpaceDE() + || onOffValue(properties.getAutoSpaceDE().getVal()); + boolean digit = !properties.isSetAutoSpaceDN() + || onOffValue(properties.getAutoSpaceDN().getVal()); + return new AutoSpacing(latin, digit); + } + + private static boolean onOffValue(Object value) { + return value == null + || !("0".equals(value.toString()) || "false".equalsIgnoreCase(value.toString()) + || "off".equalsIgnoreCase(value.toString())); + } + + private static String latinText(String text) { + return text.codePoints() + .filter(codePoint -> codePoint <= 0xff) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + } + + private static String defaultFontFamily(XWPFDocument document, XWPFRun.FontCharRange range) { + if (document.getStyles() == null || document.getStyles().getCtStyles() == null) { + return null; + } + var styles = document.getStyles().getCtStyles(); + if (!styles.isSetDocDefaults() + || !styles.getDocDefaults().isSetRPrDefault() + || !styles.getDocDefaults().getRPrDefault().isSetRPr()) { + return null; + } + var properties = styles.getDocDefaults().getRPrDefault().getRPr(); + if (properties.sizeOfRFontsArray() == 0) { + return null; + } + var fonts = properties.getRFontsArray(0); + return switch (range) { + case ascii -> fonts.isSetAscii() ? fonts.getAscii() : fonts.getHAnsi(); + case eastAsia -> fonts.isSetEastAsia() ? fonts.getEastAsia() : null; + default -> null; + }; + } + private static float paragraphFontSize(XWPFParagraph paragraph, float fallback) { Float runSize = paragraph.getRuns().stream() .map(XWPFRun::getFontSizeAsDouble) @@ -593,20 +971,151 @@ private static float cellFontSize(XWPFTableCell cell, float fallback) { .orElse(fallback); } - private static float pageMargin(XWPFDocument document, float fallback) { + private static PageMargins pageMargins(XWPFDocument document, float fallback, boolean landscape) { var body = document.getDocument().getBody(); if (!body.isSetSectPr() || !body.getSectPr().isSetPgMar()) { + return new PageMargins(fallback, fallback, fallback, 10.0f); + } + var margins = body.getSectPr().getPgMar(); + float left = twipsValue(margins.getLeft(), fallback); + if (!landscape) { + return new PageMargins(left, left, left, 10.0f); + } + float top = twipsValue(margins.getTop(), left); + float bottom = twipsValue(margins.getBottom(), left); + return new PageMargins(left, top, bottom, 0.0f); + } + + private static float twipsValue(Object value, float fallback) { + if (value == null) { return fallback; } try { - return Float.parseFloat(body.getSectPr().getPgMar().getLeft().toString()) / 20.0f; + return Float.parseFloat(value.toString()) / 20.0f; + } catch (NumberFormatException exception) { + return fallback; + } + } + + private static float documentLinePitch(XWPFDocument document) { + var body = document.getDocument().getBody(); + if (!body.isSetSectPr() || !body.getSectPr().isSetDocGrid() + || body.getSectPr().getDocGrid().getLinePitch() == null) { + return 0.0f; + } + try { + return Float.parseFloat(body.getSectPr().getDocGrid().getLinePitch().toString()) / 20.0f; + } catch (NumberFormatException exception) { + return 0.0f; + } + } + + private static PageNumberFooter pageNumberFooter(XWPFDocument document) { + if (document.getFooterList().isEmpty()) { + return null; + } + PageNumberFooter result = null; + for (var footer : document.getFooterList()) { + boolean hasPageField = footer.getParagraphs().stream() + .flatMap(paragraph -> paragraph.getRuns().stream()) + .flatMap(run -> run.getCTR().getInstrTextList().stream()) + .map(instruction -> instruction.getStringValue().trim()) + .anyMatch(instruction -> PAGE_FIELD.matcher(instruction).find()); + if (!hasPageField) { + return null; + } + Matcher cachedPage = CACHED_PAGE_NUMBER.matcher(footer.getText().strip()); + if (!cachedPage.matches()) { + continue; + } + PageNumberFooter candidate = new PageNumberFooter(cachedPage.group(1), cachedPage.group(3)); + if (result != null && !result.equals(candidate)) { + return null; + } + result = candidate; + } + return result == null ? new PageNumberFooter("", "") : result; + } + + private static float pageFooterDistance(XWPFDocument document, float fallback) { + var body = document.getDocument().getBody(); + if (!body.isSetSectPr() || !body.getSectPr().isSetPgMar() + || body.getSectPr().getPgMar().getFooter() == null) { + return fallback; + } + try { + return Float.parseFloat(body.getSectPr().getPgMar().getFooter().toString()) / 20.0f; } catch (NumberFormatException exception) { return fallback; } } private static float textWidth(PDFont font, String text, float fontSize) throws IOException { - return font.getStringWidth(text) / 1000.0f * fontSize; + return textWidth(font, text, fontSize, DEFAULT_AUTO_SPACING); + } + + private static float textWidth( + PDFont font, + String text, + float fontSize, + AutoSpacing autoSpacing) throws IOException { + return font.getStringWidth(text) / 1000.0f * fontSize + + eastAsianBoundaryCount(text, autoSpacing) * fontSize * 0.25f; + } + + static int eastAsianBoundaryCount(String text) { + return eastAsianBoundaryCount(text, DEFAULT_AUTO_SPACING); + } + + private static int eastAsianBoundaryCount(String text, AutoSpacing autoSpacing) { + int boundaries = 0; + int previous = -1; + for (int offset = 0; offset < text.length();) { + int codePoint = text.codePointAt(offset); + offset += Character.charCount(codePoint); + if (previous >= 0 && isEastAsianLatinBoundary(previous, codePoint, autoSpacing)) { + boundaries++; + } + previous = codePoint; + } + return boundaries; + } + + private static boolean isEastAsianLatinBoundary(int left, int right, AutoSpacing autoSpacing) { + if (isEastAsian(left)) { + return isSpacedLatinOrDigit(right, autoSpacing); + } + return isSpacedLatinOrDigit(left, autoSpacing) && isEastAsian(right); + } + + private static boolean isSpacedLatinOrDigit(int codePoint, AutoSpacing autoSpacing) { + return Character.isDigit(codePoint) + ? autoSpacing.digit() + : autoSpacing.latin() + && Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.LATIN; + } + + private static boolean isEastAsian(int codePoint) { + Character.UnicodeScript script = Character.UnicodeScript.of(codePoint); + return script == Character.UnicodeScript.HAN + || script == Character.UnicodeScript.HIRAGANA + || script == Character.UnicodeScript.KATAKANA + || script == Character.UnicodeScript.HANGUL; + } + + private static String renderableText(String text) { + return text.replace('\r', ' ').replace('\n', ' ').replace('\t', ' '); + } + + private static float gridLineHeight(float naturalHeight, float linePitch) { + if (linePitch <= 0.0f) { + return naturalHeight; + } + return (float) Math.ceil(naturalHeight / linePitch) * linePitch; + } + + static float centeredTextX(float pageWidth, float leftIndent, float rightIndent, float textWidth) { + return (pageWidth + leftIndent - rightIndent - textWidth) / 2.0f; } private static float stylePoints(String xml, String property) { @@ -621,13 +1130,42 @@ private static void showText( String text, float x, float y) throws IOException { + showText(content, font, fontSize, text, x, y, DEFAULT_AUTO_SPACING); + } + + private static void showText( + PDPageContentStream content, + PDFont font, + float fontSize, + String text, + float x, + float y, + AutoSpacing autoSpacing) throws IOException { if (text.isEmpty()) { return; } content.beginText(); content.setFont(font, fontSize); content.newLineAtOffset(x, y); - content.showText(text); + if (eastAsianBoundaryCount(text, autoSpacing) == 0) { + content.showText(text); + } else { + List positioned = new ArrayList<>(); + int start = 0; + int previous = -1; + for (int offset = 0; offset < text.length();) { + int codePoint = text.codePointAt(offset); + if (previous >= 0 && isEastAsianLatinBoundary(previous, codePoint, autoSpacing)) { + positioned.add(text.substring(start, offset)); + positioned.add(-250.0f); + start = offset; + } + offset += Character.charCount(codePoint); + previous = codePoint; + } + positioned.add(text.substring(start)); + content.showTextWithPositioning(positioned.toArray()); + } content.endText(); } @@ -643,24 +1181,50 @@ private static float twipsToPoints(int twips) { return Math.max(0, twips) / 20.0f; } + static float indentationToPoints(int twips) { + return twips == -1 ? 0.0f : twips / 20.0f; + } + private static final class PageContext implements AutoCloseable { private final PDDocument document; private final PageSize pageSize; private final float margin; + private final float topMargin; + private final float bottomMargin; + private final float linePitch; + private final PDFont pageNumberFont; + private final PageNumberFooter pageNumberFooter; + private final float pageFooterDistance; private PDPageContentStream content; private float y; private int pageCount; - private PageContext(PDDocument document, PageSize pageSize, float margin, float topOffset) + private PageContext( + PDDocument document, + PageSize pageSize, + float margin, + float topMargin, + float bottomMargin, + float linePitch, + float topOffset, + PDFont pageNumberFont, + PageNumberFooter pageNumberFooter, + float pageFooterDistance) throws IOException { this.document = document; this.pageSize = pageSize; this.margin = margin; + this.topMargin = topMargin; + this.bottomMargin = bottomMargin; + this.linePitch = linePitch; + this.pageNumberFont = pageNumberFont; + this.pageNumberFooter = pageNumberFooter; + this.pageFooterDistance = pageFooterDistance; newPage(topOffset); } private void ensureSpace(float height) throws IOException { - if (y - height < margin) { + if (y - height < bottomMargin) { newPage(); } } @@ -676,18 +1240,30 @@ private void newPage() throws IOException { private void newPage(float topOffset) throws IOException { if (content != null) { + renderPageNumber(); content.close(); } PDPage page = new PDPage(new PDRectangle(pageSize.width(), pageSize.height())); document.addPage(page); pageCount++; content = new PDPageContentStream(document, page); - y = pageSize.height() - margin - topOffset; + y = pageSize.height() - topMargin - topOffset; + } + + private void renderPageNumber() throws IOException { + if (pageNumberFont == null) { + return; + } + String pageNumber = pageNumberFooter.prefix() + pageCount + pageNumberFooter.suffix(); + float fontSize = 9.0f; + float x = (pageSize.width() - textWidth(pageNumberFont, pageNumber, fontSize)) / 2.0f; + showText(content, pageNumberFont, fontSize, pageNumber, x, pageFooterDistance); } @Override public void close() throws IOException { if (content != null) { + renderPageNumber(); content.close(); content = null; } diff --git a/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/ClassicFixtureSmokeTest.java b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/ClassicFixtureSmokeTest.java index 95188f5d..e0acb86e 100644 --- a/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/ClassicFixtureSmokeTest.java +++ b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/ClassicFixtureSmokeTest.java @@ -19,6 +19,7 @@ import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class ClassicFixtureSmokeTest { @@ -244,6 +245,68 @@ void rendersIssue93AsSinglePageForm() throws Exception { } } + @Test + void rendersIssue66AsThreePageForm() throws Exception { + String windows = System.getenv("WINDIR"); + org.junit.jupiter.api.Assumptions.assumeTrue( + windows != null && Files.isRegularFile(Path.of(windows, "Fonts", "msyh.ttc")), + "requires Microsoft YaHei"); + Path fixture = REPOSITORY_ROOT.resolve("tests/Issue_Files/docx/issues66.docx"); + + try (PDDocument document = Loader.loadPDF(MiniPdf.convertToPdfBytes(fixture))) { + String text = new PDFTextStripper().getText(document); + assertEquals(3, document.getNumberOfPages()); + assertTrue(text.contains("\u5fae\u7eb3\u52a0\u5de5\u5e73\u53f0\u5de5\u827a\u7533\u8bf7\u8868"), text); + assertTrue(text.contains("985 \u9ad8\u6821"), text); + PDFTextStripper firstPageStripper = new PDFTextStripper(); + firstPageStripper.setStartPage(1); + firstPageStripper.setEndPage(1); + String firstPage = firstPageStripper.getText(document); + assertFalse(firstPage.contains("\u4f7f\u7528\u6d89\u53ca\u771f\u7a7a\u7684\u8bbe\u5907"), firstPage); + assertTrue(firstPage.lines().anyMatch(line -> line.trim().equals("1")), firstPage); + PDFTextStripper secondPageStripper = new PDFTextStripper(); + secondPageStripper.setStartPage(2); + secondPageStripper.setEndPage(2); + assertTrue(secondPageStripper.getText(document).lines() + .anyMatch(line -> line.trim().equals("2"))); + PDFTextStripper thirdPageStripper = new PDFTextStripper(); + thirdPageStripper.setStartPage(3); + thirdPageStripper.setEndPage(3); + assertTrue(thirdPageStripper.getText(document).lines() + .anyMatch(line -> line.trim().equals("3"))); + } + } + + @Test + void skipsUnsupportedDocxPictures() throws Exception { + Path fixture = REPOSITORY_ROOT.resolve("tests/Issue_Files/docx/OSCAR WARD.docx"); + + try (PDDocument document = Loader.loadPDF(MiniPdf.convertToPdfBytes(fixture))) { + assertTrue(document.getNumberOfPages() > 0); + } + } + + @Test + void preservesLiteralTextAroundPageNumberField() throws Exception { + String windows = System.getenv("WINDIR"); + org.junit.jupiter.api.Assumptions.assumeTrue( + windows != null && Files.isRegularFile(Path.of(windows, "Fonts", "msyh.ttc")), + "requires Microsoft YaHei"); + Path fixture = REPOSITORY_ROOT.resolve("tests/Issue_Files/docx/issue202605.docx"); + + try (PDDocument document = Loader.loadPDF(MiniPdf.convertToPdfBytes(fixture))) { + assertEquals(3, document.getNumberOfPages()); + for (int page = 1; page <= document.getNumberOfPages(); page++) { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(page); + stripper.setEndPage(page); + String expected = page + " \u9801"; + assertTrue(stripper.getText(document).lines() + .anyMatch(line -> line.trim().equals(expected)), "missing " + expected); + } + } + } + @Test void convertsIssuePptx() throws Exception { Path fixture = REPOSITORY_ROOT.resolve("tests/Issue_Files/pptx/Asian Pacific.pptx"); diff --git a/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/internal/docx/PoiDocxRendererTest.java b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/internal/docx/PoiDocxRendererTest.java new file mode 100644 index 00000000..4c1c145f --- /dev/null +++ b/minipdf-java/minipdf/src/test/java/io/github/minisoftware/minipdf/internal/docx/PoiDocxRendererTest.java @@ -0,0 +1,160 @@ +package io.github.minisoftware.minipdf.internal.docx; + +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +class PoiDocxRendererTest { + @Test + void centersTextWithinAsymmetricallyIndentedLineBox() { + assertEquals(250.0f, PoiDocxRenderer.centeredTextX(500.0f, 0.0f, -100.0f, 100.0f)); + assertEquals(200.0f, PoiDocxRenderer.centeredTextX(500.0f, 0.0f, 0.0f, 100.0f)); + } + + @Test + void preservesNegativeParagraphIndentsButNormalizesUnsetValue() { + assertEquals(-52.6f, PoiDocxRenderer.indentationToPoints(-1052)); + assertEquals(0.0f, PoiDocxRenderer.indentationToPoints(-1)); + } + + @Test + void reservesOneCellInsetFromWrappingWidth() { + assertEquals(78.4f, PoiDocxRenderer.cellContentWidth(83.8f, 5.4f)); + } + + @Test + void wrapsLatinTextAtHyphensAndSpacesBeforeSplittingCharacters() throws Exception { + var font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + float fontSize = 10.5f; + float width = font.getStringWidth("88888888
") / 1000.0f * fontSize + 0.1f; + + assertEquals( + List.of("020-", "88888888
", "13888888888"), + PoiDocxRenderer.wrap(font, "020-88888888
13888888888", fontSize, width)); + } + + @Test + void countsEastAsianLatinAndDigitBoundaries() { + assertEquals(9, PoiDocxRenderer.eastAsianBoundaryCount("2025年11月26日14时40分")); + assertEquals(2, PoiDocxRenderer.eastAsianBoundaryCount("使用PECVD设备")); + assertEquals(0, PoiDocxRenderer.eastAsianBoundaryCount("纯中文")); + assertEquals(0, PoiDocxRenderer.eastAsianBoundaryCount("ASCII only")); + } + + @Test + void wrapsEastAsianParagraphsWithDocumentEastAsianFont() { + var fallback = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + var simSun = new PDType1Font(Standard14Fonts.FontName.COURIER); + var fonts = new PoiDocxRenderer.ParagraphFonts( + fallback, + simSun, + fallback, + fallback, + fallback, + fallback, + fallback, + "Times New Roman", + "SimSun"); + + assertSame(simSun, PoiDocxRenderer.wrappingFont(fonts, "使用PECVD设备")); + assertSame(fallback, PoiDocxRenderer.wrappingFont(fonts, "ASCII only")); + } + + @Test + void resolvesFontAndBoldFromHomogeneousCellRuns() throws Exception { + var fallback = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + var simHei = new PDType1Font(Standard14Fonts.FontName.COURIER); + var times = new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN); + var fonts = new PoiDocxRenderer.ParagraphFonts( + fallback, + fallback, + simHei, + fallback, + fallback, + times, + fallback, + "Times New Roman", + "SimSun"); + try (var document = new XWPFDocument()) { + var cell = document.createTable(1, 1).getRow(0).getCell(0); + var run = cell.getParagraphs().get(0).createRun(); + run.setFontFamily("SimHei", org.apache.poi.xwpf.usermodel.XWPFRun.FontCharRange.eastAsia); + run.setText("中"); + + assertSame(simHei, PoiDocxRenderer.resolvedCellFont(cell, fonts, fallback)); + + run.setBold(true); + assertSame(fallback, PoiDocxRenderer.resolvedCellFont(cell, fonts, fallback)); + } + } + + @Test + void splitsInheritedMixedScriptRunByDocumentFontSlots() throws Exception { + var fallback = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + var simSun = new PDType1Font(Standard14Fonts.FontName.COURIER); + var times = new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN); + var fonts = new PoiDocxRenderer.ParagraphFonts( + fallback, + simSun, + fallback, + fallback, + fallback, + times, + fallback, + "Times New Roman", + "SimSun"); + try (var document = new XWPFDocument()) { + var paragraph = document.createParagraph(); + paragraph.createRun().setText("A中B"); + + List segments = + PoiDocxRenderer.paragraphSegments(paragraph, fonts, 10.0f); + + assertEquals(List.of("A", "中", "B"), segments.stream() + .map(PoiDocxRenderer.RunSegment::text) + .toList()); + assertSame(times, segments.get(0).font()); + assertSame(simSun, segments.get(1).font()); + assertSame(times, segments.get(2).font()); + assertEquals(0.0f, segments.get(0).leadingSpacing()); + assertEquals(2.5f, segments.get(1).leadingSpacing()); + assertEquals(2.5f, segments.get(2).leadingSpacing()); + } + } + + @Test + void suppressesMixedScriptSpacingWhenParagraphDisablesIt() throws Exception { + var fallback = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + var simSun = new PDType1Font(Standard14Fonts.FontName.COURIER); + var fonts = new PoiDocxRenderer.ParagraphFonts( + fallback, + simSun, + fallback, + fallback, + fallback, + fallback, + fallback, + "Times New Roman", + "SimSun"); + try (var document = new XWPFDocument()) { + var paragraph = document.createParagraph(); + var properties = paragraph.getCTP().addNewPPr(); + properties.addNewAutoSpaceDE().setVal(false); + properties.addNewAutoSpaceDN().setVal(false); + paragraph.createRun().setText("A中1"); + + List segments = + PoiDocxRenderer.paragraphSegments(paragraph, fonts, 10.0f); + + assertEquals(List.of(0.0f, 0.0f, 0.0f), segments.stream() + .map(PoiDocxRenderer.RunSegment::leadingSpacing) + .toList()); + } + } +} \ No newline at end of file