From c57a2a45deb03b52928699fc6ef0178802e0200d Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Tue, 1 Sep 2026 20:55:59 +0100
Subject: [PATCH] Add v2 client-side PDF text editor (#6500)
# Description of Changes
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---
app/core/build.gradle | 3 +
.../api/PdfTextEditorCharcodeController.java | 598 ++
.../service/pdfjson/PdfJsonFontService.java | 6 +-
app/core/src/main/resources/logback.xml | 47 +-
.../config/ToolIODeclarationCoverageTest.java | 2 +
.../api/PdfBoxFontEncodingProbeTest.java | 516 ++
.../PdfTextEditorCharcodeControllerTest.java | 755 ++
.../controller/api/SamplePdfFontDumpTest.java | 340 +
.../pdfjson/PdfJsonFontServiceMoreTest.java | 4 +-
.../pdfjson/PdfJsonFontServiceTest.java | 13 +-
.../resources/pdftexteditor/mushroom-life.pdf | Bin 0 -> 66890 bytes
.../service/ua/FontEmbeddingService.java | 43 +
.../service/ua/PdfUaRealCorpusTest.java | 16 +-
engine/src/stirling/models/tool_models.py | 14 +
frontend/editor/playwright.config.ts | 21 +-
frontend/editor/public/fonts/NotoSans-OFL.txt | 94 +
.../editor/public/fonts/NotoSans-Regular.ttf | Bin 0 -> 629024 bytes
.../public/locales/en-US/translation.toml | 414 +-
.../fileEditor/FileEditorThumbnail.tsx | 5 +-
.../core/components/shared/FileSidebar.tsx | 6 +-
.../core/components/shared/WorkbenchBar.tsx | 18 +-
.../tools/pdfTextEditor/FontStatusPanel.tsx | 372 -
.../pdfTextEditor/PdfTextEditorSidebar.tsx | 437 --
.../tools/pdfTextEditor/PdfTextEditorView.tsx | 2904 --------
.../core/data/useTranslatedToolRegistry.tsx | 1 -
frontend/editor/src/core/env.test.ts | 21 +-
.../__tests__/httpErrorHandler.test.ts | 64 +
.../editor/src/core/services/pdfiumService.ts | 37 +-
.../pdf-text-editor-charcode-backend.spec.ts | 204 +
.../src/core/tests/stubbed/editorTestTypes.ts | 147 +
.../pdf-text-editor-annotations.spec.ts | 126 +
.../pdf-text-editor-beforeinput.spec.ts | 83 +
.../pdf-text-editor-caret-drift.spec.ts | 129 +
.../stubbed/pdf-text-editor-clip-path.spec.ts | 106 +
.../pdf-text-editor-combined-features.spec.ts | 881 +++
.../stubbed/pdf-text-editor-cropbox.spec.ts | 333 +
...df-text-editor-cross-font-charcode.spec.ts | 91 +
.../pdf-text-editor-double-edit.spec.ts | 203 +
.../pdf-text-editor-edge-gestures.spec.ts | 169 +
.../stubbed/pdf-text-editor-edit-mask.spec.ts | 96 +
.../pdf-text-editor-enter-and-drift.spec.ts | 272 +
.../pdf-text-editor-enter-font.spec.ts | 156 +
.../pdf-text-editor-exact-placement.spec.ts | 159 +
.../pdf-text-editor-fix-colour-picker.spec.ts | 331 +
.../pdf-text-editor-fix-empty-state.spec.ts | 161 +
...df-text-editor-fix-image-selection.spec.ts | 348 +
...pdf-text-editor-fix-mixed-fontsize.spec.ts | 250 +
.../pdf-text-editor-fix-page-prefetch.spec.ts | 254 +
.../pdf-text-editor-fix-pristine-save.spec.ts | 120 +
...df-text-editor-fix-rotated-overlay.spec.ts | 183 +
...t-editor-fix-undo-duplicate-glyphs.spec.ts | 171 +
.../pdf-text-editor-fix-undo-ink.spec.ts | 142 +
.../pdf-text-editor-focus-after-edit.spec.ts | 164 +
.../pdf-text-editor-font-capability.spec.ts | 180 +
.../pdf-text-editor-font-coverage.spec.ts | 55 +
.../pdf-text-editor-font-recognition.spec.ts | 120 +
.../stubbed/pdf-text-editor-hidpi.spec.ts | 100 +
.../pdf-text-editor-jpeg-insert.spec.ts | 57 +
.../pdf-text-editor-known-issues.spec.ts | 535 ++
.../pdf-text-editor-letter-spacing.spec.ts | 142 +
.../pdf-text-editor-menu-layout.spec.ts | 287 +
.../pdf-text-editor-model-sync.spec.ts | 130 +
.../pdf-text-editor-multi-run-undo.spec.ts | 205 +
.../pdf-text-editor-mushroom-scramble.spec.ts | 121 +
.../pdf-text-editor-mushroom-spaces.spec.ts | 164 +
.../pdf-text-editor-newline-register.spec.ts | 168 +
.../pdf-text-editor-paragraphs.spec.ts | 707 ++
.../pdf-text-editor-pattern-fill.spec.ts | 67 +
...-editor-protected-and-save-warning.spec.ts | 123 +
.../pdf-text-editor-reflow-justified.spec.ts | 147 +
.../pdf-text-editor-reported-issues.spec.ts | 455 ++
.../pdf-text-editor-rotated-text.spec.ts | 143 +
.../pdf-text-editor-selection-scope.spec.ts | 202 +
.../stubbed/pdf-text-editor-stability.spec.ts | 90 +
.../stubbed/pdf-text-editor-text-fit.spec.ts | 119 +
.../pdf-text-editor-toolbar-controls.spec.ts | 125 +
.../stubbed/pdf-text-editor-type3.spec.ts | 129 +
.../pdf-text-editor-unicode-fallback.spec.ts | 278 +
...f-text-editor-upgrades-walkthrough.spec.ts | 199 +
.../pdf-text-editor-vis-addtext.spec.ts | 738 ++
.../pdf-text-editor-vis-colour.spec.ts | 756 ++
.../pdf-text-editor-vis-fontsize.spec.ts | 677 ++
.../pdf-text-editor-vis-images.spec.ts | 807 +++
.../stubbed/pdf-text-editor-vis-pages.spec.ts | 942 +++
.../pdf-text-editor-vis-rotation.spec.ts | 842 +++
.../pdf-text-editor-vis-roundtrip.spec.ts | 834 +++
.../pdf-text-editor-vis-selection.spec.ts | 802 +++
.../pdf-text-editor-vis-undoredo.spec.ts | 786 +++
.../stubbed/pdf-text-editor-vis-zoom.spec.ts | 758 ++
.../pdf-text-editor-width-modes.spec.ts | 402 ++
.../pdf-text-editor-workbench-files.spec.ts | 117 +
.../pdf-text-editor-workbench-save.spec.ts | 139 +
.../stubbed/pdf-text-editor-wrap.spec.ts | 271 +
.../tests/stubbed/pdf-text-editor.spec.ts | 6134 +++++++++++++++++
.../src/core/tests/stubbed/saveHelpers.ts | 76 +
.../test-fixtures/annotation-text-sample.pdf | Bin 0 -> 2121 bytes
.../core/tests/test-fixtures/big-sample.pdf | Bin 0 -> 304865 bytes
.../tests/test-fixtures/cropbox-control.pdf | 33 +
.../tests/test-fixtures/cropbox-offset.pdf | 33 +
.../tests/test-fixtures/cropbox-rotate90.pdf | 33 +
.../test-fixtures/form-xobject-sample.pdf | Bin 0 -> 2017 bytes
.../generate-annotation-text-sample.mjs | 73 +
.../test-fixtures/generate-big-sample.mjs | 52 +
.../generate-cropbox-fixtures.py | 70 +
.../generate-form-xobject-sample.mjs | 68 +
.../generate-many-pages-sample.mjs | 40 +
.../generate-paragraph-sample.mjs | 55 +
.../generate-rotated-text-sample.py | 54 +
.../generate-shading-and-justified-sample.mjs | 111 +
.../test-fixtures/generate-signed-sample.py | 67 +
.../generate-split-contents-sample.mjs | 57 +
.../generate-subset-font-sample.py | 77 +
.../tests/test-fixtures/justified-sample.pdf | Bin 0 -> 1178 bytes
.../test-fixtures/letter-spacing-sample.pdf | 43 +
.../tests/test-fixtures/many-pages-sample.pdf | Bin 0 -> 4135 bytes
.../tests/test-fixtures/multi-page-sample.pdf | Bin 0 -> 264772 bytes
.../tests/test-fixtures/mushroom-life.pdf | Bin 0 -> 66890 bytes
.../tests/test-fixtures/paragraph-sample.pdf | Bin 0 -> 1390 bytes
.../test-fixtures/pattern-fill-sample.pdf | 42 +
.../test-fixtures/rotated-text-sample.pdf | 33 +
.../tests/test-fixtures/shading-sample.pdf | Bin 0 -> 1189 bytes
.../tests/test-fixtures/signed-sample.pdf | 37 +
.../test-fixtures/split-contents-sample.pdf | Bin 0 -> 1195 bytes
.../test-fixtures/stirling-marketing.pdf | Bin 0 -> 257765 bytes
.../test-fixtures/subset-font-sample.pdf | Bin 0 -> 5315 bytes
.../core/tests/test-fixtures/type3-sample.pdf | 59 +
.../core/tests/test-fixtures/user-sample.pdf | Bin 0 -> 264772 bytes
.../tools/pdfTextEditor/PdfTextEditor.tsx | 2529 ++-----
.../__tests__/BackendResolver.test.ts | 357 +
.../__tests__/BulletGrouping.test.ts | 104 +
.../__tests__/ChangeZOrderCommand.test.ts | 141 +
.../__tests__/CmapResolver.test.ts | 286 +
.../pdfTextEditor/__tests__/Color.test.ts | 63 +
.../__tests__/DisplayTransform.test.ts | 428 ++
.../__tests__/FontBorrowing.test.ts | 246 +
.../__tests__/HistoryStack.test.ts | 226 +
.../__tests__/LineGrouperSpacing.test.ts | 170 +
.../__tests__/ParagraphEdit.test.ts | 255 +
.../__tests__/PdfiumPageRenderer.test.ts | 42 +
.../__tests__/ReplaceImageCommand.test.ts | 314 +
.../pdfTextEditor/__tests__/affine.test.ts | 126 +
.../__tests__/canvasBackground.test.ts | 107 +
.../__tests__/cloneParagraphLineSlot.test.ts | 53 +
.../__tests__/deviceFontEmbed.test.ts | 453 ++
.../__tests__/documentRisks.test.ts | 124 +
.../__tests__/editorDirtyState.test.ts | 124 +
.../__tests__/embeddedFace.test.ts | 365 +
.../__tests__/exactLayout.test.ts | 192 +
.../__tests__/externalImageEdit.test.ts | 326 +
.../pdfTextEditor/__tests__/fitText.test.ts | 57 +
.../__tests__/fontCapability.test.ts | 169 +
.../__tests__/fontFamily.test.ts | 66 +
.../pdfTextEditor/__tests__/guides.test.ts | 419 ++
.../__tests__/helveticaVariant.test.ts | 50 +
.../__tests__/historyFailure.test.ts | 61 +
.../__tests__/lineLayout.test.ts | 106 +
.../__tests__/localFonts.test.ts | 202 +
.../__tests__/overlayPainter.test.ts | 380 +
.../pdfTextEditor/__tests__/pageFonts.test.ts | 156 +
.../pdfTextEditor/__tests__/pdfFixtures.ts | 140 +
.../pdfTextEditor/__tests__/pdfPasses.test.ts | 296 +
.../__tests__/pdfRevision.test.ts | 149 +
.../__tests__/prepareFixtures.test.ts | 55 +
.../pdfTextEditor/__tests__/rawPdf.test.ts | 238 +
.../pdfTextEditor/__tests__/rotation.test.ts | 59 +
.../pdfTextEditor/__tests__/sha256.test.ts | 40 +
.../__tests__/spellcheck.test.ts | 226 +
.../__tests__/textMatching.test.ts | 270 +
.../__tests__/toolbarState.test.ts | 54 +
.../pdfTextEditor/charcode/BackendResolver.ts | 842 +++
.../charcode/CharcodeStrategy.ts | 82 +
.../pdfTextEditor/charcode/CmapResolver.ts | 339 +
.../charcode/ContentStreamResolver.ts | 139 +
.../charcode/charcodeRegistry.ts | 185 +
.../commands/AlignParagraphLinesCommand.ts | 153 +
.../commands/ChangeZOrderCommand.ts | 140 +
.../tools/pdfTextEditor/commands/Command.ts | 18 +
.../commands/CompositeCommand.ts | 40 +
.../commands/DeleteImageCommand.ts | 112 +
.../commands/DeleteObjectCommand.ts | 94 +
.../commands/DuplicateRunCommand.ts | 100 +
.../pdfTextEditor/commands/EditTextCommand.ts | 1698 +++++
.../commands/InsertImageCommand.ts | 203 +
.../commands/InsertTextCommand.ts | 133 +
.../commands/MergeRunsCommand.ts | 249 +
.../commands/MoveTextRunCommand.ts | 100 +
.../commands/ReflowWrapCommand.ts | 660 ++
.../commands/ReplaceImageCommand.ts | 265 +
.../commands/SetColourCommand.ts | 117 +
.../commands/SetFontFamilyCommand.ts | 251 +
.../commands/SetFontSizeCommand.ts | 163 +
.../commands/SetImageTransformCommand.ts | 124 +
.../pdfTextEditor/commands/SetLockCommand.ts | 62 +
.../commands/SetTextOutlineCommand.ts | 223 +
.../commands/TransformImageObjectCommand.ts | 158 +
.../commands/UngroupParagraphCommand.ts | 157 +
.../pdfTextEditor/commands/editTextHelpers.ts | 1565 +++++
.../pdfTextEditor/commands/partialEdit.ts | 1460 ++++
.../components/AnnotationOutline.css | 21 +
.../components/AnnotationOutline.tsx | 80 +
.../components/EditorFileInputs.tsx | 34 +
.../components/EditorFileSwitcher.tsx | 66 +
.../components/EditorSaveBar.tsx | 113 +
.../components/EditorSidebar.tsx | 331 +
.../pdfTextEditor/components/FindBar.tsx | 351 +
.../components/FontFamilySelect.tsx | 199 +
.../pdfTextEditor/components/HelpOverlay.tsx | 321 +
.../pdfTextEditor/components/ImageHandle.tsx | 166 +
.../components/MarqueeSelector.tsx | 111 +
.../pdfTextEditor/components/PageRulers.tsx | 481 ++
.../pdfTextEditor/components/PageStage.tsx | 343 +
.../pdfTextEditor/components/PageView.tsx | 375 +
.../components/PasswordPromptModal.tsx | 99 +
.../components/SaveRiskModal.tsx | 71 +
.../components/SpellcheckControl.tsx | 88 +
.../components/TextRunOverlay.css | 24 +
.../components/TextRunOverlay.tsx | 1073 +++
.../pdfTextEditor/components/Toolbar.tsx | 578 ++
.../pdfTextEditor/components/ZoomPill.tsx | 108 +
.../inspector/DocumentInspector.tsx | 242 +
.../components/inspector/DocumentSettings.tsx | 160 +
.../inspector/InspectorPrimitives.tsx | 155 +
.../inspector/SelectionInspector.tsx | 400 ++
.../core/tools/pdfTextEditor/fontAnalysis.ts | 504 --
.../pdfTextEditor/hooks/useAutoLoadFile.ts | 145 +
.../hooks/useDevicePixelRatio.ts | 34 +
.../pdfTextEditor/hooks/useDocumentLoader.ts | 179 +
.../pdfTextEditor/hooks/useEditorClipboard.ts | 175 +
.../hooks/useEditorKeyboardShortcuts.ts | 185 +
.../pdfTextEditor/hooks/useEditorStore.ts | 59 +
.../hooks/useEditorTestGlobal.ts | 14 +
.../hooks/useSelectionActions.ts | 276 +
.../hooks/useSelectionGeometry.ts | 110 +
.../hooks/useToolbarController.ts | 529 ++
.../hooks/useUnsavedChangesGuard.ts | 31 +
.../pdfTextEditor/hooks/useWorkbenchPin.ts | 92 +
.../pdfTextEditor/model/AnnotationBox.ts | 22 +
.../core/tools/pdfTextEditor/model/Color.ts | 46 +
.../pdfTextEditor/model/DisplayTransform.ts | 357 +
.../pdfTextEditor/model/EditorDocument.ts | 188 +
.../core/tools/pdfTextEditor/model/FontRef.ts | 32 +
.../tools/pdfTextEditor/model/ImageObject.ts | 44 +
.../core/tools/pdfTextEditor/model/Page.ts | 117 +
.../core/tools/pdfTextEditor/model/TextRun.ts | 208 +
.../core/tools/pdfTextEditor/model/affine.ts | 96 +
.../tools/pdfTextEditor/pdfTextEditorTypes.ts | 233 -
.../tools/pdfTextEditor/pdfTextEditorUtils.ts | 1525 ----
.../core/tools/pdfTextEditor/pdfdoc/bytes.ts | 145 +
.../tools/pdfTextEditor/pdfdoc/contentOps.ts | 180 +
.../pdfdoc/passes/consolidateContents.ts | 92 +
.../pdfdoc/passes/preserveShadings.ts | 253 +
.../pdfTextEditor/pdfdoc/prepareForEditing.ts | 58 +
.../core/tools/pdfTextEditor/pdfdoc/raw.ts | 686 ++
.../tools/pdfTextEditor/pdfdoc/revision.ts | 156 +
.../pdfTextEditor/pdfium/BackgroundSampler.ts | 135 +
.../tools/pdfTextEditor/pdfium/LineGrouper.ts | 225 +
.../pdfTextEditor/pdfium/ParagraphGrouper.ts | 327 +
.../pdfium/PdfiumAnnotationReader.ts | 92 +
.../pdfTextEditor/pdfium/PdfiumModelSync.ts | 126 +
.../pdfium/PdfiumPageRenderer.ts | 130 +
.../tools/pdfTextEditor/pdfium/PdfiumSave.ts | 73 +
.../pdfTextEditor/pdfium/PdfiumTextReader.ts | 791 +++
.../pdfTextEditor/pdfium/PdfiumTextWriter.ts | 101 +
.../tools/pdfTextEditor/store/EditorStore.ts | 514 ++
.../tools/pdfTextEditor/store/HistoryStack.ts | 147 +
.../tools/pdfTextEditor/store/Selection.ts | 113 +
.../src/core/tools/pdfTextEditor/types.ts | 146 +
.../pdfTextEditor/util/canvasBackground.ts | 83 +
.../pdfTextEditor/util/deviceFontEmbed.ts | 292 +
.../tools/pdfTextEditor/util/documentRisks.ts | 83 +
.../src/core/tools/pdfTextEditor/util/dom.ts | 64 +
.../tools/pdfTextEditor/util/embeddedFace.ts | 154 +
.../tools/pdfTextEditor/util/exactLayout.ts | 136 +
.../tools/pdfTextEditor/util/exportPdf.ts | 76 +
.../pdfTextEditor/util/externalImageEdit.ts | 313 +
.../tools/pdfTextEditor/util/fallbackFont.ts | 207 +
.../core/tools/pdfTextEditor/util/fitText.ts | 60 +
.../pdfTextEditor/util/fontCapability.ts | 152 +
.../tools/pdfTextEditor/util/fontFamily.ts | 98 +
.../core/tools/pdfTextEditor/util/guides.ts | 283 +
.../pdfTextEditor/util/helveticaVariant.ts | 36 +
.../tools/pdfTextEditor/util/imagePicking.ts | 83 +
.../tools/pdfTextEditor/util/imagePixels.ts | 104 +
.../pdfTextEditor/util/jpegOrientation.ts | 60 +
.../tools/pdfTextEditor/util/lineLayout.ts | 60 +
.../tools/pdfTextEditor/util/localFonts.ts | 307 +
.../pdfTextEditor/util/objectTransform.ts | 64 +
.../pdfTextEditor/util/overlayPainter.ts | 391 ++
.../tools/pdfTextEditor/util/pageFonts.ts | 157 +
.../core/tools/pdfTextEditor/util/sha256.ts | 89 +
.../tools/pdfTextEditor/util/spellcheck.ts | 197 +
.../tools/pdfTextEditor/util/textMatching.ts | 130 +
.../tools/pdfTextEditor/util/textMetrics.ts | 81 +
.../tools/pdfTextEditor/util/toolbarState.ts | 96 +
.../editor/src/core/types/toolApiTypes.ts | 11 +
frontend/editor/src/core/ui/ToggleSwitch.tsx | 4 +
.../src/core/utils/pdfiumBitmapUtils.ts | 103 +-
.../core/utils/signatureFlattening.test.ts | 9 +-
.../src/core/utils/thumbnailUtils.test.ts | 29 +
.../editor/src/core/utils/thumbnailUtils.ts | 15 +-
.../editor/src/portal/api/demoData.test.ts | 11 +-
301 files changed, 64841 insertions(+), 8130 deletions(-)
create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/api/PdfTextEditorCharcodeController.java
create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/PdfBoxFontEncodingProbeTest.java
create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/PdfTextEditorCharcodeControllerTest.java
create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/SamplePdfFontDumpTest.java
create mode 100644 app/core/src/test/resources/pdftexteditor/mushroom-life.pdf
create mode 100644 frontend/editor/public/fonts/NotoSans-OFL.txt
create mode 100644 frontend/editor/public/fonts/NotoSans-Regular.ttf
delete mode 100644 frontend/editor/src/core/components/tools/pdfTextEditor/FontStatusPanel.tsx
delete mode 100644 frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorSidebar.tsx
delete mode 100644 frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx
create mode 100644 frontend/editor/src/core/services/__tests__/httpErrorHandler.test.ts
create mode 100644 frontend/editor/src/core/tests/live/pdf-text-editor-charcode-backend.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/editorTestTypes.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-annotations.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-beforeinput.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-caret-drift.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-clip-path.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-combined-features.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-cropbox.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-cross-font-charcode.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-double-edit.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-edge-gestures.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-edit-mask.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-enter-and-drift.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-enter-font.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-exact-placement.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-fix-colour-picker.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-fix-empty-state.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-fix-image-selection.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-fix-mixed-fontsize.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-fix-page-prefetch.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-fix-pristine-save.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-fix-rotated-overlay.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-fix-undo-duplicate-glyphs.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-fix-undo-ink.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-focus-after-edit.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-font-capability.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-font-coverage.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-font-recognition.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-hidpi.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-jpeg-insert.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-known-issues.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-letter-spacing.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-menu-layout.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-model-sync.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-multi-run-undo.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-mushroom-scramble.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-mushroom-spaces.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-newline-register.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-paragraphs.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-pattern-fill.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-protected-and-save-warning.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-reflow-justified.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-reported-issues.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-rotated-text.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-selection-scope.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-stability.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-text-fit.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-toolbar-controls.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-type3.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-unicode-fallback.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-upgrades-walkthrough.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-vis-addtext.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-vis-colour.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-vis-fontsize.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-vis-images.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-vis-pages.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-vis-rotation.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-vis-roundtrip.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-vis-selection.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-vis-undoredo.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-vis-zoom.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-width-modes.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-workbench-files.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-workbench-save.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor-wrap.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/pdf-text-editor.spec.ts
create mode 100644 frontend/editor/src/core/tests/stubbed/saveHelpers.ts
create mode 100644 frontend/editor/src/core/tests/test-fixtures/annotation-text-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/big-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/cropbox-control.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/cropbox-offset.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/cropbox-rotate90.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/form-xobject-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/generate-annotation-text-sample.mjs
create mode 100644 frontend/editor/src/core/tests/test-fixtures/generate-big-sample.mjs
create mode 100644 frontend/editor/src/core/tests/test-fixtures/generate-cropbox-fixtures.py
create mode 100644 frontend/editor/src/core/tests/test-fixtures/generate-form-xobject-sample.mjs
create mode 100644 frontend/editor/src/core/tests/test-fixtures/generate-many-pages-sample.mjs
create mode 100644 frontend/editor/src/core/tests/test-fixtures/generate-paragraph-sample.mjs
create mode 100644 frontend/editor/src/core/tests/test-fixtures/generate-rotated-text-sample.py
create mode 100644 frontend/editor/src/core/tests/test-fixtures/generate-shading-and-justified-sample.mjs
create mode 100644 frontend/editor/src/core/tests/test-fixtures/generate-signed-sample.py
create mode 100644 frontend/editor/src/core/tests/test-fixtures/generate-split-contents-sample.mjs
create mode 100644 frontend/editor/src/core/tests/test-fixtures/generate-subset-font-sample.py
create mode 100644 frontend/editor/src/core/tests/test-fixtures/justified-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/letter-spacing-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/many-pages-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/multi-page-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/mushroom-life.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/paragraph-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/pattern-fill-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/rotated-text-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/shading-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/signed-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/split-contents-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/stirling-marketing.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/subset-font-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/type3-sample.pdf
create mode 100644 frontend/editor/src/core/tests/test-fixtures/user-sample.pdf
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/BackendResolver.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/BulletGrouping.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/ChangeZOrderCommand.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/CmapResolver.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/Color.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/DisplayTransform.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/FontBorrowing.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/HistoryStack.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/LineGrouperSpacing.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/ParagraphEdit.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/PdfiumPageRenderer.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/ReplaceImageCommand.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/affine.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/canvasBackground.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/cloneParagraphLineSlot.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/deviceFontEmbed.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/documentRisks.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/editorDirtyState.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/embeddedFace.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/exactLayout.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/externalImageEdit.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/fitText.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontCapability.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontFamily.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/guides.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/helveticaVariant.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/historyFailure.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/lineLayout.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/localFonts.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/overlayPainter.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/pageFonts.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfFixtures.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfPasses.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfRevision.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/prepareFixtures.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/rawPdf.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/rotation.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/sha256.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/spellcheck.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/textMatching.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/__tests__/toolbarState.test.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/charcode/BackendResolver.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/charcode/CharcodeStrategy.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/charcode/CmapResolver.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/charcode/ContentStreamResolver.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/charcode/charcodeRegistry.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/AlignParagraphLinesCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/ChangeZOrderCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/Command.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/CompositeCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteImageCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteObjectCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/DuplicateRunCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/EditTextCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/InsertImageCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/InsertTextCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/MergeRunsCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/MoveTextRunCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/ReflowWrapCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/ReplaceImageCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/SetColourCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontFamilyCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontSizeCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/SetImageTransformCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/SetLockCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/SetTextOutlineCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/TransformImageObjectCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/UngroupParagraphCommand.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/editTextHelpers.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/commands/partialEdit.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.css
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileInputs.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileSwitcher.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/EditorSaveBar.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/EditorSidebar.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/FindBar.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/FontFamilySelect.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/HelpOverlay.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/ImageHandle.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/MarqueeSelector.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/PageRulers.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/PageStage.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/PageView.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/PasswordPromptModal.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/SaveRiskModal.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/SpellcheckControl.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.css
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/Toolbar.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/ZoomPill.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentInspector.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentSettings.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/inspector/InspectorPrimitives.tsx
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/components/inspector/SelectionInspector.tsx
delete mode 100644 frontend/editor/src/core/tools/pdfTextEditor/fontAnalysis.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/hooks/useAutoLoadFile.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/hooks/useDevicePixelRatio.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/hooks/useDocumentLoader.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorClipboard.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorKeyboardShortcuts.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorStore.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorTestGlobal.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionActions.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionGeometry.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/hooks/useToolbarController.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/hooks/useUnsavedChangesGuard.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/hooks/useWorkbenchPin.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/model/AnnotationBox.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/model/Color.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/model/DisplayTransform.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/model/EditorDocument.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/model/FontRef.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/model/ImageObject.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/model/Page.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/model/TextRun.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/model/affine.ts
delete mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts
delete mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorUtils.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/bytes.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/contentOps.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/consolidateContents.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/preserveShadings.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/prepareForEditing.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/raw.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/revision.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfium/BackgroundSampler.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfium/LineGrouper.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfium/ParagraphGrouper.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumAnnotationReader.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumModelSync.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumPageRenderer.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumSave.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextReader.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextWriter.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/store/EditorStore.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/store/HistoryStack.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/store/Selection.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/types.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/canvasBackground.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/deviceFontEmbed.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/documentRisks.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/dom.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/embeddedFace.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/exactLayout.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/exportPdf.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/externalImageEdit.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/fallbackFont.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/fitText.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/fontCapability.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/fontFamily.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/guides.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/helveticaVariant.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/imagePicking.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/imagePixels.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/jpegOrientation.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/lineLayout.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/localFonts.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/objectTransform.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/overlayPainter.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/pageFonts.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/sha256.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/spellcheck.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/textMatching.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/textMetrics.ts
create mode 100644 frontend/editor/src/core/tools/pdfTextEditor/util/toolbarState.ts
diff --git a/app/core/build.gradle b/app/core/build.gradle
index 0e1533b6e8..8a842fd2de 100644
--- a/app/core/build.gradle
+++ b/app/core/build.gradle
@@ -306,6 +306,9 @@ tasks.register('copyFrontendAssets', Copy) {
// Exclude files that conflict with backend static resources
exclude 'robots.txt' // Backend already has this
exclude 'favicon.ico' // Backend already has this
+ // Backend ships its own NotoSans-Regular.ttf here and it is git-tracked;
+ // letting the editor's copy win would dirty the source tree on every build.
+ exclude 'fonts/NotoSans-Regular.ttf'
}
into resourcesStaticDir
duplicatesStrategy = DuplicatesStrategy.INCLUDE // Let frontend overwrite when needed
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfTextEditorCharcodeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfTextEditorCharcodeController.java
new file mode 100644
index 0000000000..169197a199
--- /dev/null
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfTextEditorCharcodeController.java
@@ -0,0 +1,598 @@
+package stirling.software.SPDF.controller.api;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDResources;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import io.swagger.v3.oas.annotations.Operation;
+
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.annotations.api.GeneralApi;
+import stirling.software.common.service.CustomPDFDocumentFactory;
+
+/**
+ * Charcode-encode helper for the v2 PDF text editor.
+ *
+ *
The frontend editor uses PDFium-WASM, which exposes {@code FPDFText_SetCharcodes} for writing
+ * new text using raw font charcodes (skipping PDFium's broken reverse Unicode→CID lookup for
+ * embedded subset fonts). What PDFium does NOT expose is the byte-encoding side of an existing font
+ * - given a PDFont and a Unicode string, what are the bytes the font's encoding produces? PDFBox
+ * does have that ({@link PDFont#encode}).
+ *
+ *
This endpoint accepts the source PDF + a "locator" describing where to find the font in
+ * question (page index + a sample char known to render in the target font, optionally narrowed by
+ * the font's /BaseFont name) + the Unicode text the frontend wants to encode. It returns the
+ * charcode sequence the frontend can pass to {@code FPDFText_SetCharcodes}.
+ *
+ *
If the locator can't find a matching text fragment, or if the font can't encode some chars,
+ * the response reports which chars are missing so the frontend can fall back to Helvetica per char.
+ */
+@Slf4j
+@GeneralApi
+@RequiredArgsConstructor
+public class PdfTextEditorCharcodeController {
+
+ /** Reject JSON bodies whose base64 implies a decoded PDF larger than this. */
+ private static final int MAX_PDF_BYTES = 100 * 1024 * 1024;
+
+ /**
+ * Upper bound on {@code request.text} code units. Editor requests are word-sized; an unbounded
+ * text drove a per-code-point encode/exception loop (CPU burn) on crafted requests.
+ */
+ private static final int MAX_TEXT_CHARS = 4096;
+
+ /** Nested form-XObject resource dictionaries visited per lookup (cycle/DoS guard). */
+ private static final int MAX_RESOURCE_DICTS = 32;
+
+ /** Bound on the reverse-map cache so a busy multi-document server can't grow it forever. */
+ private static final int REVERSE_MAP_CACHE_MAX = 32;
+
+ /** Access-ordered LRU bounded at {@link #REVERSE_MAP_CACHE_MAX} entries. */
+ private static final class BoundedReverseMapCache
+ extends java.util.LinkedHashMap> {
+ private static final long serialVersionUID = 1L;
+
+ BoundedReverseMapCache() {
+ super(16, 0.75f, true);
+ }
+
+ @Override
+ protected boolean removeEldestEntry(
+ java.util.Map.Entry> eldest) {
+ return size() > REVERSE_MAP_CACHE_MAX;
+ }
+ }
+
+ private static final java.util.Map> REVERSE_MAP_CACHE =
+ java.util.Collections.synchronizedMap(new BoundedReverseMapCache());
+
+ private final CustomPDFDocumentFactory pdfDocumentFactory;
+
+ // NOTE: PDFBox's PDSimpleFont emits one "No Unicode mapping for .notdef" WARN per probed
+ // charcode when buildReverseUnicodeMap iterates 0..0xFFFF, which once flooded info.log to
+ // ~1.4 GB overnight. That logger is silenced DECLARATIVELY in logback.xml (a config entry ops
+ // can see and revert) rather than by mutating the global logger from a static block here -
+ // mutating it at class-load time hid the same warnings from every other tool in the JVM with
+ // no trace in configuration.
+
+ @Data
+ public static class EncodeCharcodesRequest {
+
+ /** Base64-encoded original PDF. The frontend already has the bytes loaded. */
+ private String pdfBase64;
+
+ /** 0-based page index containing the font sample. */
+ private int pageIndex;
+
+ /**
+ * A char known to exist on the page in the target font. Combined with {@code fontName}
+ * (when supplied) it locates the source PDFont via its ToUnicode CMap.
+ */
+ private String locatorChar;
+
+ /**
+ * Optional /BaseFont name of the target font (as PDFium's FPDFFont_GetBaseFontName reports
+ * it). When a page has TWO fonts that both render {@code locatorChar}, this disambiguates
+ * which one to encode against - otherwise the first font found wins and a cross-font edit
+ * gets the wrong font's charcode. Null = keep the legacy first-match behaviour.
+ */
+ private String fontName;
+
+ /**
+ * Optional SHA-256 (lowercase hex) of the target font's embedded program bytes (what
+ * PDFium's FPDFFont_GetFontData returns = the decoded FontFile/FontFile2/FontFile3 stream).
+ * This is the ONLY unambiguous font identity: PDFium strips the "ABCDEF+" subset tag from
+ * font names, so every subset of one family reports the same {@code fontName} and a
+ * name-based lookup can land on a SIBLING subset whose charcode space is different -
+ * returning valid-but-wrong charcodes that scramble the edited text. When present and a
+ * font on the page matches, it wins over name matching.
+ */
+ private String fontSha256;
+
+ /** Unicode text the frontend wants to encode. */
+ private String text;
+ }
+
+ @Data
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class EncodeCharcodesResponse {
+ /**
+ * Per-char charcode array (one entry per code point in {@code request.text}). When the
+ * font's encoding produces multi-byte sequences, each char gets the full unsigned int value
+ * of its bytes packed big-endian (so a 2-byte CID like 0x004D becomes 77).
+ */
+ private List charcodes;
+
+ /** Chars from the request that the font couldn't encode. */
+ private List missing;
+
+ /** Diagnostic note - included so the frontend HUD can show what happened. */
+ private String note;
+
+ /** Set when the request failed entirely (bad pdf bytes, no matching font, etc.). */
+ private String error;
+ }
+
+ @Operation(
+ summary = "Encode Unicode → font charcodes for the v2 PDF text editor",
+ description =
+ """
+ Frontend-only helper: takes the source PDF, a locator pointing at an existing
+ char rendered in the target font, and a Unicode string. Returns the byte
+ sequence the target font produces for that Unicode, packed as one unsigned
+ int per char. The frontend then calls FPDFText_SetCharcodes with the
+ returned ints to inject new text that reuses the embedded font's actual
+ glyphs. Chars the font can't encode are listed in `missing` so the caller
+ can fall back per-char.
+ """)
+ @PostMapping(
+ value = "/pdf-text-editor/encode-charcodes",
+ consumes = "application/json",
+ produces = "application/json")
+ public ResponseEntity encodeCharcodes(
+ @RequestBody EncodeCharcodesRequest request) {
+ EncodeCharcodesResponse resp = new EncodeCharcodesResponse();
+ if (request == null
+ || request.getPdfBase64() == null
+ || request.getText() == null
+ || request.getLocatorChar() == null) {
+ resp.setError("missing required fields");
+ return ResponseEntity.badRequest().body(resp);
+ }
+ // length/4*3 bounds the decoded size without decoding, so we reject early before
+ // allocating.
+ String b64 = request.getPdfBase64();
+ if ((long) b64.length() / 4 * 3 > MAX_PDF_BYTES) {
+ resp.setError("pdf too large");
+ return ResponseEntity.status(413).body(resp);
+ }
+ // Reported separately: a combined check names only one cause and misleads the caller.
+ if (request.getText().length() > MAX_TEXT_CHARS) {
+ resp.setError("text too long");
+ return ResponseEntity.badRequest().body(resp);
+ }
+ if (request.getLocatorChar().length() > 4) {
+ resp.setError("locatorChar too long");
+ return ResponseEntity.badRequest().body(resp);
+ }
+ byte[] pdfBytes;
+ try {
+ pdfBytes = Base64.getDecoder().decode(b64);
+ } catch (IllegalArgumentException e) {
+ resp.setError("pdfBase64 is not valid base64");
+ return ResponseEntity.badRequest().body(resp);
+ }
+ try (PDDocument doc = pdfDocumentFactory.load(pdfBytes, true)) {
+ if (request.getPageIndex() < 0 || request.getPageIndex() >= doc.getNumberOfPages()) {
+ resp.setError("pageIndex out of range");
+ return ResponseEntity.badRequest().body(resp);
+ }
+ PDPage page = doc.getPage(request.getPageIndex());
+ // Skip walking the page's content stream (it crashes on Type3 fonts with
+ // UnsupportedOperationException("Not implemented: Type3") before we can do anything
+ // useful). Instead enumerate the page's font resources and pick the one identified by
+ // the request's font-program hash (definitive), falling back to name matching.
+ // For Chrome/Skia-printed PDFs that emit one Type3 font per glyph, this lands on
+ // the exact font that renders the locator char.
+ ResourceFont located =
+ findFontByToUnicode(
+ page,
+ request.getLocatorChar(),
+ request.getFontName(),
+ request.getFontSha256(),
+ doc);
+ if (located == null) {
+ resp.setError(
+ "no font on page "
+ + request.getPageIndex()
+ + " renders locatorChar="
+ + request.getLocatorChar()
+ + (request.getFontName() != null
+ ? " (fontName=" + request.getFontName() + ")"
+ : ""));
+ return ResponseEntity.ok(resp);
+ }
+ // Build a reverse Unicode→charcode map by walking the font's ToUnicode CMap.
+ // This is the ONLY path that works for Type3 fonts (PDFBox's font.encode() throws
+ // "Not implemented: Type3" on them), and it also acts as a more reliable fallback
+ // for subset fonts whose encode() rejects chars not in the original document.
+ //
+ // For Sample.pdf specifically, every embedded font is Type3 (Chrome/Skia output),
+ // but they all carry a ToUnicode CMap mapping CIDs back to Unicode. We iterate
+ // charcodes 0..0xFFFF, call font.toUnicode(cc) for each, and record the inverse
+ // mapping for the chars the user wants to write.
+ PDFont font = located.font();
+ java.util.Map reverseMap =
+ buildReverseUnicodeMap(pdfBytes, located, request.getPageIndex());
+ List charcodes = new ArrayList<>();
+ List missing = new ArrayList<>();
+ String text = request.getText();
+ int i = 0;
+ while (i < text.length()) {
+ int cp = text.codePointAt(i);
+ String oneChar = new String(Character.toChars(cp));
+ i += Character.charCount(cp);
+ // Whitespace is NEVER charcode-reused. Subset Type1/LaTeX fonts
+ // usually have no real space glyph, yet font.encode(0x20) still
+ // returns code 0x20 without throwing - and SetCharcodes(0x20)
+ // then paints whatever glyph sits at that subset code (e.g. „
+ // quotedblbase in LMRoman). Report whitespace as missing so the
+ // frontend emits it as a positional gap instead.
+ if (Character.isWhitespace(cp)) {
+ missing.add(oneChar);
+ continue;
+ }
+ // 1st try: font.encode() - works for Type0/TrueType/Type1
+ Long packed = null;
+ try {
+ byte[] encoded = font.encode(oneChar);
+ long p = 0L;
+ for (byte b : encoded) p = (p << 8) | (b & 0xff);
+ packed = p;
+ } catch (IOException
+ | IllegalArgumentException
+ | UnsupportedOperationException encodeEx) {
+ // 2nd try: ToUnicode reverse lookup - works for Type3 + anything with a CMap
+ packed = reverseMap.get(oneChar);
+ }
+ if (packed != null) charcodes.add(packed);
+ else missing.add(oneChar);
+ }
+ resp.setCharcodes(charcodes);
+ if (!missing.isEmpty()) resp.setMissing(missing);
+ resp.setNote(
+ "font="
+ + font.getName()
+ + " encoded "
+ + charcodes.size()
+ + " of "
+ + (charcodes.size() + missing.size())
+ + " chars");
+ return ResponseEntity.ok(resp);
+ } catch (IOException e) {
+ log.warn("encodeCharcodes: failed to load PDF", e);
+ resp.setError("failed to load PDF");
+ return ResponseEntity.badRequest().body(resp);
+ } catch (RuntimeException e) {
+ log.warn("encodeCharcodes: unexpected error", e);
+ resp.setError("unexpected error");
+ return ResponseEntity.status(500).body(resp);
+ }
+ }
+
+ /**
+ * Locate the font the request targets. Identity sources, strongest first:
+ *
+ *
+ * - Program hash: SHA-256 of the embedded font program bytes. Definitive - two
+ * different subsets NEVER share program bytes, and PDFium's FPDFFont_GetFontData returns
+ * exactly the decoded FontFile stream, so frontend and backend hash the same bytes.
+ *
- Exact /BaseFont name (subset tag included), then tag-stripped name. Name
+ * matches are only accepted when UNAMBIGUOUS: PDFium reports subset fonts WITHOUT their
+ * "ABCDEF+" tag, so a page with several subsets of one family ("AAAAAC+Garamond",
+ * "AAAAAG+Garamond", ...) has them ALL match the stripped name - and encoding against the
+ * wrong sibling returns valid-but-wrong charcodes that scramble the edited text ("RUSSELL
+ * W. MANGUM" rendered "US EEL W. MANGS M"). With 2+ candidates we return null so the
+ * frontend takes its safe fallback instead of a coin flip.
+ *
+ *
+ * This avoids running PDFStreamEngine.processPage, which throws
+ * UnsupportedOperationException on Type3 font glyph rendering. The PDFont lookup itself is
+ * purely metadata-driven and works on all subtypes.
+ */
+ private static ResourceFont findFontByToUnicode(
+ PDPage page, String wantChar, String fontName, String fontSha256, PDDocument doc) {
+ try {
+ List fonts = collectResourceTreeFonts(page.getResources());
+
+ // 1) Program-hash identity. When several dicts share one program (identical bytes
+ // re-embedded), any of them renders the same glyphs for the same codes; prefer the
+ // one whose ToUnicode covers the locator char so the reverse map is usable.
+ if (fontSha256 != null && !fontSha256.isEmpty()) {
+ List hashMatches = new ArrayList<>();
+ for (ResourceFont rf : fonts) {
+ String sha = fontProgramSha256(rf.font());
+ if (fontSha256.equalsIgnoreCase(sha)) hashMatches.add(rf);
+ }
+ for (ResourceFont rf : hashMatches) {
+ if (probesToUnicode(rf.font(), wantChar)) return rf;
+ }
+ if (!hashMatches.isEmpty()) return hashMatches.get(0);
+ // No program on this page hashes to what the frontend is editing (e.g. PDFium
+ // returned a substitute font's bytes for a non-embedded font). Fall through to
+ // name matching rather than failing outright.
+ }
+
+ // 2) Name identity - exact tag-included first, then tag-stripped - each accepted
+ // only when it selects a single font.
+ if (fontName != null && !fontName.isEmpty()) {
+ ResourceFont exact =
+ selectUnambiguous(
+ fonts, wantChar, f -> fontName.equals(f.getName()), "exact");
+ if (exact != null) return exact;
+ String wantStripped = stripSubsetTag(fontName);
+ ResourceFont stripped =
+ selectUnambiguous(
+ fonts,
+ wantChar,
+ f -> wantStripped.equals(stripSubsetTag(f.getName())),
+ "stripped");
+ if (stripped != null) return stripped;
+ // The frontend NAMED the font it is editing. Falling back to "any font that
+ // renders the char" would hand back a DIFFERENT font's charcodes, which the
+ // frontend then writes into the named font's text object - wrong glyph, and the
+ // backend strategy skips all frontend validation. Report the char missing
+ // instead so the caller takes its own fallback path.
+ return null;
+ }
+
+ // 3) Legacy locator-only behaviour: first font whose ToUnicode renders the char.
+ for (ResourceFont rf : fonts) {
+ if (probesToUnicode(rf.font(), wantChar)) return rf;
+ }
+ } catch (RuntimeException ignore) {
+ // Be defensive: any single bad font shouldn't sink the whole request.
+ }
+ return null;
+ }
+
+ /**
+ * Apply {@code nameFilter}, then decide: exactly one candidate whose ToUnicode covers {@code
+ * wantChar} wins; two+ probe-hits are AMBIGUOUS (null). With zero probe-hits, a single
+ * name-matching font is still returned (font.encode() may handle chars without a ToUnicode -
+ * common for Type0/Identity-H), but two+ name matches are again ambiguous.
+ */
+ private static ResourceFont selectUnambiguous(
+ List fonts,
+ String wantChar,
+ java.util.function.Predicate nameFilter,
+ String modeLabel) {
+ List named = new ArrayList<>();
+ for (ResourceFont rf : fonts) {
+ try {
+ if (rf.font().getName() != null && nameFilter.test(rf.font())) named.add(rf);
+ } catch (RuntimeException ignore) {
+ }
+ }
+ if (named.isEmpty()) return null;
+ List probed = new ArrayList<>();
+ for (ResourceFont rf : named) {
+ if (probesToUnicode(rf.font(), wantChar)) probed.add(rf);
+ }
+ if (probed.size() == 1) return probed.get(0);
+ if (probed.size() > 1) {
+ log.debug(
+ "encodeCharcodes: {} name match ambiguous ({} fonts render locator '{}') -"
+ + " refusing cross-subset guess",
+ modeLabel,
+ probed.size(),
+ wantChar);
+ return null;
+ }
+ return named.size() == 1 ? named.get(0) : null;
+ }
+
+ /** True when some charcode in the font's ToUnicode CMap maps to {@code wantChar}. */
+ private static boolean probesToUnicode(PDFont font, String wantChar) {
+ // Cheap inverse-CMap probe: iterate codes until we hit one whose toUnicode is wantChar.
+ // For Type3 with at most ~16 glyphs, this is microseconds. For full Type0 subsets
+ // it's a few-thousand-iteration scan.
+ int upper = font.isStandard14() ? 256 : 0x10000;
+ for (int cc = 0; cc < upper; cc++) {
+ String u;
+ try {
+ u = font.toUnicode(cc);
+ } catch (Exception ignore) {
+ continue;
+ }
+ if (u != null && u.equals(wantChar)) return true;
+ }
+ return false;
+ }
+
+ private record ResourceFont(PDFont font, String path) {}
+
+ private record PendingResources(PDResources resources, String path) {}
+
+ /**
+ * Breadth-first collection of every distinct font reachable from the page's resources AND every
+ * nested form XObject's resources (bounded by {@link #MAX_RESOURCE_DICTS}, cycle-safe, deduped
+ * by COS dictionary identity). The v2 reader surfaces form-XObject text as editable, so its
+ * fonts must be findable too.
+ */
+ private static List collectResourceTreeFonts(PDResources resources) {
+ List out = new ArrayList<>();
+ java.util.ArrayDeque queue = new java.util.ArrayDeque<>();
+ java.util.Set seenDicts =
+ java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
+ java.util.Set seenFonts =
+ java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
+ if (resources != null) queue.add(new PendingResources(resources, ""));
+ int visited = 0;
+ // Bound a crafted page declaring many fonts none of which match (CPU-DoS guard).
+ final int MAX_FONTS = 64;
+ while (!queue.isEmpty() && visited < MAX_RESOURCE_DICTS) {
+ PendingResources pending = queue.poll();
+ PDResources res = pending.resources();
+ if (!seenDicts.add(res.getCOSObject())) continue;
+ visited++;
+ for (org.apache.pdfbox.cos.COSName name : res.getFontNames()) {
+ if (out.size() >= MAX_FONTS) break;
+ PDFont font;
+ try {
+ font = res.getFont(name);
+ } catch (IOException | RuntimeException e) {
+ continue;
+ }
+ if (font == null || !seenFonts.add(font.getCOSObject())) continue;
+ out.add(new ResourceFont(font, pending.path() + "/" + name.getName()));
+ }
+ try {
+ for (org.apache.pdfbox.cos.COSName xn : res.getXObjectNames()) {
+ try {
+ org.apache.pdfbox.pdmodel.graphics.PDXObject xo = res.getXObject(xn);
+ if (xo
+ instanceof
+ org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject form) {
+ PDResources fr = form.getResources();
+ if (fr != null) {
+ queue.add(
+ new PendingResources(
+ fr, pending.path() + "/" + xn.getName()));
+ }
+ }
+ } catch (IOException | RuntimeException ignore) {
+ }
+ }
+ } catch (RuntimeException ignore) {
+ }
+ }
+ return out;
+ }
+
+ /**
+ * SHA-256 (lowercase hex) of a font's embedded program bytes - the decoded
+ * FontFile/FontFile2/FontFile3 stream, which is byte-identical to what PDFium's
+ * FPDFFont_GetFontData hands the frontend. Null when the font embeds no program.
+ */
+ private static String fontProgramSha256(PDFont font) {
+ try {
+ org.apache.pdfbox.pdmodel.font.PDFontDescriptor fd = font.getFontDescriptor();
+ if (fd == null && font instanceof org.apache.pdfbox.pdmodel.font.PDType0Font type0) {
+ fd = type0.getDescendantFont().getFontDescriptor();
+ }
+ if (fd == null) return null;
+ org.apache.pdfbox.pdmodel.common.PDStream stream = fd.getFontFile2();
+ if (stream == null) stream = fd.getFontFile3();
+ if (stream == null) stream = fd.getFontFile();
+ if (stream == null) return null;
+ return sha256Hex(stream.toByteArray());
+ } catch (IOException | RuntimeException e) {
+ return null;
+ }
+ }
+
+ /** Drop the 6-letter "ABCDEF+" subset prefix PDF puts on subset /BaseFont names. */
+ private static String stripSubsetTag(String fontName) {
+ if (fontName == null) return null;
+ if (fontName.length() > 7
+ && fontName.charAt(6) == '+'
+ && fontName.chars().limit(6).allMatch(c -> c >= 'A' && c <= 'Z')) {
+ return fontName.substring(7);
+ }
+ return fontName;
+ }
+
+ /**
+ * Build a Unicode→charcode map for a font by iterating every charcode in 0..0xFFFF and asking
+ * the font's ToUnicode CMap what Unicode it maps to. Charcodes that aren't in the CMap throw
+ * inside toUnicode (PDFBox returns null or throws depending on font subtype), and those are
+ * skipped silently.
+ *
+ * This is the encoding inverse PDFBox doesn't expose directly. For Type3 fonts (where
+ * font.encode() throws "Not implemented"), this is the ONLY way to write text in the same font
+ * - we look up the user's char in the reverse map and pass that charcode to
+ * FPDFText_SetCharcodes on the frontend.
+ *
+ *
The 0..0xFFFF range is sufficient for Type0/CIDFontType2 fonts (CIDs are 16-bit). For
+ * single-byte fonts the loop short-circuits after 256. We don't go higher because no PDF font
+ * has a CID outside that range in practice; the per-font result is memoised in {@link
+ * #REVERSE_MAP_CACHE} so the 65 536-entry probe runs once per document+font, not per request.
+ */
+ private static java.util.Map buildReverseUnicodeMap(
+ byte[] pdfBytes, ResourceFont located, int pageIndex) {
+ String key = sha256Hex(pdfBytes) + "|" + fontCacheIdentity(located, pageIndex);
+ // Compound get/put under the map's own monitor. The 0..0xFFFF probe runs OUTSIDE the
+ // lock so one slow build can't block every other request on the shared cache.
+ java.util.Map cached;
+ synchronized (REVERSE_MAP_CACHE) {
+ cached = REVERSE_MAP_CACHE.get(key);
+ }
+ if (cached != null) return cached;
+ java.util.Map built = computeReverseUnicodeMap(located.font());
+ synchronized (REVERSE_MAP_CACHE) {
+ java.util.Map raced = REVERSE_MAP_CACHE.putIfAbsent(key, built);
+ return raced != null ? raced : built;
+ }
+ }
+
+ private static String fontCacheIdentity(ResourceFont located, int pageIndex) {
+ org.apache.pdfbox.cos.COSObjectKey objectKey = null;
+ try {
+ objectKey = located.font().getCOSObject().getKey();
+ } catch (RuntimeException ignore) {
+ }
+ if (objectKey != null) {
+ return "obj|" + objectKey.getNumber() + "." + objectKey.getGeneration();
+ }
+ return "res|p" + pageIndex + located.path();
+ }
+
+ /** Lowercase hex SHA-256 of the PDF bytes; used as the reverse-map cache key. */
+ private static String sha256Hex(byte[] bytes) {
+ try {
+ byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes);
+ StringBuilder sb = new StringBuilder(digest.length * 2);
+ for (byte b : digest) {
+ sb.append(Character.forDigit((b >> 4) & 0xf, 16));
+ sb.append(Character.forDigit(b & 0xf, 16));
+ }
+ return sb.toString();
+ } catch (java.security.NoSuchAlgorithmException e) {
+ // SHA-256 is always present in a JRE; fall back to a length+hash key just in case so
+ // the cache still functions (correctness holds - collisions only cost a rebuild).
+ return bytes.length + ":" + java.util.Arrays.hashCode(bytes);
+ }
+ }
+
+ private static java.util.Map computeReverseUnicodeMap(PDFont font) {
+ java.util.Map out = new java.util.HashMap<>();
+ int upper = font.isStandard14() ? 256 : 0x10000;
+ for (int cc = 0; cc < upper; cc++) {
+ String u;
+ try {
+ u = font.toUnicode(cc);
+ } catch (Exception ignore) {
+ continue;
+ }
+ if (u == null || u.isEmpty()) continue;
+ // First charcode wins for a given Unicode (the canonical mapping).
+ out.putIfAbsent(u, (long) cc);
+ }
+ return out;
+ }
+}
diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java
index 6a56bad09f..ee6217de0d 100644
--- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java
+++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java
@@ -154,7 +154,8 @@ public class PdfJsonFontService {
return "otf";
}
if (signature == 0x74746366) {
- return "cff";
+ log.debug("[FONT-DEBUG] TrueType Collection ('ttcf') font program is unsupported");
+ return null;
}
return null;
}
@@ -175,7 +176,8 @@ public class PdfJsonFontService {
return "otf";
}
if (signature == 0x74746366) {
- return "cff";
+ log.debug("[FONT-DEBUG] TrueType Collection ('ttcf') FontFile2 is unsupported");
+ return null;
}
return null;
}
diff --git a/app/core/src/main/resources/logback.xml b/app/core/src/main/resources/logback.xml
index ebdeeda64b..f96540d3cc 100644
--- a/app/core/src/main/resources/logback.xml
+++ b/app/core/src/main/resources/logback.xml
@@ -15,26 +15,63 @@
%d %p %c{1} [%thread] %m%n
-
- ${LOG_PATH}/auth-%d{yyyy-MM-dd}.log.gz
+
+
+ ${LOG_PATH}/auth-%d{yyyy-MM-dd}.%i.log.gz
+ 100MB
7
64MB
-
+
${LOG_PATH}/info.log
%d %p %c{1} [%thread] %m%n
-
- ${LOG_PATH}/info-%d{yyyy-MM-dd}.log.gz
+
+ ${LOG_PATH}/info-%d{yyyy-MM-dd}.%i.log.gz
+ 100MB
7
256MB
+
+
+
+
+
+
diff --git a/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java b/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java
index d7d211a7ce..e8fa1d7e87 100644
--- a/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java
+++ b/app/core/src/test/java/stirling/software/SPDF/config/ToolIODeclarationCoverageTest.java
@@ -57,6 +57,8 @@ class ToolIODeclarationCoverageTest {
// documents.
"/api/v1/convert/pdf/text-editor",
"/api/v1/convert/text-editor/pdf",
+ // Charcode lookup for the v2 editor: returns glyph mappings, not a document.
+ "/api/v1/general/pdf-text-editor",
// Signing sessions, certificate checks and hardware token enumeration; the
// signing tool itself is /api/v1/security/cert-sign, which is declared.
"/api/v1/security/cert-sign/sessions",
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/PdfBoxFontEncodingProbeTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/PdfBoxFontEncodingProbeTest.java
new file mode 100644
index 0000000000..277c9660ab
--- /dev/null
+++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/PdfBoxFontEncodingProbeTest.java
@@ -0,0 +1,516 @@
+package stirling.software.SPDF.controller.api;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import javax.imageio.ImageIO;
+
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.PDResources;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
+import org.apache.pdfbox.pdmodel.font.PDType1Font;
+import org.apache.pdfbox.pdmodel.font.PDType3Font;
+import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
+import org.apache.pdfbox.rendering.PDFRenderer;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Probe: what can PDFBox actually do for font ENCODING on real-world PDFs. This is a diagnostic
+ * test (not a regression) - run with --tests PdfBoxFontEncodingProbeTest -i to see stdout.
+ *
+ * Answers these questions:
+ *
+ *
+ * - Type0/CIDFontType2 subset: can we add a new glyph not in the original subset? (no, encode
+ * throws IllegalArgumentException).
+ *
- Type1: same question.
+ *
- TrueType: same question.
+ *
- Can we load a fresh TTF via PDType0Font.load(doc, file) and write text with it? (yes,
+ * primary path).
+ *
- Round-trip via getFontStream / re-embed - can it rehabilitate Type3? (no - Type3 has no
+ * FontFile* program at all).
+ *
- What fonts ship with PDFBox / fontbox? (only LiberationSans-Regular.ttf + AFM for the 14
+ * standard fonts; CFF/Type1 binaries are NOT bundled - Standard14Fonts.getMappedFontName
+ * redirects unmappable ones to LiberationSans).
+ *
+ */
+@Disabled(
+ "Diagnostic probe: dumps PDFBox font encoding tables to stdout and asserts nothing. Kept for font debugging; run manually.")
+public class PdfBoxFontEncodingProbeTest {
+
+ private static final Path PROJECT_ROOT =
+ Paths.get(System.getProperty("user.dir")).getParent().getParent();
+
+ private static final Path SAMPLE =
+ PROJECT_ROOT.resolve("frontend/editor/public/samples/Sample.pdf");
+
+ private static final Path[] EXTRA_FIXTURES = {
+ PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/stirling-marketing.pdf"),
+ PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/multi-page-sample.pdf"),
+ PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/big-sample.pdf"),
+ PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/paragraph-sample.pdf"),
+ PROJECT_ROOT.resolve("frontend/editor/src/core/tests/test-fixtures/user-sample.pdf"),
+ };
+
+ /**
+ * Rasterize the Q4b output (Sample.pdf with injected Liberation text) to confirm the new text
+ * actually renders on top of the existing Type3 content.
+ */
+ @Test
+ public void probeRenderInjectedSample() throws IOException {
+ Path liberation =
+ PROJECT_ROOT.resolve(
+ "app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf");
+ byte[] pdfBytes = Files.readAllBytes(SAMPLE);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
+ PDPage page = doc.getPage(0);
+ PDType0Font ttf;
+ try (InputStream in = Files.newInputStream(liberation)) {
+ ttf = PDType0Font.load(doc, in, true);
+ }
+ try (PDPageContentStream cs =
+ new PDPageContentStream(
+ doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
+ cs.beginText();
+ cs.setFont(ttf, 24);
+ cs.newLineAtOffset(50, 120);
+ cs.showText("INJECTED via PDType0Font.load - $@#&Z");
+ cs.endText();
+ }
+ doc.save(out);
+ }
+ // Rasterize page 0 to a PNG so we can eyeball it.
+ try (PDDocument check = Loader.loadPDF(out.toByteArray())) {
+ PDFRenderer renderer = new PDFRenderer(check);
+ java.awt.image.BufferedImage img = renderer.renderImageWithDPI(0, 100);
+ // Build dir, not the repo root: this render is a debugging aid and was
+ // twice committed by accident when it landed in the working tree.
+ Path png =
+ Paths.get(System.getProperty("user.dir"), "build", "probe-output")
+ .resolve("pdfbox-probe-q4b-rendered.png");
+ Files.createDirectories(png.getParent());
+ ImageIO.write(img, "PNG", png.toFile());
+ System.out.println(
+ "Rendered injected sample to "
+ + png
+ + " - "
+ + img.getWidth()
+ + "x"
+ + img.getHeight());
+ }
+ }
+
+ /**
+ * Build a PDF in memory that uses a Type0/CIDFontType2 subset font (the kind Word / InDesign /
+ * LibreOffice produce), then probe whether encode() can add a glyph that wasn't in the original
+ * subset.
+ */
+ @Test
+ public void probeType0CIDFontType2Subset() throws IOException {
+ System.out.println(
+ "\n##################################################################\n"
+ + "Q1 probe: Type0/CIDFontType2 SUBSET can/cannot add new glyphs\n"
+ + "##################################################################\n");
+ Path liberation =
+ PROJECT_ROOT.resolve(
+ "app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf");
+
+ // Build a PDF that contains only "abc" subsetted from LiberationSans.
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (PDDocument doc = new PDDocument()) {
+ PDPage page = new PDPage();
+ doc.addPage(page);
+ PDType0Font subset;
+ try (InputStream in = Files.newInputStream(liberation)) {
+ subset = PDType0Font.load(doc, in, true /* embedSubset */);
+ }
+ try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
+ cs.beginText();
+ cs.setFont(subset, 12);
+ cs.newLineAtOffset(100, 700);
+ cs.showText("abc");
+ cs.endText();
+ }
+ doc.save(baos);
+ }
+
+ // Reload the produced PDF and try to add a NEW glyph through the embedded subset font.
+ byte[] subsetPdf = baos.toByteArray();
+ try (PDDocument doc = Loader.loadPDF(subsetPdf)) {
+ PDResources res = doc.getPage(0).getResources();
+ for (COSName fn : res.getFontNames()) {
+ PDFont f = res.getFont(fn);
+ System.out.println(
+ " Subset font in saved PDF: "
+ + f.getName()
+ + " ("
+ + f.getClass().getSimpleName()
+ + ", subType="
+ + f.getSubType()
+ + ")");
+ for (String ch : new String[] {"a", "b", "c", "Z", "z", "0", "$", "@", "X", " "}) {
+ try {
+ byte[] enc = f.encode(ch);
+ StringBuilder hex = new StringBuilder();
+ for (byte b : enc) hex.append(String.format("%02X ", b & 0xff));
+ System.out.println(
+ " encode('" + ch + "') -> [" + hex.toString().trim() + "] OK");
+ } catch (UnsupportedOperationException uoe) {
+ System.out.println(" encode('" + ch + "') UNSUPPORTED");
+ } catch (IllegalArgumentException iae) {
+ System.out.println(
+ " encode('" + ch + "') MISSING - " + iae.getMessage());
+ } catch (IOException ioe) {
+ System.out.println(" encode('" + ch + "') IO ERR - " + ioe.getMessage());
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ public void probeExtraFixtures() throws IOException {
+ System.out.println(
+ "\n##################################################################\n"
+ + "Extra fixture font-class probe\n"
+ + "##################################################################\n");
+ for (Path fixture : EXTRA_FIXTURES) {
+ if (!Files.exists(fixture)) {
+ System.out.println("(missing) " + fixture);
+ continue;
+ }
+ System.out.println("\n=== " + fixture.getFileName() + " ===");
+ byte[] bytes = Files.readAllBytes(fixture);
+ try (PDDocument doc = Loader.loadPDF(bytes)) {
+ Set seen = new HashSet<>();
+ for (int p = 0; p < doc.getNumberOfPages(); p++) {
+ PDPage page = doc.getPage(p);
+ PDResources res = page.getResources();
+ if (res == null) continue;
+ for (COSName name : res.getFontNames()) {
+ if (!seen.add(name)) continue;
+ try {
+ PDFont f = res.getFont(name);
+ if (f == null) continue;
+ String fontFile = "none";
+ PDFontDescriptor d = f.getFontDescriptor();
+ if (d != null) {
+ if (d.getFontFile() != null) fontFile = "FontFile";
+ else if (d.getFontFile2() != null) fontFile = "FontFile2";
+ else if (d.getFontFile3() != null) fontFile = "FontFile3";
+ }
+ String z = "?";
+ try {
+ f.encode("Z");
+ z = "OK";
+ } catch (UnsupportedOperationException ex) {
+ z = "UNSUPPORTED";
+ } catch (IllegalArgumentException ex) {
+ z = "MISSING";
+ } catch (IOException ex) {
+ z = "IO_ERR";
+ }
+ System.out.println(
+ " page "
+ + p
+ + " "
+ + name.getName()
+ + " -> "
+ + f.getName()
+ + " "
+ + f.getClass().getSimpleName()
+ + " ("
+ + f.getSubType()
+ + ", "
+ + fontFile
+ + ", embed="
+ + f.isEmbedded()
+ + ") encode('Z')="
+ + z);
+ } catch (IOException e) {
+ System.out.println(
+ " page "
+ + p
+ + " "
+ + name.getName()
+ + " load failed: "
+ + e.getMessage());
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ public void probeAllQuestions() throws IOException {
+ System.out.println(
+ "\n##################################################################\n"
+ + "PDFBox font-encoding probe (Sample.pdf + bundled fallback fonts)\n"
+ + "##################################################################\n");
+
+ // Discover every font in Sample.pdf so we have a real-world test set.
+ byte[] pdfBytes = Files.readAllBytes(SAMPLE);
+ try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
+ List allFonts = new ArrayList<>();
+ Set seen = new HashSet<>();
+ for (int p = 0; p < doc.getNumberOfPages(); p++) {
+ PDPage page = doc.getPage(p);
+ PDResources res = page.getResources();
+ if (res == null) continue;
+ for (COSName name : res.getFontNames()) {
+ if (!seen.add(name)) continue;
+ try {
+ PDFont f = res.getFont(name);
+ if (f != null) allFonts.add(f);
+ } catch (Exception e) {
+ System.out.println(
+ " (skipped " + name.getName() + " - " + e.getMessage() + ")");
+ }
+ }
+ }
+ System.out.println(
+ "Discovered " + allFonts.size() + " unique fonts across Sample.pdf:");
+ for (PDFont f : allFonts) {
+ System.out.println(
+ " - "
+ + f.getName()
+ + " ("
+ + f.getClass().getSimpleName()
+ + ", subType="
+ + f.getSubType()
+ + ", embedded="
+ + f.isEmbedded()
+ + ")");
+ }
+
+ // Q1/Q2/Q3
+ // Try encoding a char that is NEVER in Sample.pdf via each font.
+ // 'Z' is unlikely to be in the subset for most marketing pages.
+ // Try several candidates to surface what each font can/can't add.
+ String[] candidates = {"Z", "$", "@", "#", "Q", "&", "A", "0", "M"};
+ for (PDFont f : allFonts) {
+ System.out.println("\n=== Encode-probe for font: " + f.getName() + " ===");
+ for (String ch : candidates) {
+ try {
+ byte[] enc = f.encode(ch);
+ StringBuilder hex = new StringBuilder();
+ for (byte b : enc) hex.append(String.format("%02X ", b & 0xff));
+ System.out.println(
+ " encode('" + ch + "') -> [" + hex.toString().trim() + "] OK");
+ } catch (UnsupportedOperationException uoe) {
+ System.out.println(
+ " encode('" + ch + "') UNSUPPORTED: " + uoe.getMessage());
+ } catch (IllegalArgumentException iae) {
+ System.out.println(" encode('" + ch + "') MISSING: " + iae.getMessage());
+ } catch (IOException ioe) {
+ System.out.println(" encode('" + ch + "') IO ERR: " + ioe.getMessage());
+ }
+ }
+ }
+
+ // Q5
+ // For each font, see what's in the FontFile* stream - this is what we'd
+ // have to round-trip through to "rehabilitate" a Type3 font.
+ System.out.println("\n=== FontFile stream availability (Q5) ===");
+ for (PDFont f : allFonts) {
+ String kind = "none";
+ int size = 0;
+ PDFontDescriptor d = f.getFontDescriptor();
+ if (d != null) {
+ if (d.getFontFile() != null) {
+ kind = "FontFile (Type1)";
+ size = streamBytes(d.getFontFile().getCOSObject().createInputStream());
+ } else if (d.getFontFile2() != null) {
+ kind = "FontFile2 (TTF)";
+ size = streamBytes(d.getFontFile2().getCOSObject().createInputStream());
+ } else if (d.getFontFile3() != null) {
+ kind = "FontFile3 (CFF/OpenType)";
+ size = streamBytes(d.getFontFile3().getCOSObject().createInputStream());
+ }
+ }
+ System.out.println(
+ " "
+ + f.getName()
+ + " ("
+ + f.getClass().getSimpleName()
+ + "): "
+ + kind
+ + " ("
+ + size
+ + " bytes)");
+ if (f instanceof PDType3Font) {
+ System.out.println(
+ " -> Type3 has CharProc streams, NOT a FontFile binary."
+ + " getFontStream() returns null. Round-trip rehab is impossible:");
+ System.out.println(
+ " each glyph is a mini content stream, not a glyph outline in a"
+ + " standard font format. We'd need to rasterize each CharProc to"
+ + " glyph outlines + build a fresh TTF/CFF from scratch.");
+ }
+ }
+ }
+
+ // Q4: PDType0Font.load(doc, file) round-trip
+ System.out.println("\n=== Q4: load fresh TTF and write text to a fresh PDF ===");
+ Path liberation =
+ PROJECT_ROOT.resolve(
+ "app/core/src/main/resources/static/fonts/LiberationSans-Regular.ttf");
+ if (!Files.exists(liberation)) {
+ System.out.println(" Liberation TTF not found at " + liberation);
+ } else {
+ try (PDDocument out = new PDDocument()) {
+ PDPage page = new PDPage();
+ out.addPage(page);
+ PDType0Font ttf;
+ try (InputStream in = Files.newInputStream(liberation)) {
+ ttf = PDType0Font.load(out, in, true /* embedSubset */);
+ }
+ System.out.println(
+ " Loaded TTF -> "
+ + ttf.getName()
+ + " ("
+ + ttf.getClass().getSimpleName()
+ + ")");
+ String testText = "Hello world! 0123 Z $ @";
+ byte[] encoded = ttf.encode(testText);
+ System.out.println(
+ " Encoded "
+ + testText.length()
+ + " chars -> "
+ + encoded.length
+ + " bytes (Identity-H = 2 bytes/glyph)");
+ try (PDPageContentStream cs = new PDPageContentStream(out, page)) {
+ cs.beginText();
+ cs.setFont(ttf, 12);
+ cs.newLineAtOffset(100, 700);
+ cs.showText(testText);
+ cs.endText();
+ }
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ out.save(baos);
+ Path tmp = Files.createTempFile("pdfbox-probe-q4-", ".pdf");
+ Files.write(tmp, baos.toByteArray());
+ System.out.println(
+ " Wrote fresh-TTF PDF to "
+ + tmp
+ + " ("
+ + baos.size()
+ + " bytes) - opens cleanly.");
+
+ // Re-load to confirm the new font is embedded properly.
+ try (PDDocument check = Loader.loadPDF(baos.toByteArray())) {
+ PDResources res = check.getPage(0).getResources();
+ for (COSName fn : res.getFontNames()) {
+ PDFont f = res.getFont(fn);
+ System.out.println(
+ " embedded font: "
+ + f.getName()
+ + " ("
+ + f.getClass().getSimpleName()
+ + ", embedded="
+ + f.isEmbedded()
+ + ")");
+ }
+ }
+ }
+ }
+
+ // Q4b: load TTF into an EXISTING PDF (Sample.pdf) and append text
+ System.out.println(
+ "\n=== Q4b: load TTF into EXISTING Sample.pdf and write text on page 0 ===");
+ try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
+ PDPage page = doc.getPage(0);
+ PDType0Font ttf;
+ try (InputStream in = Files.newInputStream(liberation)) {
+ ttf = PDType0Font.load(doc, in, true);
+ }
+ // append-mode content stream so we don't disturb existing graphics
+ try (PDPageContentStream cs =
+ new PDPageContentStream(
+ doc,
+ page,
+ PDPageContentStream.AppendMode.APPEND,
+ true /* compress */,
+ true /* resetContext */)) {
+ cs.beginText();
+ cs.setFont(ttf, 12);
+ cs.newLineAtOffset(50, 50);
+ cs.showText("Injected via PDType0Font.load - $@#&");
+ cs.endText();
+ }
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ doc.save(baos);
+ Path tmp = Files.createTempFile("pdfbox-probe-q4b-", ".pdf");
+ Files.write(tmp, baos.toByteArray());
+ System.out.println(
+ " Wrote injected-text PDF to " + tmp + " (" + baos.size() + " bytes).");
+
+ // Verify by re-reading: how many fonts now on page 0?
+ try (PDDocument check = Loader.loadPDF(baos.toByteArray())) {
+ PDResources res = check.getPage(0).getResources();
+ int count = 0;
+ for (COSName fn : res.getFontNames()) {
+ PDFont f = res.getFont(fn);
+ count++;
+ System.out.println(
+ " page-0 font: "
+ + fn.getName()
+ + " -> "
+ + f.getName()
+ + " ("
+ + f.getClass().getSimpleName()
+ + ")");
+ }
+ System.out.println(" Total fonts on page 0: " + count);
+ }
+ }
+
+ // Q6: what fonts ship in PDFBox / fontbox
+ System.out.println("\n=== Q6: bundled fonts (Standard14 redirect probe) ===");
+ for (Standard14Fonts.FontName fn : Standard14Fonts.FontName.values()) {
+ PDType1Font f = new PDType1Font(fn);
+ String mapped = "" + Standard14Fonts.getMappedFontName(fn.getName());
+ System.out.println(
+ " Standard14 "
+ + fn.getName()
+ + " -> mapped='"
+ + mapped
+ + "' name="
+ + f.getName());
+ }
+ System.out.println(
+ " (PDFBox bundles ONLY LiberationSans-Regular.ttf as a binary; the AFMs cover"
+ + " metrics for the 14 standard fonts but rendering Helvetica/Times/Courier"
+ + " glyphs falls back to LiberationSans glyphs at runtime when no system font"
+ + " is found.)");
+ }
+
+ private static int streamBytes(InputStream is) {
+ try (InputStream it = is) {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ byte[] buf = new byte[4096];
+ int n;
+ while ((n = it.read(buf)) >= 0) baos.write(buf, 0, n);
+ return baos.size();
+ } catch (IOException e) {
+ return -1;
+ }
+ }
+}
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/PdfTextEditorCharcodeControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/PdfTextEditorCharcodeControllerTest.java
new file mode 100644
index 0000000000..68cb3566ea
--- /dev/null
+++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/PdfTextEditorCharcodeControllerTest.java
@@ -0,0 +1,755 @@
+package stirling.software.SPDF.controller.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.util.Base64;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.font.PDType1Font;
+import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.ResponseEntity;
+
+import stirling.software.SPDF.controller.api.PdfTextEditorCharcodeController.EncodeCharcodesRequest;
+import stirling.software.SPDF.controller.api.PdfTextEditorCharcodeController.EncodeCharcodesResponse;
+import stirling.software.common.service.CustomPDFDocumentFactory;
+import stirling.software.common.service.PdfMetadataService;
+
+/**
+ * Regression coverage for the v2 text editor "spaces render as „" bug.
+ *
+ * mushroom-life.pdf is a LaTeX document whose embedded LMRoman subset font has NO real space
+ * glyph, yet {@code font.encode(" ")} still returns charcode 0x20 without throwing. Reusing that
+ * code via {@code FPDFText_SetCharcodes} paints whatever glyph sits at subset code 0x20 - the
+ * quotedblbase „. The controller must therefore report whitespace as {@code missing} so the
+ * frontend emits it as a positional gap instead of a reused glyph.
+ */
+class PdfTextEditorCharcodeControllerTest {
+
+ private static PdfTextEditorCharcodeController controller() {
+ return new PdfTextEditorCharcodeController(
+ new CustomPDFDocumentFactory(mock(PdfMetadataService.class)));
+ }
+
+ private static String mushroomBase64() throws Exception {
+ try (InputStream in =
+ PdfTextEditorCharcodeControllerTest.class.getResourceAsStream(
+ "/pdftexteditor/mushroom-life.pdf")) {
+ assertThat(in).as("mushroom-life.pdf test resource").isNotNull();
+ return Base64.getEncoder().encodeToString(in.readAllBytes());
+ }
+ }
+
+ private static EncodeCharcodesRequest request(String text) throws Exception {
+ EncodeCharcodesRequest req = new EncodeCharcodesRequest();
+ req.setPdfBase64(mushroomBase64());
+ req.setPageIndex(0);
+ // findFontByToUnicode locates the font via the ToUnicode CMap - "M" exists on page 0.
+ req.setLocatorChar("M");
+ req.setText(text);
+ return req;
+ }
+
+ @Test
+ void spaceIsReportedMissingNeverEncoded() throws Exception {
+ PdfTextEditorCharcodeController controller = controller();
+ ResponseEntity resp = controller.encodeCharcodes(request(" "));
+
+ EncodeCharcodesResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getError()).isNull();
+ // The space must be reported missing, NOT handed back as a charcode
+ // (0x20) the frontend would reuse into the „ glyph.
+ assertThat(body.getMissing()).containsExactly(" ");
+ assertThat(body.getCharcodes()).isNullOrEmpty();
+ }
+
+ @Test
+ void realCharsEncodeWhileWhitespaceStaysAGap() throws Exception {
+ PdfTextEditorCharcodeController controller = controller();
+ // "M M" - both M's must encode to real charcodes; only the space is a gap.
+ ResponseEntity resp = controller.encodeCharcodes(request("M M"));
+
+ EncodeCharcodesResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getError()).isNull();
+ assertThat(body.getCharcodes()).as("both M glyphs encode").hasSize(2);
+ assertThat(body.getMissing()).containsExactly(" ");
+ }
+
+ @Test
+ void tabAndNewlineAreAlsoTreatedAsGaps() throws Exception {
+ PdfTextEditorCharcodeController controller = controller();
+ ResponseEntity resp = controller.encodeCharcodes(request("\t\n"));
+
+ EncodeCharcodesResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getMissing()).containsExactly("\t", "\n");
+ assertThat(body.getCharcodes()).isNullOrEmpty();
+ }
+
+ /**
+ * A page with two fonts that BOTH render 'A'. {@code fontName} must select which one to encode
+ * against - the cross-font fix. Without it the first font in resources order won wins and a
+ * cross-font edit got the wrong font's charcode.
+ */
+ private static String twoFontBase64() throws Exception {
+ try (PDDocument doc = new PDDocument()) {
+ PDPage page = new PDPage();
+ doc.addPage(page);
+ PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
+ PDType1Font times = new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
+ try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
+ cs.beginText();
+ cs.setFont(helvetica, 12);
+ cs.newLineAtOffset(72, 720);
+ cs.showText("A");
+ cs.endText();
+ cs.beginText();
+ cs.setFont(times, 12);
+ cs.newLineAtOffset(72, 700);
+ cs.showText("A");
+ cs.endText();
+ }
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ doc.save(bos);
+ return Base64.getEncoder().encodeToString(bos.toByteArray());
+ }
+ }
+
+ private static EncodeCharcodesRequest twoFontRequest(String fontName) throws Exception {
+ EncodeCharcodesRequest req = new EncodeCharcodesRequest();
+ req.setPdfBase64(twoFontBase64());
+ req.setPageIndex(0);
+ req.setLocatorChar("A");
+ req.setFontName(fontName);
+ req.setText("A");
+ return req;
+ }
+
+ @Test
+ void fontNameDisambiguatesBetweenTwoFontsRenderingTheSameChar() throws Exception {
+ PdfTextEditorCharcodeController controller = controller();
+
+ // Targeting Times-Roman must encode against Times-Roman, not whichever
+ // font happens to appear first in the page's font resources.
+ EncodeCharcodesResponse times =
+ controller.encodeCharcodes(twoFontRequest("Times-Roman")).getBody();
+ assertThat(times).isNotNull();
+ assertThat(times.getError()).isNull();
+ assertThat(times.getNote()).contains("Times-Roman");
+ assertThat(times.getCharcodes()).hasSize(1);
+
+ // Targeting Helvetica must encode against Helvetica.
+ EncodeCharcodesResponse helv =
+ controller.encodeCharcodes(twoFontRequest("Helvetica")).getBody();
+ assertThat(helv).isNotNull();
+ assertThat(helv.getError()).isNull();
+ assertThat(helv.getNote()).contains("Helvetica");
+ assertThat(helv.getCharcodes()).hasSize(1);
+ }
+
+ @Test
+ void unknownFontNameReportsNoFontInsteadOfWrongFont() throws Exception {
+ PdfTextEditorCharcodeController controller = controller();
+ // A name that matches no font on the page must NOT silently encode
+ // against a different font: the frontend writes the returned charcodes
+ // into the NAMED font's text object, so a first-match fallback would
+ // bake wrong glyphs. It must report failure so the caller falls back.
+ EncodeCharcodesResponse body =
+ controller.encodeCharcodes(twoFontRequest("DoesNotExist")).getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getError()).contains("no font");
+ assertThat(body.getCharcodes()).isNull();
+ }
+
+ @Test
+ void missingRequiredFieldsReturns400() {
+ EncodeCharcodesRequest req = new EncodeCharcodesRequest();
+ req.setPdfBase64("AAAA");
+ req.setLocatorChar("M");
+ // text is null
+ ResponseEntity resp = controller().encodeCharcodes(req);
+ assertThat(resp.getStatusCode().value()).isEqualTo(400);
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().getError()).isEqualTo("missing required fields");
+ }
+
+ @Test
+ void invalidBase64Returns400() {
+ EncodeCharcodesRequest req = new EncodeCharcodesRequest();
+ req.setPdfBase64("!!!notbase64!!!");
+ req.setLocatorChar("M");
+ req.setText("M");
+ ResponseEntity resp = controller().encodeCharcodes(req);
+ assertThat(resp.getStatusCode().value()).isEqualTo(400);
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().getError()).isEqualTo("pdfBase64 is not valid base64");
+ }
+
+ @Test
+ void pageIndexOutOfRangeReturns400() throws Exception {
+ EncodeCharcodesRequest req = request("M");
+ req.setPageIndex(999);
+ ResponseEntity resp = controller().encodeCharcodes(req);
+ assertThat(resp.getStatusCode().value()).isEqualTo(400);
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().getError()).isEqualTo("pageIndex out of range");
+ }
+
+ @Test
+ void nonPdfBytesReturnsGenericError() {
+ EncodeCharcodesRequest req = new EncodeCharcodesRequest();
+ req.setPdfBase64(Base64.getEncoder().encodeToString("not a pdf".getBytes()));
+ req.setLocatorChar("M");
+ req.setText("M");
+ // Must not throw, and must not leak the raw PDFBox parser message.
+ ResponseEntity resp = controller().encodeCharcodes(req);
+ assertThat(resp.getStatusCode().is4xxClientError()).isTrue();
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().getError()).isEqualTo("failed to load PDF");
+ }
+
+ @Test
+ void absentLocatorCharReturns200WithError() throws Exception {
+ // U+FFFF never appears in the document, so no font matches.
+ ResponseEntity resp =
+ controller().encodeCharcodes(requestWithLocator(""));
+ assertThat(resp.getStatusCode().value()).isEqualTo(200);
+ EncodeCharcodesResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getError()).isNotNull();
+ assertThat(body.getCharcodes()).isNull();
+ }
+
+ @Test
+ void oversizePdfRejected() {
+ EncodeCharcodesRequest req = new EncodeCharcodesRequest();
+ // A base64 string long enough that length/4*3 exceeds the 100MB cap, without
+ // ever allocating the decoded bytes (the guard runs before decode).
+ char[] huge = new char[140 * 1024 * 1024];
+ java.util.Arrays.fill(huge, 'A');
+ req.setPdfBase64(new String(huge));
+ req.setLocatorChar("M");
+ req.setText("M");
+ ResponseEntity resp = controller().encodeCharcodes(req);
+ assertThat(resp.getStatusCode().value()).isEqualTo(413);
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().getError()).isEqualTo("pdf too large");
+ }
+
+ private static EncodeCharcodesRequest requestWithLocator(String locator) throws Exception {
+ EncodeCharcodesRequest req = request("M");
+ req.setLocatorChar(locator);
+ return req;
+ }
+
+ /**
+ * Build a page whose resources declare {@code filler} fonts that do NOT render 'A' (Symbol /
+ * ZapfDingbats have non-Latin encodings) plus, optionally, a trailing Helvetica that does. The
+ * Standard14 probe upper bound is 256 so each scan is cheap.
+ */
+ private static String manyFontsBase64(int filler, boolean trailingTarget) throws Exception {
+ try (PDDocument doc = new PDDocument()) {
+ PDPage page = new PDPage();
+ doc.addPage(page);
+ org.apache.pdfbox.pdmodel.PDResources resources =
+ new org.apache.pdfbox.pdmodel.PDResources();
+ for (int n = 0; n < filler; n++) {
+ Standard14Fonts.FontName fn =
+ (n % 2 == 0)
+ ? Standard14Fonts.FontName.SYMBOL
+ : Standard14Fonts.FontName.ZAPF_DINGBATS;
+ resources.put(
+ org.apache.pdfbox.cos.COSName.getPDFName("Ff" + n), new PDType1Font(fn));
+ }
+ if (trailingTarget) {
+ resources.put(
+ org.apache.pdfbox.cos.COSName.getPDFName("Target"),
+ new PDType1Font(Standard14Fonts.FontName.HELVETICA));
+ }
+ page.setResources(resources);
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ doc.save(bos);
+ return Base64.getEncoder().encodeToString(bos.toByteArray());
+ }
+ }
+
+ private static EncodeCharcodesRequest manyFontsRequest(String base64) {
+ EncodeCharcodesRequest req = new EncodeCharcodesRequest();
+ req.setPdfBase64(base64);
+ req.setPageIndex(0);
+ req.setLocatorChar("A");
+ req.setText("A");
+ return req;
+ }
+
+ @Test
+ void targetFontFoundAmongManyFonts() throws Exception {
+ // 60 non-matching fonts then the Helvetica target, all within the 64-font cap.
+ ResponseEntity resp =
+ controller().encodeCharcodes(manyFontsRequest(manyFontsBase64(60, true)));
+ assertThat(resp.getStatusCode().value()).isEqualTo(200);
+ EncodeCharcodesResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getError()).isNull();
+ assertThat(body.getCharcodes()).hasSize(1);
+ }
+
+ @Test
+ void targetBeyondFontCapReturnsGracefulNoFont() throws Exception {
+ // 64 non-matching fonts then the target at position 65 - the scan cap stops
+ // before reaching it, so we get a graceful no-font error rather than a full scan.
+ ResponseEntity resp =
+ controller().encodeCharcodes(manyFontsRequest(manyFontsBase64(64, true)));
+ assertThat(resp.getStatusCode().value()).isEqualTo(200);
+ EncodeCharcodesResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getError()).isNotNull();
+ assertThat(body.getCharcodes()).isNull();
+ }
+
+ // Same-family sibling subsets. One document can embed several subsets of
+ // one family, each re-encoded by order of first glyph use, so a letter has
+ // a different charcode in each ("R" = 0x21 in one, 0x22 in its sibling).
+ // FPDFFont_GetBaseFontName strips the "ABCDEF+" tag, so a name-based
+ // lookup cannot tell them apart and borrows the wrong subset's codes.
+ //
+ // The doc below mirrors that with two TrueType subsets differing only by
+ // subset tag. PUA code points keep it deterministic: font.encode() cannot
+ // resolve them by glyph name, so the charcode can only come from the
+ // selected font's ToUnicode reverse map - proving WHICH font was picked.
+
+ private static final String PUA = "";
+
+ /** ToUnicode CMap mapping each supplied charcode to a BMP code point. */
+ private static byte[] toUnicodeCmap(int[][] codeToUnicode) {
+ StringBuilder sb =
+ new StringBuilder(
+ """
+ /CIDInit /ProcSet findresource begin
+ 12 dict begin
+ begincmap
+ /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
+ /CMapName /Adobe-Identity-UCS def
+ /CMapType 2 def
+ 1 begincodespacerange
+ <00>
+ endcodespacerange
+ """);
+ sb.append(codeToUnicode.length).append(" beginbfchar\n");
+ for (int[] pair : codeToUnicode) {
+ sb.append(String.format("<%02X><%04X>%n", pair[0], pair[1]));
+ }
+ sb.append(
+ """
+ endbfchar
+ endcmap
+ CMapName currentdict /CMap defineresource pop
+ end
+ end
+ """);
+ return sb.toString().getBytes(java.nio.charset.StandardCharsets.US_ASCII);
+ }
+
+ private static org.apache.pdfbox.cos.COSDictionary subsetFontDict(
+ PDDocument doc, String baseName, byte[] fontProgram, byte[] toUnicode)
+ throws Exception {
+ org.apache.pdfbox.cos.COSDictionary font = new org.apache.pdfbox.cos.COSDictionary();
+ font.setItem(org.apache.pdfbox.cos.COSName.TYPE, org.apache.pdfbox.cos.COSName.FONT);
+ font.setItem(
+ org.apache.pdfbox.cos.COSName.SUBTYPE, org.apache.pdfbox.cos.COSName.TRUE_TYPE);
+ if (baseName != null) {
+ font.setName(org.apache.pdfbox.cos.COSName.BASE_FONT, baseName);
+ }
+ font.setInt(org.apache.pdfbox.cos.COSName.FIRST_CHAR, 0x21);
+ font.setInt(org.apache.pdfbox.cos.COSName.LAST_CHAR, 0x22);
+ org.apache.pdfbox.cos.COSArray widths = new org.apache.pdfbox.cos.COSArray();
+ widths.add(org.apache.pdfbox.cos.COSInteger.get(500));
+ widths.add(org.apache.pdfbox.cos.COSInteger.get(500));
+ font.setItem(org.apache.pdfbox.cos.COSName.WIDTHS, widths);
+
+ org.apache.pdfbox.cos.COSDictionary fd = new org.apache.pdfbox.cos.COSDictionary();
+ fd.setItem(org.apache.pdfbox.cos.COSName.TYPE, org.apache.pdfbox.cos.COSName.FONT_DESC);
+ if (baseName != null) {
+ fd.setName(org.apache.pdfbox.cos.COSName.FONT_NAME, baseName);
+ }
+ fd.setInt(org.apache.pdfbox.cos.COSName.FLAGS, 4);
+ fd.setItem(
+ org.apache.pdfbox.cos.COSName.FONT_BBOX,
+ new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 1000, 1000).getCOSArray());
+ fd.setInt(org.apache.pdfbox.cos.COSName.ITALIC_ANGLE, 0);
+ fd.setInt(org.apache.pdfbox.cos.COSName.ASCENT, 800);
+ fd.setInt(org.apache.pdfbox.cos.COSName.DESCENT, -200);
+ fd.setInt(org.apache.pdfbox.cos.COSName.CAP_HEIGHT, 700);
+ fd.setInt(org.apache.pdfbox.cos.COSName.STEM_V, 80);
+ if (fontProgram != null) {
+ org.apache.pdfbox.pdmodel.common.PDStream ff2 =
+ new org.apache.pdfbox.pdmodel.common.PDStream(
+ doc, new java.io.ByteArrayInputStream(fontProgram));
+ ff2.getCOSObject().setInt(org.apache.pdfbox.cos.COSName.LENGTH1, fontProgram.length);
+ fd.setItem(org.apache.pdfbox.cos.COSName.FONT_FILE2, ff2.getCOSObject());
+ }
+ font.setItem(org.apache.pdfbox.cos.COSName.FONT_DESC, fd);
+
+ org.apache.pdfbox.pdmodel.common.PDStream tu =
+ new org.apache.pdfbox.pdmodel.common.PDStream(
+ doc, new java.io.ByteArrayInputStream(toUnicode));
+ font.setItem(org.apache.pdfbox.cos.COSName.getPDFName("ToUnicode"), tu.getCOSObject());
+ return font;
+ }
+
+ // Distinct fake font programs - hashing distinguishes the subsets by these bytes.
+ private static final byte[] PROGRAM_A =
+ "fake-ttf-program-A".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
+ private static final byte[] PROGRAM_B =
+ "fake-ttf-program-B".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
+
+ /**
+ * Two sibling subsets of "FakeGaramond" whose ToUnicode maps give U+E000 DIFFERENT charcodes:
+ * 0x22 in subset A (AAAAAC+), 0x21 in subset B (AAAAAG+) - exactly the CV's shifted-code
+ * layout. {@code includeSecond=false} keeps only subset A for the unambiguous-fallback case.
+ */
+ private static String siblingSubsetsBase64(boolean includeSecond) throws Exception {
+ try (PDDocument doc = new PDDocument()) {
+ PDPage page = new PDPage();
+ doc.addPage(page);
+ org.apache.pdfbox.cos.COSDictionary fonts = new org.apache.pdfbox.cos.COSDictionary();
+ fonts.setItem(
+ org.apache.pdfbox.cos.COSName.getPDFName("TTA"),
+ subsetFontDict(
+ doc,
+ "AAAAAC+FakeGaramond",
+ PROGRAM_A,
+ toUnicodeCmap(new int[][] {{0x21, 0xE001}, {0x22, 0xE000}})));
+ if (includeSecond) {
+ fonts.setItem(
+ org.apache.pdfbox.cos.COSName.getPDFName("TTB"),
+ subsetFontDict(
+ doc,
+ "AAAAAG+FakeGaramond",
+ PROGRAM_B,
+ toUnicodeCmap(new int[][] {{0x21, 0xE000}, {0x22, 0xE002}})));
+ }
+ org.apache.pdfbox.pdmodel.PDResources resources =
+ new org.apache.pdfbox.pdmodel.PDResources();
+ resources.getCOSObject().setItem(org.apache.pdfbox.cos.COSName.FONT, fonts);
+ page.setResources(resources);
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ doc.save(bos);
+ return Base64.getEncoder().encodeToString(bos.toByteArray());
+ }
+ }
+
+ private static String sha256Hex(byte[] bytes) throws Exception {
+ byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes);
+ StringBuilder sb = new StringBuilder();
+ for (byte b : digest) sb.append(String.format("%02x", b));
+ return sb.toString();
+ }
+
+ private static EncodeCharcodesRequest siblingRequest(
+ String base64, String fontName, String fontSha256) {
+ EncodeCharcodesRequest req = new EncodeCharcodesRequest();
+ req.setPdfBase64(base64);
+ req.setPageIndex(0);
+ req.setLocatorChar(PUA);
+ req.setFontName(fontName);
+ req.setFontSha256(fontSha256);
+ req.setText(PUA);
+ return req;
+ }
+
+ @Test
+ void fontProgramHashSelectsTheExactSubset() throws Exception {
+ String base64 = siblingSubsetsBase64(true);
+ PdfTextEditorCharcodeController controller = controller();
+
+ // Both requests carry the SAME tag-stripped name PDFium reports ("FakeGaramond"),
+ // so only the program hash can tell the subsets apart.
+ EncodeCharcodesResponse viaA =
+ controller
+ .encodeCharcodes(
+ siblingRequest(base64, "FakeGaramond", sha256Hex(PROGRAM_A)))
+ .getBody();
+ assertThat(viaA).isNotNull();
+ assertThat(viaA.getError()).isNull();
+ assertThat(viaA.getNote()).contains("AAAAAC+FakeGaramond");
+ assertThat(viaA.getCharcodes()).containsExactly(0x22L);
+
+ EncodeCharcodesResponse viaB =
+ controller
+ .encodeCharcodes(
+ siblingRequest(base64, "FakeGaramond", sha256Hex(PROGRAM_B)))
+ .getBody();
+ assertThat(viaB).isNotNull();
+ assertThat(viaB.getError()).isNull();
+ assertThat(viaB.getNote()).contains("AAAAAG+FakeGaramond");
+ assertThat(viaB.getCharcodes()).containsExactly(0x21L);
+ }
+
+ @Test
+ void ambiguousStrippedNameRefusesToGuessBetweenSiblingSubsets() throws Exception {
+ // No hash, and the tag-stripped name matches BOTH subsets which both render the
+ // locator char. Guessing here is what scrambled "RUSSELL W. MANGUM III" into
+ // "US EEL W. MANGS M III" - the sibling's codes hit different glyphs. The
+ // backend must refuse so the frontend takes its safe fallback.
+ EncodeCharcodesResponse body =
+ controller()
+ .encodeCharcodes(
+ siblingRequest(siblingSubsetsBase64(true), "FakeGaramond", null))
+ .getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getError()).contains("no font");
+ assertThat(body.getCharcodes()).isNull();
+ }
+
+ @Test
+ void exactTaggedNameStillSelectsItsSubset() throws Exception {
+ // A caller that DOES know the full tagged /BaseFont name keeps working.
+ EncodeCharcodesResponse body =
+ controller()
+ .encodeCharcodes(
+ siblingRequest(
+ siblingSubsetsBase64(true), "AAAAAG+FakeGaramond", null))
+ .getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getError()).isNull();
+ assertThat(body.getNote()).contains("AAAAAG+FakeGaramond");
+ assertThat(body.getCharcodes()).containsExactly(0x21L);
+ }
+
+ @Test
+ void strippedNameStillWorksWhenUnambiguous() throws Exception {
+ // With a SINGLE subset on the page, the tag-stripped name (what PDFium
+ // reports) must keep resolving - the ambiguity guard only bites when
+ // two+ siblings could answer.
+ EncodeCharcodesResponse body =
+ controller()
+ .encodeCharcodes(
+ siblingRequest(siblingSubsetsBase64(false), "FakeGaramond", null))
+ .getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getError()).isNull();
+ assertThat(body.getNote()).contains("AAAAAC+FakeGaramond");
+ assertThat(body.getCharcodes()).containsExactly(0x22L);
+ }
+
+ @Test
+ void staleHashFallsBackToNameMatching() throws Exception {
+ // A hash matching NO font on the page (e.g. PDFium handed back a substitute
+ // font's bytes) must not brick the request: name matching still runs, and an
+ // exact tagged name resolves.
+ EncodeCharcodesResponse body =
+ controller()
+ .encodeCharcodes(
+ siblingRequest(
+ siblingSubsetsBase64(true),
+ "AAAAAC+FakeGaramond",
+ "0000000000000000000000000000000000000000000000000000000000000000"))
+ .getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getError()).isNull();
+ assertThat(body.getNote()).contains("AAAAAC+FakeGaramond");
+ assertThat(body.getCharcodes()).containsExactly(0x22L);
+ }
+
+ private static final String PUA_E000 = "";
+ private static final String PUA_E002 = "";
+
+ private static final byte[] SHARED_PROGRAM =
+ "fake-ttf-program-shared".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
+
+ private static String cacheIdentityPairBase64(String baseName, byte[] program)
+ throws Exception {
+ try (PDDocument doc = new PDDocument()) {
+ PDPage page = new PDPage();
+ doc.addPage(page);
+ org.apache.pdfbox.cos.COSDictionary fonts = new org.apache.pdfbox.cos.COSDictionary();
+ fonts.setItem(
+ org.apache.pdfbox.cos.COSName.getPDFName("C1"),
+ subsetFontDict(
+ doc,
+ baseName,
+ program,
+ toUnicodeCmap(new int[][] {{0x21, 0xE001}, {0x22, 0xE000}})));
+ fonts.setItem(
+ org.apache.pdfbox.cos.COSName.getPDFName("C2"),
+ subsetFontDict(
+ doc,
+ baseName,
+ program,
+ toUnicodeCmap(new int[][] {{0x21, 0xE002}, {0x22, 0xE003}})));
+ org.apache.pdfbox.pdmodel.PDResources resources =
+ new org.apache.pdfbox.pdmodel.PDResources();
+ resources.getCOSObject().setItem(org.apache.pdfbox.cos.COSName.FONT, fonts);
+ page.setResources(resources);
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ doc.save(bos);
+ return Base64.getEncoder().encodeToString(bos.toByteArray());
+ }
+ }
+
+ private static EncodeCharcodesRequest cacheIdentityRequest(
+ String base64, String locator, String fontName, String fontSha256) {
+ EncodeCharcodesRequest req = new EncodeCharcodesRequest();
+ req.setPdfBase64(base64);
+ req.setPageIndex(0);
+ req.setLocatorChar(locator);
+ req.setFontName(fontName);
+ req.setFontSha256(fontSha256);
+ req.setText(locator);
+ return req;
+ }
+
+ @Test
+ void unnamedFontsSharingOneProgramDoNotShareACachedMap() throws Exception {
+ String base64 = cacheIdentityPairBase64(null, SHARED_PROGRAM);
+ String sha = sha256Hex(SHARED_PROGRAM);
+ PdfTextEditorCharcodeController controller = controller();
+
+ EncodeCharcodesResponse first =
+ controller
+ .encodeCharcodes(cacheIdentityRequest(base64, PUA_E000, null, sha))
+ .getBody();
+ assertThat(first).isNotNull();
+ assertThat(first.getError()).isNull();
+ assertThat(first.getCharcodes()).containsExactly(0x22L);
+
+ EncodeCharcodesResponse second =
+ controller
+ .encodeCharcodes(cacheIdentityRequest(base64, PUA_E002, null, sha))
+ .getBody();
+ assertThat(second).isNotNull();
+ assertThat(second.getError()).isNull();
+ assertThat(second.getMissing()).isNullOrEmpty();
+ assertThat(second.getCharcodes())
+ .as("second font must not be served the first font's cached map")
+ .containsExactly(0x21L);
+ }
+
+ @Test
+ void fontsSharingOneNameDoNotShareACachedMap() throws Exception {
+ String base64 = cacheIdentityPairBase64("SharedName", null);
+ PdfTextEditorCharcodeController controller = controller();
+
+ EncodeCharcodesResponse first =
+ controller
+ .encodeCharcodes(cacheIdentityRequest(base64, PUA_E000, "SharedName", null))
+ .getBody();
+ assertThat(first).isNotNull();
+ assertThat(first.getError()).isNull();
+ assertThat(first.getCharcodes()).containsExactly(0x22L);
+
+ EncodeCharcodesResponse second =
+ controller
+ .encodeCharcodes(cacheIdentityRequest(base64, PUA_E002, "SharedName", null))
+ .getBody();
+ assertThat(second).isNotNull();
+ assertThat(second.getError()).isNull();
+ assertThat(second.getMissing()).isNullOrEmpty();
+ assertThat(second.getCharcodes())
+ .as("same-name fonts must not share one cached map")
+ .containsExactly(0x21L);
+ }
+
+ private static String formXObjectFontBase64() throws Exception {
+ try (PDDocument doc = new PDDocument()) {
+ PDPage page = new PDPage();
+ doc.addPage(page);
+
+ org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject outer =
+ new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
+ outer.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 200, 200));
+ org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject inner =
+ new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
+ inner.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 100, 100));
+
+ org.apache.pdfbox.pdmodel.PDResources innerResources =
+ new org.apache.pdfbox.pdmodel.PDResources();
+ innerResources.put(
+ org.apache.pdfbox.cos.COSName.getPDFName("F1"),
+ new PDType1Font(Standard14Fonts.FontName.HELVETICA));
+ inner.setResources(innerResources);
+
+ org.apache.pdfbox.pdmodel.PDResources outerResources =
+ new org.apache.pdfbox.pdmodel.PDResources();
+ outerResources.put(org.apache.pdfbox.cos.COSName.getPDFName("Fm1"), inner);
+ outer.setResources(outerResources);
+
+ org.apache.pdfbox.pdmodel.PDResources pageResources =
+ new org.apache.pdfbox.pdmodel.PDResources();
+ pageResources.put(org.apache.pdfbox.cos.COSName.getPDFName("Fm0"), outer);
+ page.setResources(pageResources);
+
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ doc.save(bos);
+ return Base64.getEncoder().encodeToString(bos.toByteArray());
+ }
+ }
+
+ private static String cyclicFormXObjectsBase64() throws Exception {
+ try (PDDocument doc = new PDDocument()) {
+ PDPage page = new PDPage();
+ doc.addPage(page);
+
+ org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject formA =
+ new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
+ formA.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 100, 100));
+ org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject formB =
+ new org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject(doc);
+ formB.setBBox(new org.apache.pdfbox.pdmodel.common.PDRectangle(0, 0, 100, 100));
+
+ org.apache.pdfbox.pdmodel.PDResources resA =
+ new org.apache.pdfbox.pdmodel.PDResources();
+ org.apache.pdfbox.pdmodel.PDResources resB =
+ new org.apache.pdfbox.pdmodel.PDResources();
+ resA.put(org.apache.pdfbox.cos.COSName.getPDFName("Self"), formA);
+ resA.put(org.apache.pdfbox.cos.COSName.getPDFName("Fb"), formB);
+ resB.put(org.apache.pdfbox.cos.COSName.getPDFName("Fa"), formA);
+ resB.put(
+ org.apache.pdfbox.cos.COSName.getPDFName("F1"),
+ new PDType1Font(Standard14Fonts.FontName.HELVETICA));
+ formA.setResources(resA);
+ formB.setResources(resB);
+
+ org.apache.pdfbox.pdmodel.PDResources pageResources =
+ new org.apache.pdfbox.pdmodel.PDResources();
+ pageResources.put(org.apache.pdfbox.cos.COSName.getPDFName("Fm0"), formA);
+ page.setResources(pageResources);
+
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ doc.save(bos);
+ return Base64.getEncoder().encodeToString(bos.toByteArray());
+ }
+ }
+
+ @Test
+ void fontReachableOnlyThroughAFormXObjectIsFound() throws Exception {
+ ResponseEntity resp =
+ controller().encodeCharcodes(manyFontsRequest(formXObjectFontBase64()));
+ assertThat(resp.getStatusCode().value()).isEqualTo(200);
+ EncodeCharcodesResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getError()).isNull();
+ assertThat(body.getNote()).contains("Helvetica");
+ assertThat(body.getCharcodes()).containsExactly((long) 'A');
+ }
+
+ @Test
+ @org.junit.jupiter.api.Timeout(60)
+ void cyclicFormXObjectResourcesTerminate() throws Exception {
+ ResponseEntity resp =
+ controller().encodeCharcodes(manyFontsRequest(cyclicFormXObjectsBase64()));
+ assertThat(resp.getStatusCode().value()).isEqualTo(200);
+ EncodeCharcodesResponse body = resp.getBody();
+ assertThat(body).isNotNull();
+ assertThat(body.getError()).isNull();
+ assertThat(body.getCharcodes()).containsExactly((long) 'A');
+ }
+}
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SamplePdfFontDumpTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SamplePdfFontDumpTest.java
new file mode 100644
index 0000000000..90880bddae
--- /dev/null
+++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SamplePdfFontDumpTest.java
@@ -0,0 +1,340 @@
+package stirling.software.SPDF.controller.api;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.contentstream.PDFStreamEngine;
+import org.apache.pdfbox.contentstream.operator.state.Concatenate;
+import org.apache.pdfbox.contentstream.operator.state.Restore;
+import org.apache.pdfbox.contentstream.operator.state.Save;
+import org.apache.pdfbox.contentstream.operator.state.SetGraphicsStateParameters;
+import org.apache.pdfbox.contentstream.operator.state.SetMatrix;
+import org.apache.pdfbox.contentstream.operator.text.BeginText;
+import org.apache.pdfbox.contentstream.operator.text.EndText;
+import org.apache.pdfbox.contentstream.operator.text.SetFontAndSize;
+import org.apache.pdfbox.contentstream.operator.text.SetTextHorizontalScaling;
+import org.apache.pdfbox.contentstream.operator.text.SetTextLeading;
+import org.apache.pdfbox.contentstream.operator.text.SetTextRenderingMode;
+import org.apache.pdfbox.contentstream.operator.text.SetTextRise;
+import org.apache.pdfbox.contentstream.operator.text.SetWordSpacing;
+import org.apache.pdfbox.contentstream.operator.text.ShowText;
+import org.apache.pdfbox.cos.COSBase;
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.cos.COSStream;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDResources;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
+import org.apache.pdfbox.pdmodel.font.PDType3CharProc;
+import org.apache.pdfbox.pdmodel.font.PDType3Font;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Diagnostic test: enumerate every font referenced by Sample.pdf and dump its subtype, encoding,
+ * ToUnicode, and embedded font program info. For Type3 fonts also dump CharProcs glyph names and
+ * the content stream of one glyph (the 'M' if present).
+ *
+ * Not a real regression test - run with --tests SamplePdfFontDumpTest -i to see the stdout
+ * output.
+ */
+@Disabled(
+ "Diagnostic probe: dumps Sample.pdf font internals to stdout and asserts nothing. Kept for font debugging; run manually.")
+public class SamplePdfFontDumpTest {
+
+ private static final Path SAMPLE =
+ Paths.get(System.getProperty("user.dir"))
+ .getParent()
+ .getParent()
+ .resolve("frontend/editor/public/samples/Sample.pdf");
+
+ @Test
+ public void dumpFonts() throws IOException {
+ byte[] pdfBytes = Files.readAllBytes(SAMPLE);
+ try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
+ int numPages = doc.getNumberOfPages();
+ System.out.println("Sample.pdf has " + numPages + " pages.");
+ Set seenFontDicts = new HashSet<>();
+ for (int p = 0; p < numPages; p++) {
+ PDPage page = doc.getPage(p);
+ System.out.println("\n=== Page " + p + " ===");
+ PDResources resources = page.getResources();
+ if (resources == null) {
+ System.out.println(" (no resources)");
+ continue;
+ }
+ for (COSName fontName : resources.getFontNames()) {
+ PDFont font;
+ try {
+ font = resources.getFont(fontName);
+ } catch (IOException e) {
+ System.out.println(
+ " Font "
+ + fontName.getName()
+ + ": failed to load - "
+ + e.getMessage());
+ continue;
+ }
+ if (font == null) continue;
+ COSDictionary dict = font.getCOSObject();
+ if (!seenFontDicts.add(dict)) {
+ System.out.println(
+ " Font " + fontName.getName() + " -> already seen above");
+ continue;
+ }
+ dumpFont(fontName.getName(), font);
+ }
+ }
+ // Scan: for every text-show operation, record per-font (charcode, unicode) pairs.
+ System.out.println("\n=== All (font, charcode, unicode) seen on page ===");
+ for (int p = 0; p < numPages; p++) {
+ PDPage page = doc.getPage(p);
+ AllCharsScanner scanner = new AllCharsScanner();
+ scanner.processPage(page);
+ System.out.println("\nPage " + p + ":");
+ for (var entry : scanner.perFont.entrySet()) {
+ PDFont font = entry.getKey();
+ var seen = entry.getValue();
+ System.out.println(" Font " + font.getName() + " " + font.getSubType() + ":");
+ var sortedSeen = new java.util.TreeMap(seen);
+ for (var s : sortedSeen.entrySet()) {
+ System.out.println(
+ " charcode 0x"
+ + Integer.toHexString(s.getKey())
+ + " ("
+ + s.getKey()
+ + ") -> '"
+ + s.getValue()
+ + "'");
+ }
+ }
+ }
+
+ // Confirm font.encode() works for Type3 fonts.
+ System.out.println("\n=== Can we encode existing chars in F27/F28? ===");
+ PDPage page0 = doc.getPage(0);
+ PDResources r0 = page0.getResources();
+ for (String fname : new String[] {"F27", "F28"}) {
+ PDFont f = r0.getFont(COSName.getPDFName(fname));
+ if (f == null) {
+ System.out.println(" " + fname + ": NOT FOUND on page 0");
+ continue;
+ }
+ System.out.println(" " + fname + ": " + f.getClass().getSimpleName());
+ for (String ch : new String[] {"M", "0", "1", "+", "Z", "a"}) {
+ try {
+ byte[] enc = f.encode(ch);
+ StringBuilder sb = new StringBuilder();
+ for (byte b : enc) sb.append(String.format("%02X ", b & 0xff));
+ System.out.println(
+ " encode('" + ch + "') -> [" + sb.toString().trim() + "]");
+ } catch (Exception e) {
+ System.out.println(
+ " encode('"
+ + ch
+ + "') FAILED: "
+ + e.getClass().getSimpleName()
+ + " "
+ + e.getMessage());
+ }
+ }
+ }
+
+ // Dump page 0 content stream so we can see how "10M+" is composed.
+ System.out.println("\n=== Page 0 RAW content stream (first 4kb) ===");
+ try (InputStream is = doc.getPage(0).getContents()) {
+ byte[] bytes = is.readAllBytes();
+ System.out.println("Total content stream size: " + bytes.length + " bytes");
+ String asStr = new String(bytes, StandardCharsets.ISO_8859_1);
+ int idx = asStr.indexOf("F27");
+ if (idx >= 0) {
+ int start = Math.max(0, idx - 100);
+ int end = Math.min(asStr.length(), idx + 2500);
+ System.out.println("--- F27 context ---");
+ System.out.println(asStr.substring(start, end));
+ System.out.println("---");
+ }
+ int idx2 = asStr.indexOf("F28");
+ if (idx2 >= 0) {
+ int start = Math.max(0, idx2 - 200);
+ int end = Math.min(asStr.length(), idx2 + 600);
+ System.out.println("--- F28 context ---");
+ System.out.println(asStr.substring(start, end));
+ System.out.println("---");
+ }
+ }
+
+ // Dump a CharProc for each font's first non-zero glyph, with focus on any 'M' or "0".
+ System.out.println("\n=== Sample CharProc dumps for Type3 fonts ===");
+ Set printed = new HashSet<>();
+ for (int p = 0; p < numPages; p++) {
+ PDPage page = doc.getPage(p);
+ PDResources resources = page.getResources();
+ if (resources == null) continue;
+ for (COSName fn : resources.getFontNames()) {
+ PDFont font = resources.getFont(fn);
+ if (!(font instanceof PDType3Font)) continue;
+ if (!printed.add(font.getCOSObject())) continue;
+ PDType3Font t3 = (PDType3Font) font;
+ // Iterate charcodes 0..255 looking for any that map to 'M' or '0' or '+'.
+ for (int cc = 0; cc < 256; cc++) {
+ String u = null;
+ try {
+ u = t3.toUnicode(cc);
+ } catch (Exception e) {
+ /* */
+ }
+ if (u == null) continue;
+ if (u.equals("M") || u.equals("0") || u.equals("+") || u.equals("1")) {
+ System.out.println(
+ "Page "
+ + p
+ + " font '"
+ + fn.getName()
+ + "' charcode "
+ + cc
+ + " maps to '"
+ + u
+ + "':");
+ dumpType3Glyph(t3, cc);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private void dumpFont(String resourceName, PDFont font) {
+ COSDictionary dict = font.getCOSObject();
+ String subtype = dict.getNameAsString(COSName.SUBTYPE);
+ String baseFont = dict.getNameAsString(COSName.BASE_FONT);
+ boolean hasEncoding = dict.containsKey(COSName.ENCODING);
+ boolean hasToUnicode = dict.containsKey(COSName.TO_UNICODE);
+ PDFontDescriptor descriptor = font.getFontDescriptor();
+ boolean hasEmbedded = false;
+ String embeddedKind = "none";
+ if (descriptor != null) {
+ COSDictionary dDict = descriptor.getCOSObject();
+ if (dDict.containsKey(COSName.FONT_FILE)) {
+ hasEmbedded = true;
+ embeddedKind = "FontFile (Type1)";
+ } else if (dDict.containsKey(COSName.FONT_FILE2)) {
+ hasEmbedded = true;
+ embeddedKind = "FontFile2 (TrueType)";
+ } else if (dDict.containsKey(COSName.FONT_FILE3)) {
+ hasEmbedded = true;
+ COSBase ff3 = dDict.getDictionaryObject(COSName.FONT_FILE3);
+ if (ff3 instanceof COSStream) {
+ String ff3Subtype = ((COSStream) ff3).getNameAsString(COSName.SUBTYPE);
+ embeddedKind = "FontFile3 (" + ff3Subtype + ")";
+ } else {
+ embeddedKind = "FontFile3";
+ }
+ }
+ }
+ System.out.println(
+ " Font resource '"
+ + resourceName
+ + "': base='"
+ + baseFont
+ + "' subtype="
+ + subtype
+ + " hasEncoding="
+ + hasEncoding
+ + " hasToUnicode="
+ + hasToUnicode
+ + " embedded="
+ + hasEmbedded
+ + " ("
+ + embeddedKind
+ + ")");
+
+ if (font instanceof PDType3Font) {
+ PDType3Font t3 = (PDType3Font) font;
+ COSDictionary charProcs = t3.getCharProcs();
+ int count = charProcs == null ? 0 : charProcs.size();
+ System.out.println(" Type3 CharProcs count = " + count);
+ if (charProcs != null) {
+ TreeSet names = new TreeSet<>();
+ for (COSName k : charProcs.keySet()) names.add(k.getName());
+ System.out.println(" glyph names: " + names);
+ }
+ }
+ }
+
+ private void dumpType3Glyph(PDType3Font font, int charcode) throws IOException {
+ String name = font.getEncoding() != null ? font.getEncoding().getName(charcode) : null;
+ System.out.println(" Type3 charcode " + charcode + " -> glyph name '" + name + "'");
+ PDType3CharProc proc = font.getCharProc(charcode);
+ if (proc == null) {
+ System.out.println(" (no CharProc for that charcode)");
+ return;
+ }
+ COSStream stream = proc.getCOSObject();
+ byte[] raw;
+ try (InputStream is = stream.createInputStream()) {
+ raw = is.readAllBytes();
+ }
+ System.out.println(" CharProc content stream (" + raw.length + " bytes):");
+ System.out.println("---");
+ System.out.println(new String(raw, StandardCharsets.ISO_8859_1));
+ System.out.println("---");
+ }
+
+ /** Records every (font, charcode -> unicode) tuple seen on a page. */
+ static final class AllCharsScanner extends PDFStreamEngine {
+ final java.util.LinkedHashMap> perFont =
+ new java.util.LinkedHashMap<>();
+
+ AllCharsScanner() {
+ addOperator(new BeginText(this));
+ addOperator(new EndText(this));
+ addOperator(new SetFontAndSize(this));
+ addOperator(new SetTextHorizontalScaling(this));
+ addOperator(new SetTextLeading(this));
+ addOperator(new SetTextRenderingMode(this));
+ addOperator(new SetTextRise(this));
+ addOperator(new SetWordSpacing(this));
+ addOperator(new SetMatrix(this));
+ addOperator(new Save(this));
+ addOperator(new Restore(this));
+ addOperator(new Concatenate(this));
+ addOperator(new SetGraphicsStateParameters(this));
+ addOperator(new ShowText(this));
+ }
+
+ @Override
+ protected void showText(byte[] string) throws IOException {
+ PDFont font = getGraphicsState().getTextState().getFont();
+ if (font == null) return;
+ var seen = perFont.computeIfAbsent(font, k -> new java.util.LinkedHashMap<>());
+ ByteArrayInputStream in = new ByteArrayInputStream(string);
+ while (in.available() > 0) {
+ int code;
+ try {
+ code = font.readCode(in);
+ } catch (IOException e) {
+ break;
+ }
+ String u;
+ try {
+ u = font.toUnicode(code);
+ } catch (RuntimeException e) {
+ u = null;
+ }
+ seen.putIfAbsent(code, u);
+ }
+ }
+ }
+}
diff --git a/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontServiceMoreTest.java
index 777c855cb6..f6bc1a462e 100644
--- a/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontServiceMoreTest.java
+++ b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontServiceMoreTest.java
@@ -485,9 +485,9 @@ class PdfJsonFontServiceMoreTest {
class DetectExtra {
@Test
- @DisplayName("detectFontFlavor recognises ttcf as cff and otf via OTTO")
+ @DisplayName("detectFontFlavor rejects ttcf collections and recognises otf via OTTO")
void detectFlavorExtra() {
- assertEquals("cff", service.detectFontFlavor(new byte[] {0x74, 0x74, 0x63, 0x66}));
+ assertNull(service.detectFontFlavor(new byte[] {0x74, 0x74, 0x63, 0x66}));
List otfVariants = List.of(new byte[] {0x4F, 0x54, 0x54, 0x4F});
for (byte[] otf : otfVariants) {
assertEquals("otf", service.detectFontFlavor(otf));
diff --git a/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontServiceTest.java
index 4fdc585276..ad0ed8b0af 100644
--- a/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontServiceTest.java
+++ b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontServiceTest.java
@@ -57,10 +57,9 @@ class PdfJsonFontServiceTest {
}
@Test
- void detectFontFlavor_cffSignature_returnsCff() {
- // 0x74746366 = "ttcf"
- byte[] cff = {0x74, 0x74, 0x63, 0x66};
- assertEquals("cff", service.detectFontFlavor(cff));
+ void detectFontFlavor_ttcSignature_returnsNull() {
+ byte[] ttc = {0x74, 0x74, 0x63, 0x66};
+ assertNull(service.detectFontFlavor(ttc));
}
@Test
@@ -94,9 +93,9 @@ class PdfJsonFontServiceTest {
}
@Test
- void detectTrueTypeFormat_cffSignature_returnsCff() {
- byte[] cff = {0x74, 0x74, 0x63, 0x66};
- assertEquals("cff", service.detectTrueTypeFormat(cff));
+ void detectTrueTypeFormat_ttcSignature_returnsNull() {
+ byte[] ttc = {0x74, 0x74, 0x63, 0x66};
+ assertNull(service.detectTrueTypeFormat(ttc));
}
@Test
diff --git a/app/core/src/test/resources/pdftexteditor/mushroom-life.pdf b/app/core/src/test/resources/pdftexteditor/mushroom-life.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..62c8c3e0f69fcc9fa489531d0eb137e5a665dd63
GIT binary patch
literal 66890
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