Compare commits

...
Author SHA1 Message Date
Ludy 11aef9eb7a Merge branch 'main' into update_python_dep_20260812 2026-08-25 00:19:46 +02:00
79686a3a09 form field editing (#6655)
# Description of Changes

Building ontop of a users draft PR for form creation tools

**Fill Form** becomes a full **Form Editor**: fill, create, modify and
delete AcroForm fields visually. Builds on the community form-creation
draft, plus a UX/UI rework pass.

- **Backend**: `/api/v1/form` endpoints — `fields-with-coordinates`,
`add/modify/delete-fields`, combined `edit-fields` (one round-trip),
`fill`, `extract-csv/xlsx`; supports text (multiline, comb), checkbox,
dropdown, list box, radio, button actions (reset/print/URL/submit) and
signature placeholders
- **Create**: type palette, click-or-drag placement with snap guides,
inline property editor, batch "Add N fields"
- **Modify**: move/resize on the page, arrow-nudge + Delete key, X/Y/W/H
inputs, staged edits/deletes with chips, discard
- **Fill**: live progress + required tracking, flatten toggle, Export
menu (JSON/CSV/XLSX), Ctrl/Cmd+S
- **Safety**: confirm dialog before discarding staged work; empty
required fields warn with "Save anyway" instead of blocking
- **UI**: consistent panel skeleton (fixed header / scrolling list /
pinned actions), empty states that link into Create, full i18n with
plural keys



[walkthrough.html](https://github.com/user-attachments/files/30508976/walkthrough.html)



---

## 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.

---------

Co-authored-by: Denys Vitali <denys@denv.it>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-24 20:45:54 +00:00
Ludy 193a5231a0 Merge branch 'main' into update_python_dep_20260812 2026-08-23 13:20:17 +02:00
Ludy 5efefa51d7 Merge branch 'main' into update_python_dep_20260812 2026-08-21 09:58:36 +02:00
Ludy87 e4bb1b4b9f Tidy formatting in cucumber tests
Style cleanup across testing/cucumber features: compacted multi-line f-strings, normalized kwargs formatting (headers/timeout), removed stray blank lines and minor whitespace adjustments. Affected files: testing/cucumber/features/environment.py, job_step_definitions.py, job_support.py, parallel_support.py. No functional behavior changes intended.
2026-08-16 16:03:35 +02:00
Ludy87 444f6ea01f Clean up imports in cucumber features
Reorder and simplify imports in testing/cucumber feature files: remove noqa comments in environment.py, import parallel_support consistently, remove an unused os import in parallel_support.py, and consolidate/add parallel_support imports in step definition modules to satisfy linters and remove redundancy.
2026-08-16 15:56:04 +02:00
Ludy 580ad97321 Merge branch 'main' into update_python_dep_20260812 2026-08-16 15:45:07 +02:00
Ludy87 639e3652b0 Reformat tool models and IO spec lines
Reflowed long ToolIOSpec and Field initializers across multiple lines for readability in engine/src/stirling/models/tool_io.py and engine/src/stirling/models/tool_models.py. Purely formatting changes — no logic or behavior modified.
2026-08-13 11:51:48 +02:00
Ludy87 b39df8ca14 Format: wrap long lines and minor reflows
Reflowed and wrapped long Python lines, added inline noqa where needed, and adjusted multi-line comprehensions/expressions across various scripts and tests (.github/scripts, app/core/static, engine/scripts & tests, scripts/translations, testing/cucumber step definitions). Purely formatting changes to satisfy linters/line-length checks; no functional logic was altered.
2026-08-13 11:34:25 +02:00
Ludy87 84cb0d8725 Format: wrap long lines to 120 cols
Reduce ruff line-length to 120 and reflow/wrap long lines across the engine package to satisfy linting. Minor formatting changes: broken long strings into parenthesised or multi-line expressions, added noqa E501 where appropriate, and adjusted tuple/type wrapping for readability. Affected files include engine/pyproject.toml and various modules under engine/src/stirling (agents, contracts, documents, services). No functional behavior changes.
2026-08-13 11:29:27 +02:00
Ludy d0223008f6 Merge branch 'main' into update_python_dep_20260812 2026-08-13 11:13:02 +02:00
Ludy87 da0ed4f7b2 tests: close SqliteVecStore and add runtime teardown
Add an autouse fixture (close_sqlite_stores) that tracks SqliteVecStore instances by monkeypatching __init__ and closes them with asyncio.run() to avoid leaked DB handles. Convert the runtime fixture to a yield-style fixture that closes app_runtime.documents on teardown. Update imports (asyncio, SqliteVecStore) and remove the now-redundant runtime construction from test_pdf_create. Fixes resource leaks in engine tests.
2026-08-12 17:16:40 +02:00
Ludy87 1e3f2312ee Update ai-engine.yml 2026-08-12 15:53:10 +02:00
Ludy87 27868fbe1c Upgrade ruff, add engine lint targets & style fixes
Add PY_FILES git pathspec and update .taskfiles/pre-commit to lint only tracked engine Python files; include engine/**/*.py in pre-commit patterns. Upgrade ruff to 0.16.2 (pyproject.toml + uv.lock) and add BLE to ruff select rules. Mark intentional bare excepts with noqa: BLE001. Apply numerous minor formatting and line-wrap cleanups (SQL, string joins, multi-line args) and small test/fixture tidy-ups. Temporarily lower pytest coverage gate to 20%.
2026-08-12 15:33:39 +02:00
Ludy87 e009ac3150 format 2026-08-12 11:54:20 +02:00
Ludy87 06c6bec7ce CI: add engine coverage & linting/style fixes
Add AI engine coverage reporting to CI (run tests with coverage, show in step summary, and upload artifact). Relax and align engine pre-commit package specifiers and update uv.lock. Increase ruff line-length and add per-file ignores; update Taskfile and pre-commit Taskfile to use engine ruff config. Add coverage entries to engine/.gitignore. Apply numerous non-functional Python style and formatting fixes across scripts and cucumber test steps (imports, f-strings, line breaks, noqa markers, single-line asserts) to satisfy linters—no behavioral changes intended.
2026-08-12 10:41:42 +02:00
115 changed files with 12179 additions and 877 deletions
+8 -8
View File
@@ -10,10 +10,10 @@ adjusting the format.
Usage:
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-US/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml
Sample for Windows:
python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-US/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml
""" # noqa: E501
import argparse
import glob
@@ -201,7 +201,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if (branch_path / file_normpath).stat().st_size > MAX_FILE_SIZE:
has_differences = True
report.append(
f"\n⚠️ The file `{locale_dir}/{basename_current_file}` is too large and could pose a security risk.\n\n---\n"
f"\n⚠️ The file `{locale_dir}/{basename_current_file}` is too large and could pose a security risk.\n\n---\n" # noqa: E501
)
continue
@@ -223,11 +223,11 @@ def check_for_differences(reference_file, file_list, branch, actor):
has_differences = True
if reference_key_count > current_key_count:
report.append(
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing."
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing." # noqa: E501
)
elif reference_key_count < current_key_count:
report.append(
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed."
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed." # noqa: E501
)
else:
report.append("1. **Test Status:** ✅ **_Passed_**")
@@ -248,7 +248,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append(" - **Issue:**")
if missing_keys_list:
report.append(
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**." # noqa: E501
)
report.append("")
report.append(" Use the following command to remove them:")
@@ -256,7 +256,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("")
if extra_keys_list:
report.append(
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**." # noqa: E501
)
report.append("")
report.append(" Use the following command to add them:")
+26
View File
@@ -83,6 +83,32 @@ jobs:
});
}
- name: Run engine tests with coverage
id: engine-coverage
if: always()
run: task engine:test:coverage
- name: Add engine coverage to step summary
if: always() && steps.engine-coverage.outcome == 'success'
working-directory: engine
run: |
{
echo '## AI Engine coverage'
echo
echo '```text'
uv run --group engine-dev coverage report --show-missing
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload engine coverage report
if: always() && steps.engine-coverage.outcome == 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ai-engine-coverage
path: engine/coverage/
retention-days: 7
if-no-files-found: warn
- name: Fail if engine check failed
if: steps.engine-check.outcome == 'failure'
run: |
+2
View File
@@ -176,6 +176,8 @@ app/core/src/main/resources/static/images/google-drive.svg
*.nar
*.ear
*.zip
# Real backend archives the form-bundle reader is tested against.
!frontend/editor/src/core/tools/formFill/__fixtures__/*.zip
*.tar.gz
*.rar
*.db
+28 -5
View File
@@ -1,5 +1,14 @@
version: '3'
vars:
# File selections as git pathspecs: git does the include/exclude matching, so
# there is no grep/xargs and it behaves identically on every platform.
PY_FILES: >-
'scripts/**/*.py'
'src/**/*.py'
':(exclude)**/tool_io.py'
':(exclude)**/tool_models.py'
tasks:
install:
desc: "Install engine runtime and development dependencies"
@@ -78,31 +87,31 @@ tasks:
desc: "Run linting"
deps: [install]
cmds:
- uv run --locked --group engine --group engine-dev ruff check .
- uv run --locked --group pre-commit ruff check $(git ls-files {{.PY_FILES}})
lint:fix:
desc: "Auto-fix lint issues"
deps: [install]
cmds:
- uv run --locked --group engine --group engine-dev ruff check . --fix
- uv run --locked --group pre-commit ruff check $(git ls-files {{.PY_FILES}}) --fix
format:
desc: "Auto-fix code formatting"
deps: [install]
cmds:
- uv run --locked --group engine --group engine-dev ruff format .
- uv run --locked --group pre-commit ruff format $(git ls-files {{.PY_FILES}})
format:check:
desc: "Check code formatting"
deps: [install]
cmds:
- uv run --locked --group engine --group engine-dev ruff format . --diff
- uv run --locked --group pre-commit ruff format $(git ls-files {{.PY_FILES}}) --diff
typecheck:
desc: "Run type checking"
deps: [install]
cmds:
- uv run --locked --group engine --group engine-dev pyright . --warnings
- uv run --locked --group engine --group engine-dev pyright $(git ls-files {{.PY_FILES}}) --warnings
test:
desc: "Run tests"
@@ -110,6 +119,20 @@ tasks:
cmds:
- uv run --locked --group engine --group engine-dev pytest tests
test:coverage:
desc: "Run tests with coverage reporting"
deps: [prepare]
cmds:
- >-
uv run --locked --group engine --group engine-dev pytest tests
--cov=src/stirling
--cov-fail-under=20
--cov-report=term-missing
--cov-report=xml:coverage/coverage.xml
--cov-report=html:coverage/html
--cov-report=json:coverage/coverage.json
- uv run python scripts/check_coverage.py coverage/coverage.json --minimum 20
fix:
desc: "Auto-fix lint + format"
cmds:
+17 -9
View File
@@ -7,10 +7,13 @@ vars:
# File selections as git pathspecs: git does the include/exclude matching, so
# there is no grep/xargs and it behaves identically on every platform.
PY_FILES: >-
'scripts/*.py'
'scripts/**/*.py'
'.github/scripts/*.py'
'app/core/src/main/resources/static/python/*.py'
':(exclude)*split_photos.py'
'testing/**/*.py'
'engine/**/*.py'
':(exclude)engine/**/tool_io.py'
':(exclude)engine/**/tool_models.py'
SPELL_FILES: >-
'*.html'
'*.css'
@@ -80,7 +83,7 @@ tasks:
desc: "Install the pinned pre-commit Python tools"
run: once
cmds:
- uv sync --project engine --locked --group pre-commit
- uv sync --locked --project engine --group pre-commit
sources:
- engine/uv.lock
- engine/pyproject.toml
@@ -101,26 +104,31 @@ tasks:
ruff:
deps: [install]
cmds:
- uv run --project engine --locked --group pre-commit ruff check --isolated --line-length=120 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
- uv run --locked --project engine --group pre-commit ruff check $(git ls-files {{.PY_FILES}}) {{if .FIX}}--fix{{end}} --config engine/pyproject.toml
ruff-format:
deps: [install]
cmds:
- uv run --project engine --locked --group pre-commit ruff format --isolated --line-length=120 {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
- uv run --locked --project engine --group pre-commit ruff format $(git ls-files {{.PY_FILES}}) {{if .FIX}}{{else}}--check{{end}} --config engine/pyproject.toml
ruff-format-diff:
deps: [install]
cmds:
- uv run --locked --project engine --group pre-commit ruff format $(git ls-files {{.PY_FILES}}) --diff --config engine/pyproject.toml
codespell:
deps: [install]
cmds:
- uv run --project engine --locked --group pre-commit codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
- uv run --locked --project engine --group pre-commit codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
toml-sort:
deps: [install]
cmds:
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
- uv run --locked --project engine --group pre-commit python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
whitespace:
cmds:
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
- uv run --locked --project engine --group pre-commit python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
gitleaks:
deps: [gitleaks-bin]
@@ -134,4 +142,4 @@ tasks:
internal: true
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
cmds:
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/install_gitleaks.py
- uv run --locked --project engine --group pre-commit python scripts/pre-commit/install_gitleaks.py
@@ -62,6 +62,15 @@ public class FormFieldWithCoordinates {
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
private List<WidgetCoordinates> widgets;
@Schema(description = "Maximum character count for a text field (/MaxLen); null when unset")
private Integer maxLength;
@Schema(
description =
"Push button activation action as a spec string:"
+ " 'reset', 'print', 'uri:<url>' or 'submit:<url>'")
private String buttonActionSpec;
/**
* Coordinates for a single widget annotation (visual representation of the field). A field can
* have multiple widgets if it appears on multiple pages.
@@ -94,5 +103,12 @@ public class FormFieldWithCoordinates {
@Schema(description = "Font size in PDF points")
private Float fontSize;
@Schema(
description =
"CropBox height in PDF points. Lets the frontend reverse the backend's"
+ " Y-flip when sending new widget coordinates back for"
+ " create/modify operations.")
private Float cropBoxHeight;
}
}
@@ -3,14 +3,20 @@ package stirling.software.common.util;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionNamed;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionResetForm;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionSubmitForm;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
@@ -59,6 +65,24 @@ public enum FormFieldTypeSupport {
List<String> options)
throws IOException {
PDTextField textField = (PDTextField) field;
if (definition.fontSize() != null && definition.fontSize() > 0) {
textField.setDefaultAppearance("/Helv " + definition.fontSize() + " Tf 0 g");
}
if (Boolean.TRUE.equals(definition.multiline())) {
textField.setMultiline(true);
}
// Comb field: evenly spaced character cells (e.g. SSN, phone). Requires
// a positive MaxLen and is mutually exclusive with multiline.
if (definition.maxLength() != null && definition.maxLength() > 0) {
textField.setMaxLen(definition.maxLength());
if (!Boolean.TRUE.equals(definition.multiline())) {
try {
textField.setComb(true);
} catch (Exception e) {
log.debug("Unable to set comb flag: {}", e.getMessage());
}
}
}
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
if (!defaultValue.isBlank()) {
FormUtils.setTextValue(textField, defaultValue);
@@ -272,14 +296,108 @@ public enum FormFieldTypeSupport {
PDTerminalField createField(PDAcroForm acroForm) {
return new PDSignatureField(acroForm);
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
// Empty signature placeholder: no value to apply (signed later by a sign tool).
},
BUTTON("button", "pushButton", PDPushButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDPushButton(acroForm);
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
if (field.getWidgets().isEmpty()) {
return;
}
PDAnnotationWidget widget = field.getWidgets().get(0);
// Visible caption (/MK /CA).
String caption = definition.label();
if (caption == null || caption.isBlank()) {
caption = definition.name();
}
if (caption != null && !caption.isBlank()) {
PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics();
if (mk == null) {
mk = new PDAppearanceCharacteristicsDictionary(widget.getCOSObject());
widget.setAppearanceCharacteristics(mk);
}
mk.setNormalCaption(caption);
}
widget.setPrinted(true);
applyButtonAction(widget, definition.buttonAction());
}
};
/**
* Writes a push button's activation action from a "reset"/"print"/"uri:"/"submit:" spec,
* returning why it could not, or null on success. A blank spec clears the action.
*/
public static String applyButtonAction(PDAnnotationWidget widget, String action) {
if (action == null) {
return null;
}
if (action.isBlank()) {
// An explicit blank clears the action rather than leaving the old one behind.
widget.getCOSObject().removeItem(COSName.A);
return null;
}
String spec = action.trim();
if (!ACTION_SPEC.matcher(spec).matches()) {
return "'" + action + "' is not a button action this editor understands";
}
// The editor emits "uri:" the moment that kind is picked, before a URL is typed; an
// empty target is not yet an action, so clear rather than write an inert one.
int colon = spec.indexOf(':');
if (colon >= 0 && spec.substring(colon + 1).isBlank()) {
widget.getCOSObject().removeItem(COSName.A);
return null;
}
try {
String lower = spec.toLowerCase(Locale.ROOT);
if (lower.equals("reset")) {
widget.getCOSObject().setItem(COSName.A, new PDActionResetForm().getCOSObject());
} else if (lower.equals("print")) {
PDActionNamed named = new PDActionNamed();
named.setN("Print");
widget.getCOSObject().setItem(COSName.A, named.getCOSObject());
} else if (lower.startsWith("uri:")) {
PDActionURI uri = new PDActionURI();
uri.setURI(spec.substring(4));
widget.getCOSObject().setItem(COSName.A, uri.getCOSObject());
} else if (lower.startsWith("submit:")) {
PDActionSubmitForm submit = new PDActionSubmitForm();
// Store the target URL on the action dictionary's /F entry.
submit.getCOSObject().setString(COSName.F, spec.substring(7));
widget.getCOSObject().setItem(COSName.A, submit.getCOSObject());
}
return null;
} catch (Exception e) {
log.debug("Unable to apply button action '{}': {}", action, e.getMessage());
return e.getMessage();
}
}
/** The spec forms applyButtonAction understands; anything else is reported, not dropped. */
private static final Pattern ACTION_SPEC =
Pattern.compile(
"^(reset|print|uri:.*|submit:.*)$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
private static final Map<String, FormFieldTypeSupport> BY_TYPE =
Arrays.stream(values())
.collect(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSObject;
import org.apache.pdfbox.cos.COSObjectKey;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/** Pins how a choice field's options survive a save, which real forms rely on. */
class ChoiceOptionRoundTripTest {
private static PDComboBox combo(PDDocument document, List<String> options) throws IOException {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(form);
PDComboBox field = new PDComboBox(form);
field.setPartialName("state");
field.setOptions(options);
form.getFields().add(field);
return field;
}
@Test
@DisplayName("a whitespace-only option survives a load, save and reload")
void whitespaceOptionSurvivesRoundTrip() throws IOException {
List<String> options = List.of(" ", "Alabama", "Alaska");
byte[] first;
try (PDDocument document = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
combo(document, options);
document.save(out);
first = out.toByteArray();
}
// The real path edits a document loaded from bytes, not one built in memory.
byte[] saved;
try (PDDocument loaded = Loader.loadPDF(first);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
loaded.save(out);
saved = out.toByteArray();
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDComboBox reread =
(PDComboBox) reloaded.getDocumentCatalog().getAcroForm(null).getField("state");
assertEquals(
options,
reread.getOptionsExportValues(),
"an option must not vanish because the writer made it indirect");
}
}
@Test
@DisplayName("an option stored as an indirect reference is still reported")
void indirectOptionIsStillReported() throws IOException {
try (PDDocument document = new PDDocument()) {
PDComboBox field = combo(document, List.of(" ", "Alabama"));
// Real forms reference option strings indirectly; the reader must follow the reference.
COSArray options = new COSArray();
options.add(new COSObject(new COSString(" "), new COSObjectKey(629, 0)));
options.add(new COSString("Alabama"));
field.getCOSObject().setItem(COSName.OPT, options);
// Every read path runs this repair first, which is where the reference is followed.
FormUtils.repairMissingWidgetPageReferences(document);
assertEquals(
List.of(" ", "Alabama"),
field.getOptionsExportValues(),
"an indirectly stored option must not be dropped");
}
}
@Test
@DisplayName("a signature field reports no value rather than a JVM identity hash")
void signatureValueIsNotAnIdentityHash() throws IOException {
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(form);
PDSignatureField signature = new PDSignatureField(form);
signature.setPartialName("approval");
// Only a field that actually holds a signature hits getValueAsString's toString().
signature.setValue(new PDSignature());
form.getFields().add(signature);
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(document);
FormUtils.FormFieldInfo field =
fields.stream()
.filter(f -> "approval".equals(f.name()))
.findFirst()
.orElseThrow();
// An identity hash differs per load, so the same document would describe itself twice.
assertNull(field.value(), "a signature has no text value");
}
}
}
@@ -0,0 +1,62 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/** A hostile or corrupt form must fail as a rejected request, never as a crashed thread. */
class DeepFieldTreeTest {
private static byte[] chainOfKids(int depth) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(document);
document.getDocumentCatalog().setAcroForm(form);
COSDictionary root = new COSDictionary();
root.setString(COSName.T, "n0");
COSDictionary cursor = root;
for (int i = 1; i < depth; i++) {
COSDictionary kid = new COSDictionary();
kid.setString(COSName.T, "n" + i);
kid.setItem(COSName.PARENT, cursor);
COSArray kids = new COSArray();
kids.add(kid);
cursor.setItem(COSName.KIDS, kids);
cursor = kid;
}
cursor.setItem(COSName.FT, COSName.getPDFName("Tx"));
COSArray fields = new COSArray();
fields.add(root);
form.getCOSObject().setItem(COSName.FIELDS, fields);
document.save(out);
return out.toByteArray();
}
}
@Test
@DisplayName("a deeply nested field tree extracts without overflowing the stack")
void deepKidsChainDoesNotOverflow() throws IOException {
// 2000 is as deep as PDFBox's own writer can build here; beyond that the overflow is in
// the writer, not in extraction, so it is not something a read endpoint would hit.
byte[] pdf = chainOfKids(2000);
try (PDDocument document = Loader.loadPDF(pdf)) {
assertDoesNotThrow(() -> FormUtils.extractFormFieldsWithCoordinates(document));
}
}
}
@@ -0,0 +1,201 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.FormFieldWithCoordinates;
/** An edit that cannot be honoured must be refused and reported, never silently reshaped. */
class FormEditSafetyTest {
private static PDDocument formWith(String name, String type) throws IOException {
PDDocument document = new PDDocument();
document.addPage(new PDPage(PDRectangle.A4));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
name,
null,
type,
0,
50f,
700f,
200f,
20f,
null,
null,
type.equals("radio") ? List.of("a", "b") : null,
null,
null,
null,
null,
null,
null,
null)));
return document;
}
private static FormUtils.ModifyFormFieldDefinition modify(
String target, String type, Float width, Float height) {
// Order: targetName, name, label, type, pageIndex, x, y, width, height, then the rest.
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, type, null, null, null, width, height, null, null, null, null,
null, null, null, null, null, null);
}
@Test
@DisplayName("a type that cannot be rebuilt is refused instead of becoming a text field")
void unrebuildableTypeIsRefused() throws IOException {
try (PDDocument document = formWith("choice", "text")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(modify("choice", "radio", null, null)), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("choice");
assertFalse(skipped.isEmpty(), "the refusal must be reported to the caller");
assertFalse(
field instanceof PDRadioButton,
"it could not become a radio, so it must not claim to be one");
assertEquals(
"text",
FormUtils.extractFormFields(document).getFirst().type(),
"the original field must survive untouched rather than be retyped");
}
}
@Test
@DisplayName("a field rebuilt as a checkbox gets an appearance so it can be ticked")
void rebuiltCheckboxIsUsable() throws IOException {
try (PDDocument document = formWith("agree", "text")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(modify("agree", "checkbox", null, null)), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("agree");
assertTrue(field instanceof PDCheckBox, "the rebuild should have produced a checkbox");
assertNotNull(
field.getWidgets().getFirst().getAppearance(),
"without an appearance the checkbox renders blank and cannot be ticked");
}
}
@Test
@DisplayName("a size of zero or infinity is refused rather than written into the page")
void unusableSizeIsRefused() throws IOException {
for (Float bad : new Float[] {0f, -5f, Float.POSITIVE_INFINITY, Float.NaN}) {
try (PDDocument document = formWith("box", "text")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(modify("box", null, bad, 20f)), skipped);
PDRectangle rect =
document.getDocumentCatalog()
.getAcroForm(null)
.getField("box")
.getWidgets()
.getFirst()
.getRectangle();
assertFalse(skipped.isEmpty(), "a refused resize must be reported: width " + bad);
assertEquals(
200f,
rect.getWidth(),
0.01f,
"the original size must survive: width " + bad);
}
}
}
@Test
@DisplayName("a widget off the page still reports its geometry instead of dropping the field")
void offPageWidgetKeepsItsGeometry() throws IOException {
try (PDDocument document = formWith("stray", "text")) {
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("stray");
// Above the page top: legal PDF, and the user needs the coordinates to drag it back.
field.getWidgets().getFirst().setRectangle(new PDRectangle(50f, 2000f, 200f, 20f));
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(document);
FormFieldWithCoordinates stray =
fields.stream()
.filter(f -> "stray".equals(f.getName()))
.findFirst()
.orElseThrow();
assertNotNull(stray.getWidgets(), "the field must keep its widget list");
assertFalse(stray.getWidgets().isEmpty(), "the off-page widget must still be reported");
assertNotNull(stray.getWidgets().getFirst(), "a null entry would crash the overlay");
}
}
private static FormUtils.ModifyFormFieldDefinition withValue(String target, String value) {
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, null, null, null, null, null, null, null, null, null, value,
null, null, null, null, null, null);
}
private static FormUtils.ModifyFormFieldDefinition withOptions(
String target, List<String> options) {
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, null, null, null, null, null, null, null, null, options, null,
null, null, null, null, null, null);
}
@Test
@DisplayName("a value a radio group cannot hold does not destroy the group")
void badRadioValueLeavesTheGroupIntact() throws IOException {
try (PDDocument document = formWith("plan", "radio")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(withValue("plan", "not-an-option")), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan");
assertTrue(
field instanceof PDRadioButton,
"a rejected value must not turn the group into another kind of field");
assertEquals(
2,
field.getWidgets().size(),
"the group's options must survive a rejected value");
assertFalse(skipped.isEmpty(), "the caller must be told the value was not applied");
}
}
@Test
@DisplayName("editing a radio group's options is either applied or reported, never ignored")
void radioOptionEditIsNotSilentlyDropped() throws IOException {
try (PDDocument document = formWith("plan", "radio")) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(
document, List.of(withOptions("plan", List.of("a", "b", "c"))), skipped);
PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan");
boolean applied = field.getWidgets().size() == 3;
assertTrue(
applied || !skipped.isEmpty(),
"a change the UI shows as saved must either happen or be reported as skipped");
}
}
}
@@ -0,0 +1,61 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* A field name is caller-supplied and reaches several loggers. A line break in one would forge a
* second log line (CWE-117), so names carrying control characters are refused outright.
*/
class FormFieldNameSafetyTest {
@Test
void aNameWithCrLfIsRefused() {
String forged = "evil\r\n2026-01-01 00:00:00 ERROR admin login from 1.2.3.4";
String reason = FormUtils.invalidFieldNameReason(forged);
assertNotNull(reason, "a name containing CR/LF must be refused");
assertFalse(reason.contains("\n"), "the refusal itself must not carry a line break");
assertFalse(reason.contains("\r"), "the refusal itself must not carry a carriage return");
}
@Test
void otherControlCharactersAreRefusedToo() {
assertNotNull(FormUtils.invalidFieldNameReason("tab\there"));
assertNotNull(FormUtils.invalidFieldNameReason("null\u0000byte"));
}
@Test
void ordinaryNamesStillPass() {
assertNull(FormUtils.invalidFieldNameReason("Full Name"));
assertNull(FormUtils.invalidFieldNameReason("weird/[]{}"));
assertNull(FormUtils.invalidFieldNameReason("Mr Smith"));
}
@Test
void thePeriodRefusalDoesNotEchoControlCharacters() {
// Both problems at once: the period branch must not leak the raw name into a log line.
String reason = FormUtils.invalidFieldNameReason("Customer.Name\r\nFORGED");
assertNotNull(reason);
assertFalse(reason.contains("\r") || reason.contains("\n"), "no raw line break: " + reason);
}
@Test
void sanitizeForLogFlattensControlCharacters() {
assertEquals("a b", FormUtils.sanitizeForLog("a\nb"));
assertEquals("a b", FormUtils.sanitizeForLog("a\rb"));
assertEquals("plain", FormUtils.sanitizeForLog("plain"));
assertNull(FormUtils.sanitizeForLog(null));
}
@Test
void aPeriodIsStillRefusedWithTheOffendingCharacterNamed() {
String reason = FormUtils.invalidFieldNameReason("Customer.Name");
assertNotNull(reason);
assertTrue(reason.contains("period"), "the message should name the problem: " + reason);
}
}
@@ -130,13 +130,15 @@ class FormFieldTypeSupportTest {
}
@Test
void doesNotSupportsDefinitionCreation_signatureReturnsTrue() {
assertTrue(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation());
void doesNotSupportsDefinitionCreation_signatureReturnsFalse() {
// Signature placeholders are now creatable via the editor.
assertFalse(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation());
}
@Test
void doesNotSupportsDefinitionCreation_buttonReturnsTrue() {
assertTrue(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation());
void doesNotSupportsDefinitionCreation_buttonReturnsFalse() {
// Push buttons (with actions) are now creatable via the editor.
assertFalse(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation());
}
@Test
@@ -0,0 +1,911 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
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.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
/**
* Guards the form editor against silently destroying a field it edits. Assertions run after a
* save/reload cycle because only the serialised document reflects what a viewer sees.
*/
class FormUtilsEditRegressionTest {
private static PDAcroForm setupForm(PDDocument document) {
document.addPage(new PDPage(PDRectangle.A4));
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
document.getDocumentCatalog().setAcroForm(acroForm);
return acroForm;
}
private static byte[] save(PDDocument document) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
private static FormUtils.NewFormFieldDefinition newField(
String type, String name, float x, float y, float w, float h, List<String> options) {
return new FormUtils.NewFormFieldDefinition(
name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null,
null, null);
}
/** Moves a field to a rect; null width/height leave the size alone. */
private static FormUtils.ModifyFormFieldDefinition moveTo(
String target, float x, float y, Float w, Float h) {
return new FormUtils.ModifyFormFieldDefinition(
target, null, null, null, 0, x, y, w, h, null, null, null, null, null, null, null,
null, null, null);
}
private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) {
PDField field = acroForm.getField(name);
assertNotNull(field, "field '" + name + "' should exist");
return field.getWidgets().get(0).getRectangle();
}
/** The /AP /N state names on a widget. */
private static Set<String> normalStateNames(PDAnnotationWidget widget) {
PDAppearanceDictionary appearance = widget.getAppearance();
assertNotNull(appearance, "widget should have an /AP dictionary");
PDAppearanceEntry normal = appearance.getNormalAppearance();
assertNotNull(normal, "widget should have an /AP /N entry");
assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances");
return normal.getSubDictionary().keySet().stream()
.map(COSName::getName)
.collect(Collectors.toSet());
}
@Test
void movingCheckboxKeepsItFillable() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 200f, 400f, null, null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("agree");
assertTrue(field instanceof PDCheckBox, "'agree' should still be a checkbox");
assertFalse(
((PDCheckBox) field).getOnValue().isEmpty(),
"a moved checkbox must keep an on-state, or it can never be ticked again");
assertTrue(
normalStateNames(field.getWidgets().get(0)).size() >= 2,
"both /AP /N states must survive a move");
PDRectangle rect = firstWidgetRect(acroForm, "agree");
assertEquals(200f, rect.getLowerLeftX(), 0.5f);
assertEquals(400f, rect.getLowerLeftY(), 0.5f);
}
}
@Test
void resizingCheckboxRebuildsAppearanceAtTheNewSize() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 28f, 28f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDCheckBox checkBox = (PDCheckBox) acroForm.getField("agree");
assertFalse(
checkBox.getOnValue().isEmpty(), "a resized checkbox must keep its on-state");
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
assertTrue(normalStateNames(widget).size() >= 2, "both /AP /N states must be rebuilt");
PDRectangle bbox =
widget.getAppearance()
.getNormalAppearance()
.getSubDictionary()
.get(COSName.getPDFName(checkBox.getOnValue()))
.getBBox();
assertEquals(28f, bbox.getWidth(), 0.5f, "the rebuilt /AP must match the new size");
}
}
/** applyToggleAppearance parks /AS on Off, so a resize must put the selection back. */
@Test
void resizingCheckboxKeepsItChecked() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
((PDCheckBox) form.getField("agree")).check();
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(
((PDCheckBox) acroForm.getField("agree")).isChecked(),
"a resize must not silently untick the box");
}
}
/** Only widgets.get(0) used to move, so a radio group lost every option but the first. */
@Test
void movingRadioGroupMovesEveryOption() throws IOException {
byte[] saved;
float[] before = new float[6];
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document,
List.of(newField("radio", "choice", 50, 700, 14, 14, List.of("A", "B", "C"))));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
List<PDAnnotationWidget> widgets = form.getField("choice").getWidgets();
assertEquals(3, widgets.size(), "the fixture needs three option widgets");
for (int i = 0; i < 3; i++) {
before[i * 2] = widgets.get(i).getRectangle().getLowerLeftX();
before[i * 2 + 1] = widgets.get(i).getRectangle().getLowerLeftY();
}
FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 670f, null, null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("choice");
assertTrue(field instanceof PDRadioButton, "'choice' should still be a radio group");
List<PDAnnotationWidget> widgets = field.getWidgets();
assertEquals(3, widgets.size(), "no option may be left behind");
float dx = 90f - before[0];
float dy = 670f - before[1];
for (int i = 0; i < 3; i++) {
PDRectangle rect = widgets.get(i).getRectangle();
assertEquals(
before[i * 2] + dx,
rect.getLowerLeftX(),
0.5f,
"option " + i + " should shift by the same delta");
assertEquals(before[i * 2 + 1] + dy, rect.getLowerLeftY(), 0.5f);
}
}
}
/** A signature's /AP is the signature, so it must never be dropped. */
@Test
void movingSignatureKeepsItsAppearance() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("signature", "sig", 50, 700, 120, 40, null)));
FormUtils.modifyFormFields(document, List.of(moveTo("sig", 60f, 600f, 140f, 50f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(
acroForm.getField("sig") instanceof PDSignatureField,
"'sig' should still be a signature");
assertEquals(60f, firstWidgetRect(acroForm, "sig").getLowerLeftX(), 0.5f);
}
}
@Test
void invalidFieldNameReason_rejectsPeriodAndAllowsTheRest() {
String reason = FormUtils.invalidFieldNameReason("Customer.Name");
assertNotNull(reason, "a period must be refused, not silently dropped");
assertTrue(reason.contains("period"), "the message should name the offending character");
assertNull(FormUtils.invalidFieldNameReason("Has Space"));
assertNull(FormUtils.invalidFieldNameReason("weird/[]{}"));
assertNull(FormUtils.invalidFieldNameReason(null));
}
/** Dropped operations used to log a warning and still report success. */
@Test
void applyFieldEdits_reportsEveryDroppedOperation() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "present", 50, 700, 200, 20, null)));
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.applyFieldEdits(
document,
List.of(newField("text", "Bad.Name", 50, 600, 100, 20, null)),
List.of(moveTo("ghost", 10f, 10f, null, null)),
List.of("alsoGhost"),
skipped);
assertEquals(3, skipped.size(), "each dropped operation should be reported");
assertTrue(skipped.stream().anyMatch(s -> "add".equals(s.operation())));
assertTrue(skipped.stream().anyMatch(s -> "modify".equals(s.operation())));
assertTrue(skipped.stream().anyMatch(s -> "delete".equals(s.operation())));
assertNotNull(
document.getDocumentCatalog().getAcroForm(null).getField("present"),
"the rest of the document must still be applied");
}
}
/** A clean batch must not report anything, or the UI would cry wolf on every save. */
@Test
void applyFieldEdits_reportsNothingWhenEverythingApplies() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.applyFieldEdits(
document,
List.of(newField("text", "fine", 50, 700, 200, 20, null)),
List.of(),
List.of(),
skipped);
assertTrue(skipped.isEmpty(), "a fully applied batch reports no skips");
}
}
/** A drag must not normalise other options to the dragged widget's size. */
@Test
void movingRadioGroupKeepsEachOptionsOwnSize() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document,
List.of(newField("radio", "choice", 50, 700, 20, 20, List.of("A", "B"))));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
List<PDAnnotationWidget> widgets = form.getField("choice").getWidgets();
// Hand-authored groups legitimately have option boxes of differing size.
PDRectangle second = widgets.get(1).getRectangle();
widgets.get(1)
.setRectangle(
new PDRectangle(
second.getLowerLeftX(), second.getLowerLeftY(), 40f, 40f));
FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 700f, 20f, 20f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
List<PDAnnotationWidget> widgets = acroForm.getField("choice").getWidgets();
assertEquals(
40f,
widgets.get(1).getRectangle().getWidth(),
0.5f,
"a pure drag must not shrink the other options");
assertEquals(90f, widgets.get(0).getRectangle().getLowerLeftX(), 0.5f);
}
}
/** With no /AP and no /Opt the on-state must come from /V, not the invented "Yes". */
@Test
void resizingCheckboxWithoutAppearanceKeepsItsExportValue() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
PDCheckBox box = (PDCheckBox) form.getField("agree");
// A NeedAppearances form exported by Word/LibreOffice looks exactly like this.
box.getWidgets().get(0).getCOSObject().removeItem(COSName.AP);
box.getCOSObject().setItem(COSName.V, COSName.getPDFName("On"));
FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDCheckBox box = (PDCheckBox) acroForm.getField("agree");
assertEquals(
"On",
box.getOnValue(),
"the export value must survive; inventing 'Yes' would orphan /V");
assertTrue(box.isChecked(), "the box was ticked and must stay ticked");
}
}
/** Renaming to the same qualified name is not a rename, so a nested field is not rejected. */
@Test
void renameProblem_ignoresAnUnchangedQualifiedName() {
assertNull(
FormUtils.renameProblem("Customer.Name", "Customer.Name"),
"a field standing still must not be rejected for its parent's period");
assertNull(FormUtils.renameProblem("plain", null));
assertNotNull(
FormUtils.renameProblem("plain", "New.Name"),
"an actual rename introducing a period must still be refused");
}
/** A nested field whose name box was left at its qualified name must still be modified. */
@Test
void modifyingNestedFieldKeepsWorkingWhenNameIsUntouched() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
// Re-parent it so its qualified name legitimately contains a period.
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
PDField child = form.getField("Name");
parent.setChildren(List.of(child));
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
form.setFields(List.of(parent));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
"Customer.Name",
null,
null,
0,
90f,
600f,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(mod), skipped);
assertTrue(
skipped.isEmpty(), "an untouched qualified name is not a rename: " + skipped);
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("Customer.Name");
assertNotNull(field, "the nested field must survive the edit");
assertEquals(90f, field.getWidgets().get(0).getRectangle().getLowerLeftX(), 0.5f);
}
}
/** Zero clears /MaxLen; null means unchanged, so it could never be removed otherwise. */
@Test
void maxLengthZeroClearsTheCombSetting() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"code", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null,
null, null, null, null, null, 8, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
assertEquals(8, ((PDTextField) form.getField("code")).getMaxLen());
FormUtils.ModifyFormFieldDefinition clear =
new FormUtils.ModifyFormFieldDefinition(
"code", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, 0, null);
FormUtils.modifyFormFields(document, List.of(clear));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertEquals(
-1,
((PDTextField) acroForm.getField("code")).getMaxLen(),
"/MaxLen should be gone, not merely zero");
}
}
/** An unrecognised button action must be reported rather than silently ignored. */
@Test
void unknownButtonActionIsReported() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"go",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"launchTheMissiles");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(mod), skipped);
assertEquals(1, skipped.size(), "an unusable action spec should be reported");
assertTrue(skipped.get(0).reason().contains("launchTheMissiles"));
}
}
/** Renaming a nested field must not re-parent it to the top level. */
@Test
void renamingNestedFieldKeepsItUnderItsParent() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
PDField child = form.getField("Name");
parent.setChildren(List.of(child));
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
form.setFields(List.of(parent));
FormUtils.ModifyFormFieldDefinition rename =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
"Customer.Phone",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(rename), skipped);
assertTrue(
skipped.isEmpty(), "a leaf rename under the same parent is legal: " + skipped);
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(
acroForm.getField("Customer.Phone"),
"the field should still live under Customer, not at the top level");
assertNull(acroForm.getField("Customer.Name"), "the old name should be gone");
}
}
/** One rejected action on a multi-widget button is one report, not one per widget. */
@Test
void unknownButtonActionIsReportedOncePerField() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
PDField button = form.getField("go");
// Give it a second widget, as a button repeated on two pages would have.
PDAnnotationWidget extra = new PDAnnotationWidget();
extra.setRectangle(new PDRectangle(50, 600, 100, 24));
extra.getCOSObject().setItem(COSName.PARENT, button.getCOSObject());
List<PDAnnotationWidget> widgets = new ArrayList<>(button.getWidgets());
widgets.add(extra);
button.getCOSObject()
.setItem(
COSName.KIDS,
new org.apache.pdfbox.cos.COSArray() {
{
for (PDAnnotationWidget w : widgets) add(w.getCOSObject());
}
});
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"go",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"launchTheMissiles");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(mod), skipped);
assertEquals(1, skipped.size(), "one field, one report: " + skipped);
}
}
/** A clamped page index still creates the field, so it is not a dropped edit. */
@Test
void clampedPageIsNotReportedAsSkipped() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"late", null, "text", 9, 50f, 700f, 100f, 20f, null, null, null,
null, null, null, null, null, null, null)),
skipped);
assertNotNull(
document.getDocumentCatalog().getAcroForm(null).getField("late"),
"the field is created on the clamped page");
assertTrue(skipped.isEmpty(), "an applied edit must not appear as skipped: " + skipped);
}
}
/** Recreation builds a top-level field, so it must refuse rather than re-parent. */
@Test
void typeChangeOnNestedFieldIsRefusedNotSilentlyReparented() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("text", "Name", 50, 700, 200, 20, null)));
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
PDField child = form.getField("Name");
parent.setChildren(List.of(child));
child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
form.setFields(List.of(parent));
FormUtils.ModifyFormFieldDefinition retype =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
null,
null,
"checkbox",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(retype), skipped);
assertEquals(1, skipped.size(), "the refusal must be reported: " + skipped);
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(
acroForm.getField("Customer.Name"),
"the original nested field must be left intact");
assertNull(acroForm.getField("Name"), "nothing should be re-parented to the top level");
}
}
/** The editor emits "uri:" the moment that kind is picked, which must not fail the edit. */
@Test
void incompleteUrlActionClearsRatherThanFailing() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
FormUtils.ModifyFormFieldDefinition pickUri =
new FormUtils.ModifyFormFieldDefinition(
"go", null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, "uri:");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(pickUri), skipped);
assertTrue(
skipped.isEmpty(),
"choosing a URL action before typing the URL is not an error: " + skipped);
PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go");
assertNull(
button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A),
"an empty target must leave no action behind");
}
}
/** A real URL still writes a real action. */
@Test
void completeUrlActionIsApplied() throws IOException {
try (PDDocument document = new PDDocument()) {
setupForm(document);
FormUtils.addNewFields(
document, List.of(newField("button", "go", 50, 700, 100, 24, null)));
FormUtils.ModifyFormFieldDefinition setUri =
new FormUtils.ModifyFormFieldDefinition(
"go",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"uri:https://example.com");
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(setUri), skipped);
assertTrue(skipped.isEmpty(), "a complete spec applies cleanly: " + skipped);
PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go");
assertNotNull(
button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A),
"the action should be written");
}
}
/** Builds a parent with the given terminal children already attached. */
private static PDNonTerminalField nest(
PDDocument document, PDAcroForm form, String parentName, String... childNames)
throws IOException {
List<FormUtils.NewFormFieldDefinition> defs = new ArrayList<>();
for (int i = 0; i < childNames.length; i++) {
defs.add(newField("text", childNames[i], 50, 700 - i * 40, 200, 20, null));
}
FormUtils.addNewFields(document, defs);
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName(parentName);
List<PDField> kids = new ArrayList<>();
for (String child : childNames) {
PDField field = form.getField(child);
field.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
kids.add(field);
}
parent.setChildren(kids);
form.setFields(List.of(parent));
return parent;
}
/** A refused edit must not release the name the field still really has. */
@Test
void refusedNestedEditDoesNotFreeItsNameForALaterEdit() throws IOException {
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
nest(document, form, "Customer", "Name", "Email");
// Edit 1 is refused (type change on a nested field). Edit 2 then asks for the
// name edit 1 still occupies, which must not be handed out.
FormUtils.ModifyFormFieldDefinition refused =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Name",
"Customer.Foo",
null,
"checkbox",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.ModifyFormFieldDefinition rename =
new FormUtils.ModifyFormFieldDefinition(
"Customer.Email",
"Customer.Name",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.modifyFormFields(document, List.of(refused, rename), skipped);
List<String> names = new ArrayList<>();
for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) {
if (f instanceof PDTerminalField) names.add(f.getFullyQualifiedName());
}
assertEquals(
names.size(),
new java.util.HashSet<>(names).size(),
"two fields must never share a qualified name: " + names);
assertTrue(
names.contains("Customer.Name"), "the refused field keeps its name: " + names);
}
}
/** A group name occupies the namespace, so a new field must not be able to take it. */
@Test
void groupNamesParticipateInCollisionChecks() throws IOException {
try (PDDocument document = new PDDocument()) {
PDAcroForm form = setupForm(document);
nest(document, form, "Customer", "Name");
FormUtils.addNewFields(
document, List.of(newField("text", "Customer", 50, 500, 100, 20, null)));
List<String> names = new ArrayList<>();
for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) {
String fqn = f.getFullyQualifiedName();
if (fqn != null) names.add(fqn);
}
assertEquals(
names.size(),
new java.util.HashSet<>(names).size(),
"the new field must not take the group's name: " + names);
}
}
/** "Customer." has no leaf, so it must be refused rather than become "Customer.field". */
@Test
void renameToBareParentPrefixIsRefused() {
assertNotNull(
FormUtils.renameProblem("Customer.Name", "Customer."),
"a name with nothing after the parent prefix is not a rename");
assertNull(FormUtils.renameProblem("Customer.Name", "Customer.Phone"));
}
/** A type change must leave the field on its own page, not relocate it to the last one. */
@Test
void typeChangeKeepsTheFieldOnItsPage() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm form = new PDAcroForm(document);
for (int i = 0; i < 5; i++) {
document.addPage(new PDPage(PDRectangle.A4));
}
form.setDefaultResources(new PDResources());
document.getDocumentCatalog().setAcroForm(form);
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"onPageTwo",
null,
"text",
1,
50f,
700f,
200f,
20f,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null)));
FormUtils.ModifyFormFieldDefinition retype =
new FormUtils.ModifyFormFieldDefinition(
"onPageTwo",
null,
null,
"checkbox",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(document, List.of(retype));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("onPageTwo");
assertNotNull(field, "the retyped field should exist");
int page = -1;
for (int i = 0; i < reloaded.getNumberOfPages(); i++) {
for (var annot : reloaded.getPage(i).getAnnotations()) {
if (annot.getCOSObject() == field.getWidgets().get(0).getCOSObject()) page = i;
}
}
assertEquals(
1, page, "a retyped field must stay on its own page, not move to the last");
}
}
}
@@ -0,0 +1,118 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.junit.jupiter.api.Test;
/** An edit the backend cannot honour must be reported, not logged and reported as success. */
class FormUtilsEditReportingTest {
private static FormUtils.NewFormFieldDefinition field(String type, String name) {
return new FormUtils.NewFormFieldDefinition(
name, name, type, 0, 60f, 700f, 120f, 20f, null, null, null, null, null, null, null,
null, null, null);
}
private static PDDocument blank() {
PDDocument document = new PDDocument();
document.addPage(new PDPage(PDRectangle.LETTER));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
return document;
}
@Test
void anUncreatableTypeIsReportedRatherThanSilentlyMadeText() throws IOException {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
try (PDDocument document = blank()) {
FormUtils.addNewFields(document, List.of(field("nonsense", "mystery")), skipped);
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(null);
assertTrue(
acroForm.getFields().isEmpty(),
"an unsupported type must not quietly become a text field");
}
assertEquals(1, skipped.size(), "the caller must be told: " + skipped);
assertTrue(skipped.get(0).reason().contains("nonsense"), skipped.get(0).reason());
}
@Test
void aLyingPageCountIsSurvivable() throws IOException {
// /Count overstates the tree, so getNumberOfPages() passes the guard but getPage throws.
byte[] broken =
("%PDF-1.4\n"
+ "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"
+ "2 0 obj << /Type /Pages /Count 1 /Kids [] >> endobj\n"
+ "trailer << /Root 1 0 R >>\n")
.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
try (PDDocument document = Loader.loadPDF(broken)) {
// Must not throw; the field is reported as skipped instead.
FormUtils.addNewFields(document, List.of(field("text", "ghost")), skipped);
} catch (IOException loadFailure) {
// A parser that refuses the file outright is an equally acceptable outcome.
return;
}
assertFalse(skipped.isEmpty(), "an unreachable page must be reported, not thrown");
}
@Test
void aTwoWidgetCheckboxKeepsItsOnStateWhenMoved() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
document.addPage(new PDPage(PDRectangle.LETTER));
document.addPage(new PDPage(PDRectangle.LETTER));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"agree",
"agree",
"checkbox",
0,
60f,
700f,
14f,
14f,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null)),
new ArrayList<>());
FormUtils.modifyFormFields(
document,
List.of(
new FormUtils.ModifyFormFieldDefinition(
"agree", null, null, null, 0, 200f, 400f, null, null, null,
null, null, null, null, null, null, null, null, null)));
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.save(out);
saved = out.toByteArray();
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDCheckBox box = (PDCheckBox) acroForm.getField("agree");
assertNotNull(box);
assertFalse(box.getOnValue().isEmpty(), "a moved checkbox must stay tickable");
}
}
}
@@ -0,0 +1,467 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Set;
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.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.apache.pdfbox.pdmodel.interactive.form.PDVariableText;
import org.junit.jupiter.api.Test;
/**
* Assertions run after a save/reload cycle: PDFBox synthesises widgets for fields with no explicit
* {@code /Kids}, so only the serialised document reflects what a viewer sees.
*/
class FormUtilsEditingTest {
private static PDAcroForm setupForm(PDDocument document, PDRectangle pageSize) {
PDPage page = new PDPage(pageSize);
document.addPage(page);
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
document.getDocumentCatalog().setAcroForm(acroForm);
return acroForm;
}
private static byte[] save(PDDocument document) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
private static FormUtils.NewFormFieldDefinition newText(
String name, float x, float y, float w, float h) {
return new FormUtils.NewFormFieldDefinition(
name, null, "text", 0, x, y, w, h, null, null, null, null, null, null, null, null,
null, null);
}
private static FormUtils.NewFormFieldDefinition newField(
String type,
String name,
float x,
float y,
float w,
float h,
List<String> options,
Integer maxLength,
String buttonAction) {
return new FormUtils.NewFormFieldDefinition(
name,
null,
type,
0,
x,
y,
w,
h,
null,
null,
options,
null,
null,
null,
null,
null,
maxLength,
buttonAction);
}
private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) {
PDField field = acroForm.getField(name);
assertNotNull(field, "field '" + name + "' should exist");
assertTrue(!field.getWidgets().isEmpty(), "field should have at least one widget");
return field.getWidgets().get(0).getRectangle();
}
/**
* PDAcroForm.refreshAppearances() never synthesizes /AP for the button family, so without an
* explicit appearance a created checkbox or radio renders blank and resolves to Off.
*/
@Test
void addNewFields_givesToggleFieldsAppearanceStreamsAndKeepsTheirDefault() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField("checkbox", "agree", 50, 600, 20, 20, null, null, null),
newField(
"radio",
"choice",
50,
500,
20,
20,
List.of("Yes", "No"),
null,
null),
newText("fullname", 50, 400, 200, 24)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm);
// NeedAppearances=false means viewers trust our streams, so they must exist.
assertFalse(acroForm.getNeedAppearances(), "appearance generation should have run");
PDField checkBox = acroForm.getField("agree");
assertTrue(checkBox instanceof PDCheckBox);
assertEquals(
Set.of("Off", "Yes"),
normalStateNames(checkBox.getWidgets().get(0)),
"checkbox needs an Off and an on-state appearance");
PDField radio = acroForm.getField("choice");
assertTrue(radio instanceof PDRadioButton);
assertEquals(2, radio.getWidgets().size());
assertEquals(Set.of("Off", "Yes"), normalStateNames(radio.getWidgets().get(0)));
assertEquals(Set.of("Off", "No"), normalStateNames(radio.getWidgets().get(1)));
// A text field's DA names /Helv; if /DR lacks that alias refreshAppearances throws for
// the whole form and every field above loses its appearance too.
PDField text = acroForm.getField("fullname");
assertNotNull(
text.getWidgets().get(0).getAppearance().getNormalAppearance(),
"text field should have a generated appearance");
}
}
/** The /AP /N state names on a widget. */
private static Set<String> normalStateNames(PDAnnotationWidget widget) {
PDAppearanceDictionary appearance = widget.getAppearance();
assertNotNull(appearance, "widget should have an /AP dictionary");
PDAppearanceEntry normal = appearance.getNormalAppearance();
assertNotNull(normal, "widget should have an /AP /N entry");
assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances");
return normal.getSubDictionary().keySet().stream()
.map(COSName::getName)
.collect(java.util.stream.Collectors.toSet());
}
@Test
void addNewFields_createsTextFieldAtRequestedRectangle() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("created", 50, 700, 200, 20)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm, "AcroForm should exist after reload");
assertTrue(acroForm.getField("created") instanceof PDTextField);
PDRectangle rect = firstWidgetRect(acroForm, "created");
assertNotNull(rect, "created widget should keep its rectangle after reload");
assertEquals(50f, rect.getLowerLeftX(), 0.5f);
assertEquals(700f, rect.getLowerLeftY(), 0.5f);
assertEquals(200f, rect.getWidth(), 0.5f);
assertEquals(20f, rect.getHeight(), 0.5f);
}
}
@Test
void addNewFields_appliesCropBoxOffsetToCoordinates() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
// Shift the CropBox origin; the frontend sends CropBox-relative coords.
document.getPage(0).setCropBox(new PDRectangle(10, 20, 500, 700));
FormUtils.addNewFields(document, List.of(newText("shifted", 5, 5, 100, 15)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDRectangle rect = firstWidgetRect(acroForm, "shifted");
// Absolute = CropBox-relative + CropBox lower-left offset.
assertEquals(15f, rect.getLowerLeftX(), 0.5f);
assertEquals(25f, rect.getLowerLeftY(), 0.5f);
}
}
@Test
void addNewFields_appliesReadOnlyFontSizeAndMultiline() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.NewFormFieldDefinition def =
new FormUtils.NewFormFieldDefinition(
"opts",
null,
"text",
0,
10f,
10f,
120f,
18f,
null,
null,
null,
null,
null,
18f,
Boolean.TRUE,
Boolean.TRUE,
null,
null);
FormUtils.addNewFields(document, List.of(def));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("opts");
assertNotNull(field);
assertTrue(field.isReadOnly(), "read-only flag should survive reload");
assertTrue(field instanceof PDTextField);
assertTrue(((PDTextField) field).isMultiline(), "multiline flag should survive reload");
String da = ((PDVariableText) field).getDefaultAppearance();
assertTrue(da.contains("18"), "default appearance should carry the font size: " + da);
}
}
@Test
void modifyFormFields_movesAndResizesWidget() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("movable", 50, 700, 200, 20)));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"movable", null, null, null, 0, 100f, 600f, 150f, 30f, null, null, null,
null, null, null, null, null, null, null);
FormUtils.modifyFormFields(document, List.of(mod));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDRectangle rect = firstWidgetRect(acroForm, "movable");
assertEquals(100f, rect.getLowerLeftX(), 0.5f);
assertEquals(600f, rect.getLowerLeftY(), 0.5f);
assertEquals(150f, rect.getWidth(), 0.5f);
assertEquals(30f, rect.getHeight(), 0.5f);
}
}
@Test
void modifyFormFields_setsReadOnlyAndFontSize() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("editable", 50, 700, 200, 20)));
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"editable",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
22f,
Boolean.TRUE,
null,
null,
null);
FormUtils.modifyFormFields(document, List.of(mod));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("editable");
assertNotNull(field);
assertTrue(field.isReadOnly(), "read-only flag should survive reload");
String da = ((PDVariableText) field).getDefaultAppearance();
assertTrue(da.contains("22"), "font size should be reflected in DA: " + da);
}
}
@Test
void deleteFormFields_removesField() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
PDAcroForm acroForm = setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("temp", 50, 700, 200, 20)));
FormUtils.deleteFormFields(document, List.of("temp"));
// After delete the AcroForm may still exist; the field must be gone.
if (acroForm != null) {
assertNull(acroForm.getField("temp"));
}
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(acroForm == null || acroForm.getField("temp") == null);
}
}
@Test
void addNewFields_createsRadioGroupWithOneWidgetPerOption() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"choice",
60,
700,
16,
16,
List.of("Yes", "No"),
null,
null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDField field = acroForm.getField("choice");
assertNotNull(field, "radio field should exist");
assertTrue(field instanceof PDRadioButton, "should be a radio button group");
assertEquals(2, field.getWidgets().size(), "one widget per option");
assertTrue(((PDRadioButton) field).getExportValues().contains("Yes"));
assertTrue(((PDRadioButton) field).getExportValues().contains("No"));
}
}
@Test
void extractFormFields_prefersFieldNameOverFirstOptionForChoiceLabel() throws IOException {
// A radio group's label is its field name, not its first option, so the viewer label
// matches the name shown in the editor.
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"Choice",
60,
700,
16,
16,
List.of("Yes", "No"),
null,
null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
FormUtils.FormFieldInfo choice =
FormUtils.extractFormFields(reloaded).stream()
.filter(f -> "Choice".equals(f.name()))
.findFirst()
.orElse(null);
assertNotNull(choice, "radio field should be extracted");
assertEquals(
"Choice", choice.label(), "field name should win over the first option value");
}
}
@Test
void addNewFields_createsCombTextField() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document, List.of(newField("text", "ssn", 50, 700, 200, 20, null, 9, null)));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDTextField field = (PDTextField) acroForm.getField("ssn");
assertNotNull(field);
assertEquals(9, field.getMaxLen(), "comb max length should persist");
assertTrue(field.isComb(), "comb flag should be set");
}
}
@Test
void addNewFields_createsSignatureAndButton() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(
document,
List.of(
newField("signature", "sig", 50, 600, 200, 60, null, null, null),
newField("button", "btn", 50, 500, 120, 24, null, null, "reset")));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertTrue(
acroForm.getField("sig") instanceof PDSignatureField,
"signature placeholder should exist");
assertTrue(
acroForm.getField("btn") instanceof PDPushButton, "push button should exist");
}
}
@Test
void applyFieldEdits_addsModifiesAndDeletesInOnePass() throws IOException {
byte[] saved;
try (PDDocument document = new PDDocument()) {
setupForm(document, PDRectangle.A4);
FormUtils.addNewFields(document, List.of(newText("old", 50, 700, 200, 20)));
FormUtils.applyFieldEdits(
document,
List.of(newText("fresh", 50, 600, 200, 20)),
List.of(),
List.of("old"));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm.getField("fresh"), "added field should be present");
assertNull(acroForm.getField("old"), "deleted field should be gone");
}
}
}
@@ -705,10 +705,20 @@ class FormUtilsGapTest {
"newName",
"New Label",
null, // keep type (text) -> in-place path
null,
null,
null,
null,
null,
Boolean.TRUE,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -731,7 +741,8 @@ class FormUtilsGapTest {
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"missing", null, null, null, null, null, null, null, null);
"missing", null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -754,7 +765,8 @@ class FormUtilsGapTest {
mods.add(null);
mods.add(
new FormUtils.ModifyFormFieldDefinition(
" ", null, null, null, null, null, null, null, null));
" ", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null));
FormUtils.modifyFormFields(doc, mods);
assertEquals(1, FormUtils.extractFormFields(doc).size());
@@ -285,13 +285,13 @@ class FormUtilsMoreTest {
}
@Test
void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException {
void widgetOutOfBoundsStillReportsItsCoordinates() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField text = new PDTextField(setup.acroForm());
text.setPartialName("offpage");
// Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates
// returns null, which is still added to the per-field widget list.
// Off the page is legal PDF; dropping it would leave the user unable to drag it
// back.
attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20));
List<FormFieldWithCoordinates> fields =
@@ -301,7 +301,8 @@ class FormUtilsMoreTest {
fields.get(0).getWidgets();
assertNotNull(widgets);
assertEquals(1, widgets.size());
assertNull(widgets.get(0));
assertNotNull(widgets.get(0), "a null entry here crashes sorting and the overlay");
assertEquals(50f, widgets.get(0).getX(), 0.01f);
}
}
@@ -476,8 +477,18 @@ class FormUtilsMoreTest {
"combobox",
null,
null,
null,
null,
null,
null,
null,
List.of("One", "Two"),
"One",
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -505,10 +516,20 @@ class FormUtilsMoreTest {
null,
"listbox", // same type -> in-place path
null,
null,
null,
null,
null,
null,
Boolean.TRUE,
List.of("X", "Y", "Z"),
null,
"Choose items");
"Choose items",
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -529,7 +550,25 @@ class FormUtilsMoreTest {
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"keep", null, null, "bogusType", null, null, null, null, null);
"keep",
null,
null,
"bogusType",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
FormUtils.modifyFormFields(doc, List.of(mod));
// The field is preserved unchanged because the target type is unsupported.
@@ -554,7 +593,8 @@ class FormUtilsMoreTest {
// Rename beta -> alpha; should be uniquified to avoid the collision.
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"beta", "alpha", null, null, null, null, null, null, null);
"beta", "alpha", null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
@@ -575,7 +615,8 @@ class FormUtilsMoreTest {
doc.addPage(new PDPage());
FormUtils.ModifyFormFieldDefinition mod =
new FormUtils.ModifyFormFieldDefinition(
"x", null, null, null, null, null, null, null, null);
"x", null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null);
FormUtils.modifyFormFields(doc, List.of(mod));
}
}
@@ -0,0 +1,102 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;
/**
* Most real PDFs have no AcroForm at all, so adding the very first field has to build one that
* PDFBox will accept.
*/
class FormUtilsNoAcroFormTest {
private static final Path PLAIN_PDF =
Path.of("src/test/resources/pdf-ingestion-fixtures/many-tables-test_stress.pdf");
private static FormUtils.NewFormFieldDefinition newField(
String type, String name, float y, List<String> options, String defaultValue) {
// name, label, type, pageIndex, x, y, width, height, required, multiSelect,
// options, defaultValue, tooltip, fontSize, readOnly, multiline, maxLength, buttonAction
return new FormUtils.NewFormFieldDefinition(
name,
name,
type,
0,
60f,
y,
200f,
20f,
null,
null,
options,
defaultValue,
null,
null,
null,
null,
null,
null);
}
private static PDDocument loadPlain() throws IOException {
return Loader.loadPDF(Files.readAllBytes(PLAIN_PDF));
}
@Test
void plainPdfReallyHasNoAcroForm() throws IOException {
try (PDDocument document = loadPlain()) {
assertNull(
document.getDocumentCatalog().getAcroForm(null),
"fixture must have no AcroForm or this test proves nothing");
}
}
@Test
void addsFirstFieldToAPdfWithNoAcroForm() throws IOException {
byte[] saved;
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
try (PDDocument document = loadPlain()) {
FormUtils.addNewFields(
document,
List.of(
newField("text", "fullName", 700f, null, "Ada"),
newField("checkbox", "agree", 660f, null, null),
newField("radio", "contact", 600f, List.of("Email", "Post"), null)),
skipped);
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.save(out);
saved = out.toByteArray();
}
assertTrue(skipped.isEmpty(), "no field should be skipped: " + skipped);
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
assertNotNull(acroForm, "an AcroForm should have been created");
assertNotNull(acroForm.getDefaultResources(), "/DR is required for variable text");
assertTrue(
acroForm.getDefaultAppearance() != null
&& !acroForm.getDefaultAppearance().isBlank(),
"/DA is required for variable text");
PDTextField text = (PDTextField) acroForm.getField("fullName");
assertNotNull(text, "the text field should exist");
assertEquals("Ada", text.getValueAsString());
assertNotNull(acroForm.getField("agree"));
assertNotNull(acroForm.getField("contact"));
}
}
}
@@ -0,0 +1,175 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
/**
* Option captions belong to the viewer, not the page. Drawing them into the content stream left
* orphan text behind on every move and delete, so these pin the page staying clean.
*/
class FormUtilsRadioCaptionTest {
private static FormUtils.NewFormFieldDefinition newField(
String type, String name, float x, float y, float w, float h, List<String> options) {
return new FormUtils.NewFormFieldDefinition(
name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null,
null, null);
}
private static byte[] save(PDDocument document) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.save(out);
return out.toByteArray();
}
private static PDDocument blankWithForm() {
PDDocument document = new PDDocument();
document.addPage(new PDPage(PDRectangle.LETTER));
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
return document;
}
private static String textOf(byte[] pdf) throws IOException {
try (PDDocument reloaded = Loader.loadPDF(pdf)) {
return new PDFTextStripper().getText(reloaded);
}
}
@Test
void radioOptionsAreNotBakedIntoThePage() throws IOException {
byte[] saved;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"contact",
72,
600,
12,
12,
List.of("Email", "Telephone", "Post"))));
saved = save(document);
}
// The caption is the viewer's job; page content cannot follow a widget that moves.
String text = textOf(saved);
assertFalse(text.contains("Email"), "options must not be page content: " + text);
assertFalse(text.contains("Telephone"), "options must not be page content: " + text);
assertFalse(text.contains("Post"), "options must not be page content: " + text);
}
@Test
void captionsDoNotReplaceTheWidgetsThemselves() throws IOException {
byte[] saved;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document,
List.of(newField("radio", "size", 72, 600, 12, 12, List.of("S", "M", "L"))));
saved = save(document);
}
try (PDDocument reloaded = Loader.loadPDF(saved)) {
PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null);
PDRadioButton radio = (PDRadioButton) acroForm.getField("size");
assertEquals(3, radio.getWidgets().size(), "one widget per option");
assertFalse(radio.getExportValues().isEmpty(), "export values must survive");
}
}
@Test
void aTextFieldDrawsNoStrayCaption() throws IOException {
// Control: proves the assertions above read the captions and not some unrelated content.
byte[] saved;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document, List.of(newField("text", "fullName", 72, 600, 200, 18, null)));
saved = save(document);
}
assertTrue(textOf(saved).isBlank(), "a text field should add no page content");
}
@Test
void deletingARadioGroupTakesItsCaptionsWithIt() throws IOException {
byte[] withRadio;
try (PDDocument document = blankWithForm()) {
FormUtils.addNewFields(
document,
List.of(
newField(
"radio",
"contact",
72,
600,
12,
12,
List.of("Email", "Telephone", "Post"))));
withRadio = save(document);
}
assertFalse(
textOf(withRadio).contains("Telephone"),
"the group adds no page text to begin with");
byte[] afterDelete;
try (PDDocument document = Loader.loadPDF(withRadio)) {
FormUtils.applyFieldEdits(document, List.of(), List.of(), List.of("contact"));
afterDelete = save(document);
}
String text = textOf(afterDelete);
assertFalse(
text.contains("Telephone"),
"a deleted radio group must not leave its captions on the page: " + text);
}
@Test
void theDrawnBoxIsTheWholeGroupNotOneOption() {
// A 90pt box used to become a 360pt stack because each option got the full height.
PDRectangle box = new PDRectangle(72f, 500f, 100f, 90f);
var rects = FormUtils.radioOptionRects(box, 3, null, null);
assertEquals(3, rects.size());
float top = rects.get(0).getUpperRightY();
float bottom = rects.get(2).getLowerLeftY();
assertEquals(90f, top - bottom, 0.01f, "the group must fill exactly the drawn height");
assertEquals(
box.getUpperRightY(), top, 0.01f, "the first option starts at the box's top edge");
for (PDRectangle r : rects) {
assertEquals(r.getWidth(), r.getHeight(), 0.01f, "options stay square");
assertTrue(r.getWidth() <= box.getWidth() + 0.01f, "an option never exceeds the box");
}
}
@Test
void explicitSizeAndGapWin() {
PDRectangle box = new PDRectangle(0f, 0f, 100f, 90f);
var rects = FormUtils.radioOptionRects(box, 3, 20f, 14f);
for (PDRectangle r : rects) {
assertEquals(14f, r.getHeight(), 0.01f, "the requested size is used verbatim");
}
float gap = rects.get(0).getLowerLeftY() - rects.get(1).getUpperRightY();
assertEquals(20f, gap, 0.01f, "the requested gap is used verbatim");
}
@Test
void aSingleOptionStillFitsTheBox() {
var rects = FormUtils.radioOptionRects(new PDRectangle(0f, 0f, 40f, 40f), 1, null, null);
assertEquals(1, rects.size());
assertTrue(rects.get(0).getHeight() <= 40f, "one option cannot exceed its box");
}
}
@@ -0,0 +1,57 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/** A form with no default resources is ordinary; adding a field to it must still work. */
class MissingDefaultResourcesTest {
@Test
@DisplayName("a text field can be added to a form that has no default resources")
void addsToFormWithoutDefaultResources() throws IOException {
// A real upload arrives as bytes, and plenty of forms in the wild carry no /DR at all.
byte[] pdf;
try (PDDocument built = new PDDocument();
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) {
built.addPage(new PDPage(PDRectangle.A4));
PDAcroForm form = new PDAcroForm(built);
// A /DA naming a font with no /DR to resolve it is what PDFBox refuses.
form.setDefaultAppearance("/Helv 0 Tf 0 g");
form.getCOSObject().removeItem(org.apache.pdfbox.cos.COSName.DR);
built.getDocumentCatalog().setAcroForm(form);
built.save(out);
pdf = out.toByteArray();
}
try (PDDocument document = org.apache.pdfbox.Loader.loadPDF(pdf)) {
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
FormUtils.addNewFields(
document,
List.of(
new FormUtils.NewFormFieldDefinition(
"note", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null,
null, null, null, null, null, null, null)),
skipped);
assertTrue(
skipped.isEmpty(),
"adding a plain text field should not be refused: " + skipped);
assertEquals(
1,
FormUtils.extractFormFields(document).size(),
"the field should be in the document");
}
}
}
@@ -183,7 +183,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
.allowCredentials(true)
.maxAge(3600);
} else if (hasConfiguredOrigins) {
@@ -229,7 +231,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
.allowCredentials(true)
.maxAge(3600);
} else {
@@ -256,7 +260,9 @@ public class WebMvcConfig implements WebMvcConfigurer {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
.allowCredentials(true)
.maxAge(3600);
}
@@ -2,10 +2,20 @@ package stirling.software.SPDF.controller.api.form;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.poi.ss.usermodel.*;
@@ -35,6 +45,7 @@ import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -59,6 +70,25 @@ import tools.jackson.databind.ObjectMapper;
@RequiredArgsConstructor
public class FormFillController {
/** Carries the edits a request asked for but the document could not take, as base64 JSON. */
public static final String SKIPPED_EDITS_HEADER = "X-Stirling-Skipped-Field-Edits";
/** How many were skipped in total, which may exceed the number listed in the header above. */
public static final String SKIPPED_EDITS_TOTAL_HEADER = "X-Stirling-Skipped-Field-Edits-Total";
/** Keeps the header well inside Jetty's response-header budget. */
private static final int MAX_REPORTED_SKIPS = 20;
/** Bytes of encoded header value, well under the container's limit for the whole header set. */
private static final int MAX_SKIP_HEADER_BYTES = 4096;
private static final int MAX_SKIP_FIELD_CHARS = 120;
/** Entry names inside the {@code ?includeFields=true} bundle. */
private static final String FIELDS_ENTRY = "fields.json";
private static final String DOCUMENT_ENTRY = "document.pdf";
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private final TempFileManager tempFileManager;
@@ -68,6 +98,72 @@ public class FormFillController {
return WebResponseUtils.pdfDocToWebResponse(document, baseName + ".pdf", tempFileManager);
}
/**
* Rejects field names PDFBox cannot store before the document is touched, so the caller gets a
* 400 naming the offending character instead of a 200 with the field quietly missing.
*/
private static void requireUsableFieldNames(
List<FormUtils.NewFormFieldDefinition> adds,
List<FormUtils.ModifyFormFieldDefinition> modifies) {
Stream<String> problems =
Stream.concat(
adds.stream()
.map(FormUtils.NewFormFieldDefinition::name)
.map(FormUtils::invalidFieldNameReason),
// A rename to the same name is not a rename, so a nested field whose
// qualified name already contains a period is left alone.
modifies.stream()
.map(m -> FormUtils.renameProblem(m.targetName(), m.name())));
problems.filter(Objects::nonNull)
.findFirst()
.ifPresent(
reason -> {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "{0}", reason);
});
}
/**
* The body is the updated PDF, so dropped edits travel as a base64 JSON header;
* percent-encoding would turn every space into a plus sign.
*/
private ResponseEntity<Resource> withSkippedEdits(
ResponseEntity<Resource> response, List<FormUtils.SkippedFieldEdit> skipped) {
if (skipped.isEmpty()) {
return response;
}
// A count cap alone is not enough: one very long field name can still overflow the
// header budget and turn the response into an error page, losing the edited PDF.
List<FormUtils.SkippedFieldEdit> reported = new ArrayList<>();
String encoded = "";
for (FormUtils.SkippedFieldEdit edit : skipped) {
if (reported.size() >= MAX_REPORTED_SKIPS) {
break;
}
reported.add(
new FormUtils.SkippedFieldEdit(
edit.operation(),
FormUtils.abbreviate(edit.target(), MAX_SKIP_FIELD_CHARS),
FormUtils.abbreviate(edit.reason(), MAX_SKIP_FIELD_CHARS)));
String candidate =
Base64.getEncoder()
.encodeToString(
objectMapper
.writeValueAsString(reported)
.getBytes(StandardCharsets.UTF_8));
if (candidate.length() > MAX_SKIP_HEADER_BYTES) {
reported.removeLast();
break;
}
encoded = candidate;
}
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.header(SKIPPED_EDITS_TOTAL_HEADER, String.valueOf(skipped.size()))
.header(SKIPPED_EDITS_HEADER, encoded)
.body(response.getBody());
}
private static String buildBaseName(MultipartFile file, String suffix) {
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
if (original == null || original.isBlank()) {
@@ -257,6 +353,110 @@ public class FormFillController {
}
}
@PostMapping(value = "/add-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Add new form fields",
description =
"Creates new form fields in the provided PDF and returns the updated file")
public ResponseEntity<Resource> addFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description = "JSON array of new field definitions",
example =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]")
@RequestPart(value = "fields", required = false)
byte[] fieldsPayload)
throws IOException {
String rawFields = decodePart(fieldsPayload);
List<FormUtils.NewFormFieldDefinition> definitions =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, rawFields);
if (definitions.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired",
"{0} must contain at least one definition",
"fields payload");
}
requireUsableFieldNames(definitions, List.of());
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
document -> FormUtils.addNewFields(document, definitions, skipped)),
skipped);
}
@PostMapping(value = "/edit-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Apply a batch of form field edits",
description =
"Adds, modifies, and deletes form fields in a single request (one document"
+ " load/save) and returns the updated file")
public ResponseEntity<Resource> editFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description =
"JSON object with optional 'add', 'modify' and 'delete'"
+ " sections",
example =
"{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}],"
+ "\"modify\":[],\"delete\":[]}")
@RequestPart(value = "edits", required = false)
byte[] editsPayload,
@Parameter(
description =
"Return a ZIP holding the updated PDF plus the field list it"
+ " produced, instead of the bare PDF. Saves re-uploading"
+ " the result just to read its fields back.")
@RequestParam(value = "includeFields", defaultValue = "false")
boolean includeFields)
throws IOException {
String rawEdits = decodePart(editsPayload);
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, rawEdits);
if (batch.add().isEmpty() && batch.modify().isEmpty() && batch.delete().isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired", "{0} must contain at least one edit", "edits payload");
}
requireUsableFieldNames(batch.add(), batch.modify());
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
includeFields,
document ->
FormUtils.applyFieldEdits(
document,
batch.add(),
batch.modify(),
batch.delete(),
skipped)),
skipped);
}
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Modify existing form fields",
@@ -285,8 +485,15 @@ public class FormFillController {
"updates payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.modifyFormFields(document, modifications));
requireUsableFieldNames(List.of(), modifications);
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
document -> FormUtils.modifyFormFields(document, modifications, skipped)),
skipped);
}
@PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -319,8 +526,13 @@ public class FormFillController {
"error.dataRequired", "{0} must contain at least one value", "names payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.deleteFormFields(document, names));
List<FormUtils.SkippedFieldEdit> skipped = new ArrayList<>();
return withSkippedEdits(
processSingleFile(
file,
"updated",
document -> FormUtils.deleteFormFields(document, names, skipped)),
skipped);
}
@PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -358,13 +570,81 @@ public class FormFillController {
private ResponseEntity<Resource> processSingleFile(
MultipartFile file, String suffix, DocumentProcessor processor) throws IOException {
return processSingleFile(file, suffix, false, processor);
}
private ResponseEntity<Resource> processSingleFile(
MultipartFile file, String suffix, boolean includeFields, DocumentProcessor processor)
throws IOException {
requirePdf(file);
String baseName = buildBaseName(file, suffix);
try (PDDocument document = pdfDocumentFactory.load(file)) {
FormUtils.repairMissingWidgetPageReferences(document);
processor.accept(document);
return saveDocument(document, baseName);
return includeFields
? saveDocumentWithFields(document, baseName)
: saveDocument(document, baseName);
}
}
/**
* Answers "what fields does the saved file have?" from the document still open here, so the
* caller does not have to upload the result back to ask.
*/
private ResponseEntity<Resource> saveDocumentWithFields(PDDocument document, String baseName)
throws IOException {
TempFile zip = null;
boolean zipTransferred = false;
try (TempFile pdf = tempFileManager.createManagedTempFile(".pdf")) {
document.save(pdf.getFile());
// Read the fields after the save so they describe the bytes actually being returned.
byte[] fields =
objectMapper.writeValueAsBytes(
FormUtils.extractFormFieldsWithCoordinates(document));
zip = tempFileManager.createManagedTempFile(".zip");
writeFieldBundle(zip.getPath(), pdf.getPath(), fields);
ResponseEntity<Resource> response =
WebResponseUtils.zipFileToWebResponse(zip, baseName + ".zip");
zipTransferred = true;
return response;
} finally {
if (zip != null && !zipTransferred) {
zip.close();
}
}
}
/**
* Deflates the JSON because it is text, but stores the PDF: its streams are already compressed,
* so deflating costs ~25ms per MB to save a few percent.
*/
private static void writeFieldBundle(Path zipPath, Path pdfPath, byte[] fields)
throws IOException {
long pdfSize = Files.size(pdfPath);
CRC32 crc = new CRC32();
try (InputStream in = Files.newInputStream(pdfPath)) {
byte[] buffer = new byte[8192];
for (int read; (read = in.read(buffer)) != -1; ) {
crc.update(buffer, 0, read);
}
}
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(zipPath))) {
ZipEntry fieldsEntry = new ZipEntry(FIELDS_ENTRY);
fieldsEntry.setMethod(ZipEntry.DEFLATED);
zip.putNextEntry(fieldsEntry);
zip.write(fields);
zip.closeEntry();
ZipEntry documentEntry = new ZipEntry(DOCUMENT_ENTRY);
documentEntry.setMethod(ZipEntry.STORED);
documentEntry.setSize(pdfSize);
documentEntry.setCompressedSize(pdfSize);
documentEntry.setCrc(crc.getValue());
zip.putNextEntry(documentEntry);
Files.copy(pdfPath, zip);
zip.closeEntry();
zip.finish();
}
}
@@ -28,6 +28,8 @@ final class FormPayloadParser {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.NewFormFieldDefinition>> NEW_FIELD_LIST_TYPE =
new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private FormPayloadParser() {}
@@ -94,6 +96,43 @@ final class FormPayloadParser {
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
}
static List<FormUtils.NewFormFieldDefinition> parseNewFieldDefinitions(
ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return List.of();
}
return objectMapper.readValue(json, NEW_FIELD_LIST_TYPE);
}
/**
* Parses a combined edit batch: {@code {"add":[...],"modify":[...],"delete":[...]}}. Each
* section is optional. The delete section accepts the same shapes as {@link #parseNameList}.
*/
static FormUtils.FieldEditBatch parseFieldEdits(ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return new FormUtils.FieldEditBatch(List.of(), List.of(), List.of());
}
final JsonNode root = objectMapper.readTree(json);
List<FormUtils.NewFormFieldDefinition> adds = List.of();
List<FormUtils.ModifyFormFieldDefinition> modifies = List.of();
List<String> deletes = List.of();
if (root != null && root.isObject()) {
final JsonNode addNode = root.get("add");
if (addNode != null && addNode.isArray()) {
adds = objectMapper.readValue(addNode.toString(), NEW_FIELD_LIST_TYPE);
}
final JsonNode modifyNode = root.get("modify");
if (modifyNode != null && modifyNode.isArray()) {
modifies = objectMapper.readValue(modifyNode.toString(), MODIFY_FIELD_LIST_TYPE);
}
final JsonNode deleteNode = root.get("delete");
if (deleteNode != null && !deleteNode.isNull()) {
deletes = parseNameList(objectMapper, deleteNode.toString());
}
}
return new FormUtils.FieldEditBatch(adds, modifies, deletes);
}
static List<String> parseNameList(ObjectMapper objectMapper, String json) {
if (json == null || json.isBlank()) {
return List.of();
@@ -12,7 +12,7 @@ To convert a PDF file to a single WebP image:
To adjust the DPI resolution for rendering PDF pages:
python script.py input.pdf output_directory --dpi 150
"""
""" # noqa: E501
import argparse
import os
@@ -55,13 +55,13 @@ def resize_image(input_image_path, output_image_path, max_size=(16383, 16383)):
resized_image = image.resize((new_width, new_height), Image.LANCZOS)
resized_image.save(output_image_path, format="WEBP", quality=100)
print(
f"The image was successfully resized to ({new_width}, {new_height}) and saved as WebP: {output_image_path}"
f"The image was successfully resized to ({new_width}, {new_height}) and saved as WebP: {output_image_path}" # noqa: E501
)
else:
# If dimensions are within the allowed limits, save the image directly
image.save(output_image_path, format="WEBP", quality=100)
print(f"The image was successfully saved as WebP: {output_image_path}")
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"An error occurred: {e}")
@@ -1,13 +1,14 @@
import argparse
import sys
import os
import cv2
import numpy as np
import os
def find_photo_boundaries(image, background_color, tolerance=30, min_area=10000, min_contour_area=500):
mask = cv2.inRange(image, background_color - tolerance, background_color + tolerance)
mask = cv2.bitwise_not(mask)
kernel = np.ones((5,5),np.uint8)
kernel = np.ones((5, 5), np.uint8)
mask = cv2.dilate(mask, kernel, iterations=2)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
@@ -21,6 +22,7 @@ def find_photo_boundaries(image, background_color, tolerance=30, min_area=10000,
return photo_boundaries
def estimate_background_color(image, sample_points=5):
h, w, _ = image.shape
points = [
@@ -37,6 +39,7 @@ def estimate_background_color(image, sample_points=5):
return np.median(colors, axis=0)
def auto_rotate(image, angle_threshold=1):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
@@ -61,8 +64,6 @@ def auto_rotate(image, angle_threshold=1):
return cv2.warpAffine(image, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
def crop_borders(image, border_color, tolerance=30):
mask = cv2.inRange(image, border_color - tolerance, border_color + tolerance)
@@ -73,14 +74,31 @@ def crop_borders(image, border_color, tolerance=30):
largest_contour = max(contours, key=cv2.contourArea)
x, y, w, h = cv2.boundingRect(largest_contour)
return image[y:y+h, x:x+w]
return image[y : y + h, x : x + w]
def split_photos(input_file, output_directory, tolerance=30, min_area=10000, min_contour_area=500, angle_threshold=10, border_size=0):
def split_photos(
input_file,
output_directory,
tolerance=30,
min_area=10000,
min_contour_area=500,
angle_threshold=10,
border_size=0,
):
image = cv2.imread(input_file)
background_color = estimate_background_color(image)
# Add a constant border around the image
image = cv2.copyMakeBorder(image, border_size, border_size, border_size, border_size, cv2.BORDER_CONSTANT, value=background_color)
image = cv2.copyMakeBorder(
image,
border_size,
border_size,
border_size,
border_size,
cv2.BORDER_CONSTANT,
value=background_color,
)
photo_boundaries = find_photo_boundaries(image, background_color, tolerance)
@@ -91,7 +109,7 @@ def split_photos(input_file, output_directory, tolerance=30, min_area=10000, min
input_file_basename = os.path.splitext(os.path.basename(input_file))[0]
for idx, (x, y, w, h) in enumerate(photo_boundaries):
cropped_image = image[y:y+h, x:x+w]
cropped_image = image[y : y + h, x : x + w]
cropped_image = auto_rotate(cropped_image, angle_threshold)
# Remove the added border, but ensure we don't create an empty image
@@ -100,23 +118,60 @@ def split_photos(input_file, output_directory, tolerance=30, min_area=10000, min
# Check if the cropped image is valid before saving
if cropped_image.size == 0 or cropped_image.shape[0] == 0 or cropped_image.shape[1] == 0:
print(f"Warning: Skipping empty image for region {idx+1}")
print(f"Warning: Skipping empty image for region {idx + 1}")
continue
output_path = os.path.join(output_directory, f"{input_file_basename}_{idx+1}.png")
output_path = os.path.join(output_directory, f"{input_file_basename}_{idx + 1}.png")
cv2.imwrite(output_path, cropped_image)
print(f"Saved {output_path}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Split photos in an image")
parser.add_argument("input_file", help="The input scanned image containing multiple photos.")
parser.add_argument("output_directory", help="The directory where the result images should be placed.")
parser.add_argument("--tolerance", type=int, default=30, help="Determines the range of color variation around the estimated background color (default: 30).")
parser.add_argument("--min_area", type=int, default=10000, help="Sets the minimum area threshold for a photo (default: 10000).")
parser.add_argument("--min_contour_area", type=int, default=500, help="Sets the minimum contour area threshold for a photo (default: 500).")
parser.add_argument("--angle_threshold", type=int, default=10, help="Sets the minimum absolute angle required for the image to be rotated (default: 10).")
parser.add_argument("--border_size", type=int, default=0, help="Sets the size of the border added and removed to prevent white borders in the output (default: 0).")
parser.add_argument(
"output_directory",
help="The directory where the result images should be placed.",
)
parser.add_argument(
"--tolerance",
type=int,
default=30,
help="Determines the range of color variation around the estimated background color (default: 30).",
)
parser.add_argument(
"--min_area",
type=int,
default=10000,
help="Sets the minimum area threshold for a photo (default: 10000).",
)
parser.add_argument(
"--min_contour_area",
type=int,
default=500,
help="Sets the minimum contour area threshold for a photo (default: 500).",
)
parser.add_argument(
"--angle_threshold",
type=int,
default=10,
help="Sets the minimum absolute angle required for the image to be rotated (default: 10).",
)
parser.add_argument(
"--border_size",
type=int,
default=0,
help="Sets the size of the border added and removed to prevent white borders in the output (default: 0).",
)
args = parser.parse_args()
split_photos(args.input_file, args.output_directory, tolerance=args.tolerance, min_area=args.min_area, min_contour_area=args.min_contour_area, angle_threshold=args.angle_threshold, border_size=args.border_size)
split_photos(
args.input_file,
args.output_directory,
tolerance=args.tolerance,
min_area=args.min_area,
min_contour_area=args.min_contour_area,
angle_threshold=args.angle_threshold,
border_size=args.border_size,
)
@@ -0,0 +1,374 @@
package stirling.software.SPDF.controller.api.form;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
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.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Drives ?includeFields=true across a spread of real form shapes, checking the bundled list stays
* interchangeable with the follow-up request it exists to remove.
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("edit-fields field bundle")
class FormFieldBundleTest {
/** Set to a directory to dump the produced archives for the frontend reader's fixtures. */
private static final String FIXTURE_DIR = System.getProperty("bundle.fixtures");
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private FormFillController controller;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
invocation -> {
File file =
Files.createTempFile(
"bundle", invocation.<String>getArgument(0))
.toFile();
TempFile temp = mock(TempFile.class);
lenient().when(temp.getFile()).thenReturn(file);
lenient().when(temp.getPath()).thenReturn(file.toPath());
return temp;
});
objectMapper = JsonMapper.builder().build();
var field = FormFillController.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(controller, objectMapper);
}
// -- document shapes ----------------------------------------------
private record Style(
String name, int pages, int rotation, List<FormUtils.NewFormFieldDefinition> fields) {}
private static FormUtils.NewFormFieldDefinition field(
String name, String type, int page, float y, List<String> options) {
return new FormUtils.NewFormFieldDefinition(
name, null, type, page, 50f, y, 200f, 20f, null, null, options, null, null, null,
null, null, null, null);
}
static List<Style> styles() {
List<Style> styles = new ArrayList<>();
styles.add(new Style("text-only", 1, 0, List.of(field("fullName", "text", 0, 700f, null))));
styles.add(
new Style(
"checkbox-and-radio",
1,
0,
List.of(
field("agree", "checkbox", 0, 700f, null),
field("plan", "radio", 0, 650f, List.of("basic", "pro")))));
styles.add(
new Style(
"choice-widgets",
1,
0,
List.of(
field("country", "dropdown", 0, 700f, List.of("UK", "IE", "FR")),
field("tags", "listbox", 0, 640f, List.of("a", "b", "c")))));
styles.add(
new Style(
"signature", 1, 0, List.of(field("approval", "signature", 0, 700f, null))));
styles.add(
new Style(
"multi-page",
3,
0,
List.of(
field("p1", "text", 0, 700f, null),
field("p2", "text", 1, 700f, null),
field("p3", "text", 2, 700f, null))));
styles.add(new Style("rotated-90", 1, 90, List.of(field("rot", "text", 0, 700f, null))));
styles.add(new Style("rotated-270", 1, 270, List.of(field("rot", "text", 0, 700f, null))));
styles.add(
new Style(
"unicode-names",
1,
0,
List.of(
field("nom_complet", "text", 0, 700f, null),
field("adresse postale", "text", 0, 660f, null))));
List<FormUtils.NewFormFieldDefinition> many = new ArrayList<>();
for (int i = 0; i < 120; i++) {
many.add(
field(
"field_" + i,
i % 3 == 0 ? "checkbox" : "text",
i / 40,
740f - (i % 40) * 18f,
null));
}
styles.add(new Style("many-fields", 3, 0, many));
return styles;
}
private byte[] blankPdf(int pages, int rotation) throws IOException {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
for (int i = 0; i < pages; i++) {
PDPage page = new PDPage(PDRectangle.A4);
page.setRotation(rotation);
document.addPage(page);
}
document.getDocumentCatalog().setAcroForm(new PDAcroForm(document));
document.save(out);
return out.toByteArray();
}
}
// -- the test ------------------------------------------------------
@ParameterizedTest(name = "{0}")
@MethodSource("styles")
@DisplayName("bundled list matches the follow-up request it replaces")
void bundleMatchesRefetch(Style style) throws Exception {
byte[] source = blankPdf(style.pages(), style.rotation());
MockMultipartFile upload =
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", source);
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
byte[] zipBytes;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
Map<String, byte[]> bundle = unzip(zipBytes);
assertThat(bundle).containsKeys("document.pdf", "fields.json");
byte[] editedPdf = bundle.get("document.pdf");
assertThat(new String(editedPdf, 0, 5, StandardCharsets.UTF_8)).isEqualTo("%PDF-");
// The comparison that matters: ask the endpoint this feature stops re-calling,
// and demand a match.
MockMultipartFile saved =
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", editedPdf);
try (PDDocument reloaded = Loader.loadPDF(editedPdf)) {
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
ResponseEntity<List<FormFieldWithCoordinates>> refetched =
controller.listFieldsWithCoordinates(saved);
assertThat(new String(bundle.get("fields.json"), StandardCharsets.UTF_8))
.isEqualTo(objectMapper.writeValueAsString(refetched.getBody()));
}
dumpFixture(style.name(), zipBytes);
}
@ParameterizedTest(name = "{0}")
@MethodSource("styles")
@DisplayName("pdf entry is stored and json entry is deflated")
void perEntryCompression(Style style) throws Exception {
byte[] source = blankPdf(style.pages(), style.rotation());
MockMultipartFile upload =
new MockMultipartFile("file", style.name() + ".pdf", "application/pdf", source);
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
byte[] zipBytes;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
Map<String, Integer> methods = methodsOf(zipBytes);
assertThat(methods.get("document.pdf")).isEqualTo(ZipEntry.STORED);
assertThat(methods.get("fields.json")).isEqualTo(ZipEntry.DEFLATED);
}
@Test
@DisplayName("hierarchical field names survive the bundle")
void nestedFieldNames() throws Exception {
byte[] source = nestedPdf();
MockMultipartFile upload =
new MockMultipartFile("file", "nested.pdf", "application/pdf", source);
byte[] edits =
objectMapper.writeValueAsBytes(
Map.of(
"modify",
List.of(
Map.of(
"targetName",
"Customer.Name",
"defaultValue",
"Ada"))));
byte[] zipBytes;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
Map<String, byte[]> bundle = unzip(zipBytes);
byte[] editedPdf = bundle.get("document.pdf");
MockMultipartFile saved =
new MockMultipartFile("file", "nested.pdf", "application/pdf", editedPdf);
try (PDDocument reloaded = Loader.loadPDF(editedPdf)) {
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
ResponseEntity<List<FormFieldWithCoordinates>> refetched =
controller.listFieldsWithCoordinates(saved);
String bundled = new String(bundle.get("fields.json"), StandardCharsets.UTF_8);
assertThat(bundled).contains("Customer.Name");
assertThat(bundled).isEqualTo(objectMapper.writeValueAsString(refetched.getBody()));
}
}
/** Builds a parent field with two children, which add-fields cannot express. */
private byte[] nestedPdf() throws IOException {
try (PDDocument document = Loader.loadPDF(blankPdf(1, 0));
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
PDAcroForm form = document.getDocumentCatalog().getAcroForm(null);
FormUtils.addNewFields(
document,
List.of(
field("Name", "text", 0, 700f, null),
field("Email", "text", 0, 660f, null)));
PDNonTerminalField parent = new PDNonTerminalField(form);
parent.setPartialName("Customer");
List<PDField> kids = new ArrayList<>();
for (String child : List.of("Name", "Email")) {
PDField kid = form.getField(child);
kid.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject());
kids.add(kid);
}
parent.setChildren(kids);
form.setFields(List.of(parent));
document.save(out);
return out.toByteArray();
}
}
@Test
@DisplayName("bundle stays close to the wire cost of the two calls it replaces")
void wireCost() throws Exception {
Style style =
styles().stream()
.filter(s -> s.name().equals("many-fields"))
.findFirst()
.orElseThrow();
byte[] source = blankPdf(style.pages(), style.rotation());
MockMultipartFile upload =
new MockMultipartFile("file", "cost.pdf", "application/pdf", source);
byte[] edits = objectMapper.writeValueAsBytes(Map.of("add", style.fields()));
byte[] zipBytes;
Map<String, byte[]> bundle;
try (PDDocument document = Loader.loadPDF(source)) {
when(pdfDocumentFactory.load(eq(upload))).thenReturn(document);
zipBytes = drain(controller.editFields(upload, edits, true));
}
bundle = unzip(zipBytes);
int pdfSize = bundle.get("document.pdf").length;
int jsonSize = bundle.get("fields.json").length;
System.out.printf(
"wire: pdf=%d json=%d zip=%d overhead=%d bytes (%.2f%% over the pdf alone)%n",
pdfSize,
jsonSize,
zipBytes.length,
zipBytes.length - pdfSize,
100.0 * (zipBytes.length - pdfSize) / pdfSize);
// True for a field list this repetitive; on a tiny list the ~200 bytes of zip framing can
// exceed what deflate saves, so this is a property of the fixture, not of every document.
assertThat(zipBytes.length).isLessThan(pdfSize + jsonSize);
}
// -- helpers -------------------------------------------------------
private static byte[] drain(ResponseEntity<Resource> response) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (InputStream in = response.getBody().getInputStream()) {
in.transferTo(out);
}
return out.toByteArray();
}
private static Map<String, byte[]> unzip(byte[] zipBytes) throws IOException {
Map<String, byte[]> entries = new HashMap<>();
try (ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
for (ZipEntry entry; (entry = in.getNextEntry()) != null; ) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
in.transferTo(out);
entries.put(entry.getName(), out.toByteArray());
}
}
return entries;
}
private static Map<String, Integer> methodsOf(byte[] zipBytes) throws IOException {
Map<String, Integer> methods = new HashMap<>();
try (ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
for (ZipEntry entry; (entry = in.getNextEntry()) != null; ) {
methods.put(entry.getName(), entry.getMethod());
in.transferTo(OutputStream.nullOutputStream());
}
}
return methods;
}
private static void dumpFixture(String name, byte[] zipBytes) throws IOException {
if (FIXTURE_DIR == null) {
return;
}
Path dir = Paths.get(FIXTURE_DIR);
Files.createDirectories(dir);
Files.write(dir.resolve(name + ".zip"), zipBytes);
}
}
@@ -29,6 +29,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -330,6 +331,160 @@ class FormFillControllerTest {
}
}
// ── addFields ──────────────────────────────────────────────────────
@Nested
@DisplayName("addFields")
class AddFields {
@Test
@DisplayName("throws when fields payload is null")
void nullPayload() {
assertThatThrownBy(() -> controller.addFields(pdfFile(), null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("throws when fields payload is an empty list")
void emptyPayload() {
assertThatThrownBy(() -> controller.addFields(pdfFile(), "[]".getBytes()))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("processes a valid new-field payload")
void validPayload() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
String json =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]";
ResponseEntity<Resource> response = controller.addFields(file, json.getBytes());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
}
// ── editFields (combined) ──────────────────────────────────────────
@Nested
@DisplayName("editFields")
class EditFields {
@Test
@DisplayName("throws when edits payload is null")
void nullPayload() {
assertThatThrownBy(() -> controller.editFields(pdfFile(), null, false))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("throws when all sections are empty")
void emptyBatch() {
assertThatThrownBy(
() ->
controller.editFields(
pdfFile(),
"{\"add\":[],\"modify\":[],\"delete\":[]}".getBytes(),
false))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("processes a combined add/delete batch")
void validBatch() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
String json =
"{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0,\"x\":50,"
+ "\"y\":700,\"width\":200,\"height\":20}],\"modify\":[],"
+ "\"delete\":[]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
@Test
@DisplayName("refuses a field name containing a period before touching the document")
void refusesPeriodInName() throws Exception {
String json =
"{\"add\":[{\"name\":\"Customer.Name\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]}";
assertThatThrownBy(() -> controller.editFields(pdfFile(), json.getBytes(), false))
.hasMessageContaining("period");
// Rejected up front, so the document is never even loaded.
verify(pdfDocumentFactory, never()).load(any(MockMultipartFile.class));
}
@Test
@DisplayName("renaming a nested field to its own qualified name is not a rename")
void allowsUnchangedQualifiedName() throws Exception {
MockMultipartFile file = pdfFile();
when(pdfDocumentFactory.load(eq(file))).thenReturn(createMinimalPdf());
String json =
"{\"modify\":[{\"targetName\":\"Customer.Name\",\"name\":\"Customer.Name\","
+ "\"x\":10,\"y\":10}]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// It must get past validation into the edit loop: the only complaint should be that
// this document has no such field, never that the name contains a period.
String encoded =
response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER);
assertThat(encoded).isNotNull();
String report =
new String(
java.util.Base64.getDecoder().decode(encoded),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(report).contains("no field with that name exists").doesNotContain("period");
}
@Test
@DisplayName("reports a dropped edit as base64 JSON in the skipped-edits header")
void reportsSkippedEdits() throws Exception {
MockMultipartFile file = pdfFile();
when(pdfDocumentFactory.load(eq(file))).thenReturn(createMinimalPdf());
String json = "{\"delete\":[\"noSuchField\"]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
String encoded =
response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER);
assertThat(encoded).isNotNull();
String report =
new String(
java.util.Base64.getDecoder().decode(encoded),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(report).contains("noSuchField").contains("delete");
// Base64 rather than percent-encoding, so spaces survive as spaces.
assertThat(report).contains("no field with that name exists");
}
@Test
@DisplayName("omits the skipped-edits header when everything applied")
void noHeaderOnCleanBatch() throws Exception {
MockMultipartFile file = pdfFile();
when(pdfDocumentFactory.load(eq(file))).thenReturn(createMinimalPdf());
String json =
"{\"add\":[{\"name\":\"clean\",\"type\":\"text\",\"pageIndex\":0,\"x\":50,"
+ "\"y\":700,\"width\":200,\"height\":20}]}";
ResponseEntity<Resource> response = controller.editFields(file, json.getBytes(), false);
assertThat(response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER))
.isNull();
}
}
// ── buildBaseName ──────────────────────────────────────────────────
@Nested
@@ -384,4 +539,156 @@ class FormFillControllerTest {
assertThat(result).isEqualTo("document_filled");
}
}
// -- includeFields bundle ------------------------------------------
@Nested
@DisplayName("editFields ?includeFields=true")
class FieldBundle {
private byte[] editsPayload() {
return ("{\"add\":[{\"name\":\"bundled\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]}")
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
private java.util.Map<String, java.util.zip.ZipEntry> entriesOf(byte[] zipBytes)
throws IOException {
java.util.Map<String, java.util.zip.ZipEntry> found = new java.util.HashMap<>();
try (java.util.zip.ZipInputStream in =
new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(zipBytes))) {
for (java.util.zip.ZipEntry e; (e = in.getNextEntry()) != null; ) {
java.io.ByteArrayOutputStream data = new java.io.ByteArrayOutputStream();
in.transferTo(data);
// getMethod/getSize are only final once the entry has been fully read.
found.put(e.getName(), e);
payloads.put(e.getName(), data.toByteArray());
}
}
return found;
}
private final java.util.Map<String, byte[]> payloads = new java.util.HashMap<>();
private byte[] bundleFor(MockMultipartFile file) throws Exception {
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
ResponseEntity<Resource> response = controller.editFields(file, editsPayload(), true);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
return drainBody(response);
}
@Test
@DisplayName("returns a zip holding the pdf and the field list")
void bundlesBoth() throws Exception {
byte[] zip = bundleFor(pdfFile());
entriesOf(zip);
assertThat(payloads).containsKeys("document.pdf", "fields.json");
assertThat(new String(payloads.get("document.pdf"), 0, 5)).isEqualTo("%PDF-");
assertThat(
new String(
payloads.get("fields.json"),
java.nio.charset.StandardCharsets.UTF_8))
.contains("bundled");
}
@Test
@DisplayName("stores the pdf entry but deflates the json")
void perEntryMethods() throws Exception {
byte[] zip = bundleFor(pdfFile());
java.util.Map<String, java.util.zip.ZipEntry> entries = entriesOf(zip);
assertThat(entries.get("document.pdf").getMethod())
.as("deflating an already-compressed PDF burns CPU for almost nothing")
.isEqualTo(java.util.zip.ZipEntry.STORED);
assertThat(entries.get("fields.json").getMethod())
.as("the JSON is text and no longer gets the container's gzip")
.isEqualTo(java.util.zip.ZipEntry.DEFLATED);
}
@Test
@DisplayName("bundled fields match what a follow-up fetch would have returned")
void matchesTheSecondCallItReplaces() throws Exception {
byte[] zip = bundleFor(pdfFile());
entriesOf(zip);
byte[] bundledPdf = payloads.get("document.pdf");
// Re-ask the endpoint this feature stops re-calling, using the returned bytes.
MockMultipartFile saved =
new MockMultipartFile("file", "test.pdf", "application/pdf", bundledPdf);
try (PDDocument reloaded = org.apache.pdfbox.Loader.loadPDF(bundledPdf)) {
when(pdfDocumentFactory.load(eq(saved), eq(true))).thenReturn(reloaded);
ResponseEntity<
java.util.List<
stirling.software.common.model.FormFieldWithCoordinates>>
refetched = controller.listFieldsWithCoordinates(saved);
String viaRefetch = realObjectMapper.writeValueAsString(refetched.getBody());
String viaBundle =
new String(
payloads.get("fields.json"),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(viaBundle)
.as("the bundle must be interchangeable with the round trip it removes")
.isEqualTo(viaRefetch);
}
}
@Test
@DisplayName("omitting the flag still returns a bare pdf")
void defaultsToPlainPdf() throws Exception {
MockMultipartFile file = pdfFile();
PDDocument doc = createMinimalPdf();
when(pdfDocumentFactory.load(eq(file))).thenReturn(doc);
byte[] body = drainBody(controller.editFields(file, editsPayload(), false));
assertThat(new String(body, 0, 5)).isEqualTo("%PDF-");
}
}
// -- skipped-edits header budget -----------------------------------
@Nested
@DisplayName("skipped-edits header")
class SkipHeaderBudget {
@Test
@DisplayName("stays within budget however long the reported names are")
void staysWithinBudget() throws Exception {
java.util.List<FormUtils.SkippedFieldEdit> skipped = new java.util.ArrayList<>();
String huge = "x".repeat(20000);
for (int i = 0; i < 40; i++) {
skipped.add(new FormUtils.SkippedFieldEdit("modify", huge, huge));
}
var method =
FormFillController.class.getDeclaredMethod(
"withSkippedEdits", ResponseEntity.class, java.util.List.class);
method.setAccessible(true);
@SuppressWarnings("unchecked")
ResponseEntity<Resource> response =
(ResponseEntity<Resource>)
method.invoke(controller, streamingOk(new byte[] {1}), skipped);
String header = response.getHeaders().getFirst(FormFillController.SKIPPED_EDITS_HEADER);
assertThat(header).isNotNull();
// Not merely short: an empty header would pass a length check while telling the
// user nothing, because the alert renders only when it has entries.
String decoded =
new String(
java.util.Base64.getDecoder().decode(header),
java.nio.charset.StandardCharsets.UTF_8);
assertThat(decoded).startsWith("[{");
assertThat(decoded).contains("...");
// Overflowing the container's header budget turns the reply into an error page,
// which loses the edited PDF the user just saved.
assertThat(header.length()).isLessThanOrEqualTo(4096);
assertThat(
response.getHeaders()
.getFirst(FormFillController.SKIPPED_EDITS_TOTAL_HEADER))
.isEqualTo("40");
}
}
}
@@ -170,6 +170,94 @@ class FormPayloadParserTest {
}
}
// ── parseNewFieldDefinitions ───────────────────────────────────────
@Nested
@DisplayName("parseNewFieldDefinitions")
class ParseNewFieldDefinitions {
@Test
@DisplayName("returns empty list for null input")
void nullInput() {
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, null);
assertThat(result).isEmpty();
}
@Test
@DisplayName("returns empty list for blank input")
void blankInput() {
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, " ");
assertThat(result).isEmpty();
}
@Test
@DisplayName("parses a valid new-field list including geometry and flags")
void validNewFields() {
String json =
"[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0,"
+ "\"x\":50,\"y\":700,\"width\":200,\"height\":20,"
+ "\"fontSize\":14,\"readOnly\":true,\"multiline\":true}]";
List<FormUtils.NewFormFieldDefinition> result =
FormPayloadParser.parseNewFieldDefinitions(objectMapper, json);
assertThat(result).hasSize(1);
FormUtils.NewFormFieldDefinition def = result.get(0);
assertThat(def.name()).isEqualTo("NewField");
assertThat(def.type()).isEqualTo("text");
assertThat(def.pageIndex()).isEqualTo(0);
assertThat(def.x()).isEqualTo(50f);
assertThat(def.y()).isEqualTo(700f);
assertThat(def.width()).isEqualTo(200f);
assertThat(def.height()).isEqualTo(20f);
assertThat(def.fontSize()).isEqualTo(14f);
assertThat(def.readOnly()).isTrue();
assertThat(def.multiline()).isTrue();
}
}
// ── parseFieldEdits ────────────────────────────────────────────────
@Nested
@DisplayName("parseFieldEdits")
class ParseFieldEdits {
@Test
@DisplayName("returns empty batch for null input")
void nullInput() {
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, null);
assertThat(batch.add()).isEmpty();
assertThat(batch.modify()).isEmpty();
assertThat(batch.delete()).isEmpty();
}
@Test
@DisplayName("parses a combined add/modify/delete batch")
void combinedBatch() {
String json =
"{\"add\":[{\"name\":\"new1\",\"type\":\"text\",\"pageIndex\":0,\"x\":1,"
+ "\"y\":2,\"width\":3,\"height\":4}],"
+ "\"modify\":[{\"targetName\":\"old1\",\"label\":\"L\"}],"
+ "\"delete\":[\"gone1\",{\"name\":\"gone2\"}]}";
FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, json);
assertThat(batch.add()).hasSize(1);
assertThat(batch.add().get(0).name()).isEqualTo("new1");
assertThat(batch.modify()).hasSize(1);
assertThat(batch.modify().get(0).targetName()).isEqualTo("old1");
assertThat(batch.delete()).containsExactly("gone1", "gone2");
}
@Test
@DisplayName("tolerates missing sections")
void missingSections() {
FormUtils.FieldEditBatch batch =
FormPayloadParser.parseFieldEdits(objectMapper, "{\"delete\":[\"x\"]}");
assertThat(batch.add()).isEmpty();
assertThat(batch.modify()).isEmpty();
assertThat(batch.delete()).containsExactly("x");
}
}
// ── parseNameList ──────────────────────────────────────────────────
@Nested
@@ -213,7 +213,9 @@ public class SecurityConfiguration {
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
@@ -316,7 +316,11 @@ public class SupabaseSecurityConfig {
"Origin",
"X-API-KEY",
"X-Browser-Id"));
cfg.setExposedHeaders(List.of("WWW-Authenticate"));
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+4
View File
@@ -46,3 +46,7 @@ logs/
# OS
.DS_Store
Thumbs.db
# Coverage
.coverage
coverage/
+9 -5
View File
@@ -27,11 +27,11 @@ engine = [
# Type checking, testing, model generation, and formatting tools for the engine.
engine-dev = [
"anyio>=4.14.2",
"datamodel-code-generator[ruff]==0.64.0",
"datamodel-code-generator[ruff]>=0.64.0",
"pyright>=1.1.411",
"pytest>=9.1.1",
"pytest-cov>=7.0.0",
"referencing>=0.37.0",
"ruff==0.15.5",
]
# Dependencies for the Cucumber/Python integration test suite.
cucumber = [
@@ -65,9 +65,9 @@ updater-signatures = [
]
# Pinned repository-wide pre-commit tooling.
pre-commit = [
"codespell==2.4.3",
"ruff==0.15.5",
"tomli-w==1.2.0",
"codespell>=2.4.3",
"ruff>=0.16.2",
"tomli-w>=1.2.0",
]
[build-system]
@@ -99,6 +99,10 @@ select = [
"BLE", # flake8-blind-except: flags bare `except Exception`
]
[tool.ruff.lint.per-file-ignores]
"testing/**/*.py" = ["N803", "BLE001", "E501"]
"*split_photos.py" = ["E501", "N806"]
[tool.ruff.lint.isort]
known-first-party = ["stirling", "tests"]
+35
View File
@@ -0,0 +1,35 @@
"""Fail when any measured source file falls below the required coverage."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("report", type=Path)
parser.add_argument("--minimum", type=float, default=90.0)
args = parser.parse_args()
data = json.loads(args.report.read_text(encoding="utf-8"))
failures: list[tuple[str, float]] = []
for filename, details in data["files"].items():
coverage = float(details["summary"]["percent_covered"])
if coverage < args.minimum:
failures.append((filename, coverage))
if failures:
print(f"Per-file coverage below {args.minimum:.1f}%:", file=sys.stderr)
for filename, coverage in sorted(failures):
print(f" {coverage:.1f}% {filename}", file=sys.stderr)
return 1
print(f"Per-file coverage: every file is at least {args.minimum:.1f}%.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -35,8 +35,7 @@ _CONTRADICTION_INTENT_SYSTEM_PROMPT = (
class _ContradictionIntentDecision(ApiModel):
is_contradiction: bool = Field(
description=(
"True if the prompt is asking about textual contradictions, "
"inconsistencies, or logical conflicts in the document."
"True if the prompt is asking about textual contradictions, inconsistencies, or logical conflicts in the document." # noqa: E501
),
)
@@ -51,8 +51,7 @@ _MATH_INTENT_SYSTEM_PROMPT = (
class _MathIntentDecision(ApiModel):
is_math: bool = Field(
description=(
"True if the prompt is about verifying numerical content "
"(math, audit, calculations, totals, percentages, etc.)."
"True if the prompt is about verifying numerical content (math, audit, calculations, totals, percentages, etc.)." # noqa: E501
),
)
+1 -2
View File
@@ -344,8 +344,7 @@ class PdfEditAgent:
else ""
)
unavailable_line = (
"Unavailable operations (exist but not currently usable): "
f"{self._get_operations_prompt(unavailable_operations)}\n"
f"Unavailable operations (exist but not currently usable): {self._get_operations_prompt(unavailable_operations)}\n" # noqa: E501
if unavailable_operations
else ""
)
+1 -1
View File
@@ -218,7 +218,7 @@ async def apply_config(request: ConfigPushRequest, http_request: Request) -> Con
save_config(request)
# Claim the stamp we just wrote so this worker's watcher does not rebuild for it.
app.state.config_cache_stamp = cache_stamp()
except Exception: # noqa: BLE001 - best-effort persist, never fail the applied push
except Exception:
logger.warning("Applied AI config but failed to persist the encrypted cache", exc_info=True)
notes.append(
"Config applied on this worker but could not be persisted; it will not survive an"
+1 -2
View File
@@ -110,8 +110,7 @@ class Evidence(ApiModel):
round: int = Field(ge=1, le=3)
final_round: bool = Field(
default=False,
description="When True, Java will not honour further Requisitions. "
"The auditor must return a Verdict this round.",
description="When True, Java will not honour further Requisitions. The auditor must return a Verdict this round.", # noqa: E501
)
unauditable_pages: list[int] = Field(
default_factory=list,
@@ -91,8 +91,7 @@ class PgVectorStore(DocumentStore):
# Partial index over rows that can actually expire keeps the reaper
# scan tight even when most rows are persistent (org docs).
await cur.execute(
"CREATE INDEX IF NOT EXISTS idx_meta_expires_at "
"ON documents_meta(expires_at) WHERE expires_at IS NOT NULL"
"CREATE INDEX IF NOT EXISTS idx_meta_expires_at ON documents_meta(expires_at) WHERE expires_at IS NOT NULL" # noqa: E501
)
await cur.execute(
"""
+1 -1
View File
@@ -139,7 +139,7 @@ class DocumentService:
try:
results = await self._store.search(col_name, query_embedding, k, principals)
all_results.extend(results)
except Exception: # noqa: BLE001 - any backend error on one collection should not stop the others
except Exception:
logger.warning(
"Skipping collection %s during cross-collection search",
col_name,
@@ -366,8 +366,7 @@ class SqliteVecStore(DocumentStore):
)
if pages:
self._conn.executemany(
"INSERT INTO document_pages(collection, owner_id, page_number, text, char_count) "
"VALUES (?, ?, ?, ?, ?)",
"INSERT INTO document_pages(collection, owner_id, page_number, text, char_count) VALUES (?, ?, ?, ?, ?)", # noqa: E501
[(collection, owner_id, p.page_number, p.text, p.char_count) for p in pages],
)
self._conn.commit()
@@ -188,8 +188,7 @@ def _check_transition(
severity=DiagnosticSeverity.WARN,
code=DiagnosticCode.OUTPUT_UNCERTAIN,
message=(
"The previous step's output depends on how it is configured, "
f"so {step.operation} may not be able to run."
f"The previous step's output depends on how it is configured, so {step.operation} may not be able to run." # noqa: E501
),
)
]
-7
View File
@@ -15,7 +15,6 @@ from __future__ import annotations
import json
import pytest
from conftest import build_app_settings
from pydantic_ai.models.test import TestModel
from pydantic_ai.profiles import ModelProfile
@@ -45,7 +44,6 @@ from stirling.contracts.pdf_create import (
WrittenSections,
)
from stirling.models.agent_tool_models import AgentToolId, CreatePdfFromHtmlAgentParams
from stirling.services import build_runtime
from stirling.services.runtime import AppRuntime
_NATIVE_PROFILE = ModelProfile(supports_json_schema_output=True)
@@ -53,11 +51,6 @@ _NATIVE_PROFILE = ModelProfile(supports_json_schema_output=True)
# ── Fixtures ──────────────────────────────────────────────────────────────────────────────────────
@pytest.fixture
def runtime() -> AppRuntime:
return build_runtime(build_app_settings())
@pytest.fixture
def agent(runtime: AppRuntime) -> PdfCreateAgent:
return PdfCreateAgent(runtime)
+21 -2
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
import asyncio
from collections.abc import Iterator
from pathlib import Path
import pytest
from stirling.config import AppSettings, DocumentsBackend, load_settings
from stirling.documents import SqliteVecStore
from stirling.services import build_runtime
from stirling.services.runtime import AppRuntime
@@ -17,6 +19,21 @@ def clear_settings_cache() -> Iterator[None]:
load_settings.cache_clear()
@pytest.fixture(autouse=True)
def close_sqlite_stores(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
stores: list[SqliteVecStore] = []
original_init = SqliteVecStore.__init__
def tracked_init(store: SqliteVecStore, db_path: str | Path) -> None:
original_init(store, db_path)
stores.append(store)
monkeypatch.setattr(SqliteVecStore, "__init__", tracked_init)
yield
for store in stores:
asyncio.run(store.close())
def build_app_settings() -> AppSettings:
return AppSettings(
smart_model_name="test",
@@ -57,5 +74,7 @@ def app_settings() -> AppSettings:
@pytest.fixture
def runtime(app_settings: AppSettings) -> AppRuntime:
return build_runtime(app_settings)
def runtime(app_settings: AppSettings) -> Iterator[AppRuntime]:
app_runtime = build_runtime(app_settings)
yield app_runtime
asyncio.run(app_runtime.documents.close())
+75 -25
View File
@@ -262,6 +262,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.15.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" },
{ url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" },
{ url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" },
{ url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" },
{ url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" },
{ url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" },
{ url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" },
{ url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" },
{ url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" },
{ url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" },
{ url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" },
{ url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" },
{ url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" },
]
[[package]]
name = "cryptography"
version = "50.0.0"
@@ -430,8 +454,8 @@ engine-dev = [
{ name = "datamodel-code-generator", extra = ["ruff"] },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "referencing" },
{ name = "ruff" },
]
pre-commit = [
{ name = "codespell" },
@@ -485,16 +509,16 @@ engine = [
]
engine-dev = [
{ name = "anyio", specifier = ">=4.14.2" },
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = "==0.64.0" },
{ name = "datamodel-code-generator", extras = ["ruff"], specifier = ">=0.64.0" },
{ name = "pyright", specifier = ">=1.1.411" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "referencing", specifier = ">=0.37.0" },
{ name = "ruff", specifier = "==0.15.5" },
]
pre-commit = [
{ name = "codespell", specifier = "==2.4.3" },
{ name = "ruff", specifier = "==0.15.5" },
{ name = "tomli-w", specifier = "==1.2.0" },
{ name = "codespell", specifier = ">=2.4.3" },
{ name = "ruff", specifier = ">=0.16.2" },
{ name = "tomli-w", specifier = ">=1.2.0" },
]
tools = [
{ name = "deep-translator", specifier = ">=1.11.4" },
@@ -1260,6 +1284,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -1424,27 +1474,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.5"
version = "0.16.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
{ url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" },
{ url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" },
{ url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" },
{ url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" },
{ url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" },
{ url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" },
{ url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" },
{ url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" },
{ url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" },
{ url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" },
{ url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" },
{ url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" },
]
[[package]]
@@ -4312,11 +4312,14 @@ issues = "GitHub"
[formFill]
allSaved = "All saved"
analyzingFields = "Analysing form fields..."
applyFailed = "Could not apply the changes"
extractCsvError = "Failed to extract CSV"
extractXlsxError = "Failed to extract XLSX"
filled = "filled"
flattenAfterFilling = "Flatten after filling"
goToPage = "Go to this page"
noFields = "No fillable form fields found in this PDF."
page = "Page"
placeholderEnter = "Enter"
placeholderSelect = "Select"
requiredAbbreviation = "req"
@@ -4325,8 +4328,83 @@ rescanFields = "Re-scan fields"
rescanFormFields = "Re-scan form fields"
save = "Save"
saveShortcut = "Ctrl+S to save"
skippedEdits_one = "1 change could not be applied:"
skippedEdits_other = "{{count}} changes could not be applied:"
skippedEditsTruncated = "{{count}} more not listed."
unsavedChanges = "Unsaved changes"
[formFill.create]
commit = "Add {{count}} field(s) to PDF"
empty = "No fields drawn yet."
failed = "Failed to add fields"
goToField = "Go to this field"
hint = "Pick a field type, then draw it on the page."
placing = "Draw a {{type}} field on the page. Press Esc to stop."
preview = "Hold to preview"
previewHelp = "Hold to see the fields as they will look once added, without the editing outlines."
removeField = "Remove field"
[formFill.editor]
action = "Button action"
actionHelp = "What the button does when clicked."
actionNone = "None"
actionPrint = "Print"
actionReset = "Reset form"
actionSubmit = "Submit to URL"
actionUri = "Open URL"
actionUrl = "URL"
actionUrlHelp = "The address the button opens or submits to."
addOption = "Add option"
caption = "Button caption"
captionHelp = "The text printed on the button face."
defaultValue = "Default value"
defaultValueHelp = "What the field contains before anyone fills it in. Leave blank for an empty field."
fontSize = "Font size"
fontSizeHelp = "Text size inside the field. Leave blank to let the reader size it to fit."
label = "Label"
labelHelp = "The wording shown to whoever fills the form. Leave it blank to fall back to the field name."
maxLength = "Max length (comb)"
maxLengthHelp = "Caps how many characters fit, drawn as evenly spaced boxes."
multiline = "Multi-line"
multilineHelp = "Allows more than one line of text and wraps at the field's edge."
multiSelect = "Allow multiple selection"
multiSelectHelp = "Lets more than one option be chosen at once."
name = "Field name"
nameHelp = "The field's internal name. Used when exporting data or filling the form from another system, so keep it unique and free of spaces."
optionGap = "Option spacing"
optionGapHelp = "Gap between buttons, in points. Leave blank to spread them evenly down the box."
optionPlaceholder = "Option {{n}}"
options = "Options"
optionsEmpty = "Add at least one option."
optionsHelp = "The choices offered in the list. Each one is stored as typed, so keep them short and distinct."
optionSize = "Option size"
optionSizeHelp = "Width and height of each button, in points. Leave blank to fit them to the box you drew."
readOnly = "Read-only"
readOnlyHelp = "Shows a value but stops anyone editing it."
removeOption = "Remove option"
required = "Required"
requiredHelp = "The form cannot be submitted until this field is filled in."
signatureNote = "Placeholder only - you don't sign here. It marks where a signature belongs so a PDF signer (Adobe Acrobat, a signing service, etc.) places the signature in this spot when the document is signed."
tooltip = "Tooltip"
tooltipHelp = "The hint shown when someone hovers the field in a PDF reader."
type = "Type"
typeHelp = "What kind of field this is. Changing it rebuilds the field, so its current value is not carried over."
[formFill.mode]
create = "Create"
fill = "Fill"
label = "Form editor mode"
modify = "Modify"
[formFill.modify]
commit = "Save {{count}} change(s)"
delete = "Delete"
empty = "This PDF has no form fields yet."
failed = "Failed to save changes"
groupSizeHint = "Use Option size"
hint = "Select a field to edit its properties, drag it on the page, or delete it."
restore = "Restore"
[formFill.sidebar]
close = "Close sidebar"
@@ -4657,8 +4735,8 @@ tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make
title = "Flatten"
[home.formFill]
desc = "Fill PDF form fields interactively with a visual editor"
title = "Fill Form"
desc = "Fill, create, edit, and delete PDF form fields with a visual editor"
title = "Form Editor"
[home.getPdfInfo]
desc = "Grabs any and all information possible on PDFs"
@@ -11699,7 +11777,7 @@ downloadAll = "Download All"
exitRedaction = "Exit Redaction Mode"
exportAll = "Export PDF"
exportSelected = "Export Selected Pages"
formFill = "Fill Form"
formFill = "Form Editor"
hideToolbar = "Hide toolbar"
moreActions = "More actions"
multiTool = "Multi-Tool"
+2 -2
View File
@@ -272,8 +272,8 @@
},
"formFill": {
"image": "/og_images/form-fill.png",
"title": "Fill Form - Stirling PDF",
"description": "Fill PDF form fields interactively with a visual editor"
"title": "Form Editor - Stirling PDF",
"description": "Fill, create, edit, and delete PDF form fields with a visual editor"
},
"multiTool": {
"image": "/og_images/multi-tool.png",
+2 -2
View File
@@ -273,8 +273,8 @@
},
"formFill": {
"image": "/og_images/form-fill.png",
"title": "Fill Form - Stirling PDF",
"description": "Fill PDF form fields interactively with a visual editor"
"title": "Form Editor - Stirling PDF",
"description": "Fill, create, edit, and delete PDF form fields with a visual editor"
},
"multiTool": {
"image": "/og_images/multi-tool.png",
+1 -1
View File
@@ -634,7 +634,7 @@ const CODE_EXEMPT_PATH = [
// PDF rendering/drawing surfaces that legitimately carry colour literals —
// scoped to specific tool paths, not a blanket "pdf" substring (which used to
// exempt most of the app in a PDF product).
/pdfTextEditor|pixelCompare|\/compare\.ts$|customPrimary|accentColors/,
/pdfTextEditor|pixelCompare|\/compare\.ts$|customPrimary|accentColors|formFieldColors/,
/validateSignature\/outputtedPDFSections|CenteredMessageSection|StatusBadgeSection/,
/\/viewer\/|Annotation|useViewerReadAloud|CommentsSidebar|\/constants\/search\.ts$|SignaturePreview/,
/ColorPicker|ColorControl|WatchedFolderManagementModal|watchedFolderPresets|fileColors|unifiedBackground|folder\.ts$|policyFolders/,
@@ -1,14 +1,8 @@
import { useRef, useEffect } from "react";
import { Modal, Text, Group, Stack, rem } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { IconBadge } from "@app/ui/IconBadge";
import { useNavigationGuard } from "@app/contexts/NavigationContext";
import { useTranslation } from "react-i18next";
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
import { Z_INDEX_TOAST } from "@app/styles/zIndex";
import { UnsavedChangesDialog } from "@app/components/shared/UnsavedChangesDialog";
const NavigationWarningModal = () => {
const { t } = useTranslation();
const {
showNavigationWarning,
hasUnsavedChanges,
@@ -77,79 +71,13 @@ const NavigationWarningModal = () => {
}
return (
<Modal
<UnsavedChangesDialog
opened={showNavigationWarning}
onClose={handleKeepWorking}
centered
size={rem(400)}
radius="lg"
padding="xl"
withCloseButton={false}
overlayProps={{ blur: 4, opacity: 0.4 }}
transitionProps={{ transition: "pop", duration: 140 }}
closeOnClickOutside={true}
closeOnEscape={true}
zIndex={Z_INDEX_TOAST}
>
<Modal.Title className="sr-only">
{t("unsavedChangesTitle", "Unsaved changes")}
</Modal.Title>
<Stack align="center" gap="md">
<IconBadge accent="amber" size="md">
<WarningAmberRoundedIcon style={{ fontSize: 22 }} />
</IconBadge>
<Stack gap={4} ta="center">
<Text fw={600} size="lg">
{t("unsavedChangesTitle", "Unsaved changes")}
</Text>
<Text size="sm" c="var(--c-text-muted)" lh={1.5}>
{t(
"unsavedChangesBody",
"You have unsaved changes to your PDF. Are you sure you want to leave?",
)}
</Text>
</Stack>
<Stack gap="sm" w="100%" mt="xs">
{hasApply && (
<Button
fullWidth
variant="primary"
onClick={handleApplyAndContinue}
>
{t("applyAndContinue", "Save & Leave")}
</Button>
)}
{hasExport && (
<Button
fullWidth
variant="primary"
onClick={handleExportAndContinue}
>
{t("exportAndContinue", "Export & Leave")}
</Button>
)}
<Group grow gap="sm" wrap="nowrap">
<Button
variant="secondary"
accent="neutral"
data-autofocus
onClick={handleKeepWorking}
>
{t("keepWorking", "Keep Working")}
</Button>
<Button
variant="secondary"
accent="danger"
onClick={handleDiscardChanges}
>
{t("discardChanges", "Discard & Leave")}
</Button>
</Group>
</Stack>
</Stack>
</Modal>
onKeepWorking={handleKeepWorking}
onDiscard={handleDiscardChanges}
onSave={hasApply ? handleApplyAndContinue : undefined}
onExport={hasExport ? handleExportAndContinue : undefined}
/>
);
};
@@ -0,0 +1,108 @@
/**
* The one "unsaved changes" dialog. Navigation and the form editor's tab switch both render it,
* so the choice looks identical wherever it interrupts you; only the actions behind it differ.
*/
import { Modal, Text, Group, Stack, rem } from "@mantine/core";
import { useTranslation } from "react-i18next";
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
import { Button } from "@app/ui/Button";
import { IconBadge } from "@app/ui/IconBadge";
import { Z_INDEX_TOAST } from "@app/styles/zIndex";
export interface UnsavedChangesDialogProps {
opened: boolean;
saving?: boolean;
onKeepWorking: () => void;
onDiscard: () => void;
/** Omit to hide the button, as when there is nothing this caller can save. */
onSave?: () => void;
onExport?: () => void;
}
export function UnsavedChangesDialog({
opened,
saving = false,
onKeepWorking,
onDiscard,
onSave,
onExport,
}: UnsavedChangesDialogProps) {
const { t } = useTranslation();
const heading = t("unsavedChangesTitle", "Unsaved changes");
return (
<Modal
opened={opened}
onClose={onKeepWorking}
centered
size={rem(400)}
radius="lg"
padding="xl"
withCloseButton={false}
overlayProps={{ blur: 4, opacity: 0.4 }}
transitionProps={{ transition: "pop", duration: 140 }}
closeOnClickOutside={true}
closeOnEscape={true}
zIndex={Z_INDEX_TOAST}
>
<Modal.Title className="sr-only">{heading}</Modal.Title>
<Stack align="center" gap="md">
<IconBadge accent="amber" size="md">
<WarningAmberRoundedIcon style={{ fontSize: 22 }} />
</IconBadge>
<Stack gap={4} ta="center">
<Text fw={600} size="lg">
{heading}
</Text>
<Text size="sm" c="var(--c-text-muted)" lh={1.5}>
{t(
"unsavedChangesBody",
"You have unsaved changes to your PDF. Are you sure you want to leave?",
)}
</Text>
</Stack>
<Stack gap="sm" w="100%" mt="xs">
{onSave && (
<Button
fullWidth
variant="primary"
loading={saving}
data-testid="unsaved-save"
onClick={onSave}
>
{t("applyAndContinue", "Save & Leave")}
</Button>
)}
{onExport && (
<Button fullWidth variant="primary" onClick={onExport}>
{t("exportAndContinue", "Export & Leave")}
</Button>
)}
<Group grow gap="sm" wrap="nowrap">
<Button
variant="secondary"
accent="neutral"
data-autofocus
onClick={onKeepWorking}
>
{t("keepWorking", "Keep Working")}
</Button>
<Button
variant="secondary"
accent="danger"
data-testid="unsaved-discard"
onClick={onDiscard}
>
{t("discardChanges", "Discard & Leave")}
</Button>
</Group>
</Stack>
</Stack>
</Modal>
);
}
export default UnsavedChangesDialog;
@@ -43,6 +43,7 @@ import {
import { useWheelZoom } from "@app/hooks/useWheelZoom";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { FormSaveBar } from "@app/tools/formFill/FormSaveBar";
import { FORM_APPLY_EVENT } from "@app/tools/formFill/formFillEvents";
import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand";
import { useMeasurementManager } from "@app/hooks/useMeasurementManager";
import { ScaleCalibrationDialog } from "@app/components/viewer/ScaleCalibrationDialog";
@@ -782,8 +783,8 @@ const EmbedPdfViewerContent = ({
handleFormApply(blob);
}
};
window.addEventListener("formfill:apply", handler);
return () => window.removeEventListener("formfill:apply", handler);
window.addEventListener(FORM_APPLY_EVENT, handler);
return () => window.removeEventListener(FORM_APPLY_EVENT, handler);
}, [handleFormApply]);
// Apply layer visibility changes - reload the modified PDF into the viewer
@@ -1237,6 +1238,7 @@ const EmbedPdfViewerContent = ({
showBakedAnnotations={isAnnotationsVisible}
enableRedaction={shouldEnableRedaction}
enableFormFill={shouldEnableFormFill}
formEditingActive={isFormFillToolActive}
isManualRedactionMode={isManualRedactMode}
signatureApiRef={signatureApiRef as React.RefObject<any>}
annotationApiRef={annotationApiRef as React.RefObject<any>}
@@ -101,6 +101,9 @@ import { DocumentReadyWrapper } from "@app/components/viewer/DocumentReadyWrappe
import { ActiveDocumentProvider } from "@app/components/viewer/ActiveDocumentContext";
import { pdfiumWasmUrl } from "@app/services/wasmPrecompiler";
import { FormFieldOverlay } from "@app/tools/formFill/FormFieldOverlay";
import { FormCreationInteractionLock } from "@app/tools/formFill/FormCreationInteractionLock";
import { FormFieldCreationOverlay } from "@app/tools/formFill/FormFieldCreationOverlay";
import { FormFieldEditOverlay } from "@app/tools/formFill/FormFieldEditOverlay";
import { ButtonAppearanceOverlay } from "@app/tools/formFill/ButtonAppearanceOverlay";
import SignatureFieldOverlay from "@app/components/viewer/SignatureFieldOverlay";
import { CommentsSidebar } from "@app/components/viewer/CommentsSidebar";
@@ -114,6 +117,8 @@ interface LocalEmbedPDFProps {
enableAnnotations?: boolean;
enableRedaction?: boolean;
enableFormFill?: boolean;
/** Structural create/modify overlays only mount while the Form tool owns the viewer. */
formEditingActive?: boolean;
isManualRedactionMode?: boolean;
showBakedAnnotations?: boolean;
onSignatureAdded?: (annotation: PdfAnnotationObject) => void;
@@ -207,6 +212,7 @@ export function LocalEmbedPDF({
enableAnnotations = false,
enableRedaction = false,
enableFormFill = false,
formEditingActive = false,
isManualRedactionMode = false,
showBakedAnnotations = true,
onSignatureAdded,
@@ -1006,6 +1012,7 @@ export function LocalEmbedPDF({
<ZoomAPIBridge />
<ScrollAPIBridge />
<SelectionAPIBridge />
<FormCreationInteractionLock />
<PanAPIBridge />
<SpreadAPIBridge />
<SearchAPIBridge />
@@ -1153,6 +1160,28 @@ export function LocalEmbedPDF({
/>
)}
{/* Create-mode: drag to place new fields */}
{enableFormFill && formEditingActive && (
<FormFieldCreationOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* Modify-mode: select / move / resize existing fields */}
{enableFormFill && formEditingActive && (
<FormFieldEditOverlay
documentId={documentId}
pageIndex={pageIndex}
pageWidth={width}
pageHeight={height}
fileId={fileId}
/>
)}
{/* SignatureFieldOverlay — bitmaps of digital-signature appearances */}
{file && (
<SignatureFieldOverlay
@@ -11,6 +11,7 @@
* For widgets without an appearance stream (unsigned fields, or fields whose
* PDF writer didn't embed one), we fall back to a translucent badge overlay.
*/
import { useStaleBakedFieldNames } from "@app/tools/formFill/FormFillContext";
import React, { useEffect, useMemo, useRef, useState, memo } from "react";
import {
renderSignatureFieldAppearances,
@@ -114,6 +115,7 @@ function SignatureFieldOverlayInner({
pageWidth,
pageHeight,
}: SignatureFieldOverlayProps) {
const staleNames = useStaleBakedFieldNames();
const [fields, setFields] = useState<ResolvedSignatureField[]>([]);
useEffect(() => {
@@ -135,8 +137,13 @@ function SignatureFieldOverlayInner({
}, [pdfSource]);
const pageFields = useMemo(
() => fields.filter((f) => f.pageIndex === pageIndex),
[fields, pageIndex],
// A staged move or delete leaves this bitmap stranded at the original rect, on top of the
// editor chrome, so it is dropped until the edit is applied and the appearance re-extracted.
() =>
fields.filter(
(f) => f.pageIndex === pageIndex && !staleNames.has(f.fieldName),
),
[fields, pageIndex, staleNames],
);
if (pageFields.length === 0) return null;
@@ -124,7 +124,7 @@ export function useViewerWorkbenchBarButtons(
const layersLabel = t("workbenchBar.toggleLayers", "Toggle Layers");
const commentsLabel = t("workbenchBar.toggleComments", "Comments");
const annotationsLabel = t("workbenchBar.annotations", "Annotations");
const formFillLabel = t("workbenchBar.formFill", "Fill Form");
const formFillLabel = t("workbenchBar.formFill", "Form Editor");
const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure");
const rulerSettingsLabel = t("workbenchBar.rulerSettings", "Scale Settings");
const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud");
@@ -455,11 +455,11 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
height="1.5rem"
/>
),
name: t("home.formFill.title", "Fill Form"),
name: t("home.formFill.title", "Form Editor"),
component: lazy(() => import("@app/tools/formFill/FormFill")),
description: t(
"home.formFill.desc",
"Fill PDF form fields interactively with a visual editor",
"Fill, create, edit, and delete PDF form fields with a visual editor",
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
@@ -467,7 +467,19 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
endpoints: ["form-fill"],
automationSettings: null,
supportsAutomate: false,
synonyms: ["form", "fill", "fillable", "input", "field", "acroform"],
synonyms: [
"form",
"fill",
"fillable",
"input",
"field",
"acroform",
"edit",
"create",
"editor",
"modify",
"builder",
],
},
changePermissions: {
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
@@ -0,0 +1,94 @@
import type { Page } from "@playwright/test";
/**
* WebKit elides Blob-backed multipart part bodies from `route.request().postData()`, so read them
* at the XHR/fetch seam instead; part headers stay readable on every engine.
*/
const STORE_KEY = "__capturedMultipartParts";
/**
* Start recording the text of the `partName` part of any `FormData` the page
* posts, keyed by the request's URL pathname. Must be called before navigation.
*/
export async function captureMultipartPart(
page: Page,
partName: string,
): Promise<void> {
await page.addInitScript((name: string) => {
const store: Record<string, string> = {};
(window as unknown as Record<string, unknown>).__capturedMultipartParts =
store;
const record = (url: string, body: unknown): void => {
if (!(body instanceof FormData)) return;
const part = body.get(name);
if (!(part instanceof Blob)) return;
let pathname: string;
try {
pathname = new URL(url, window.location.href).pathname;
} catch {
return;
}
void part.text().then((text) => {
store[pathname] = text;
});
};
// axios posts through XHR; keep the URL from open() so the capture stays
// keyed by endpoint and a commit to the wrong URL still fails the spec.
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (
this: XMLHttpRequest,
...args: unknown[]
) {
(this as unknown as Record<string, unknown>).__capturedUrl = String(
args[1] ?? "",
);
return (originalOpen as (...a: unknown[]) => unknown).apply(this, args);
};
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (
this: XMLHttpRequest,
...args: unknown[]
) {
const url = (this as unknown as Record<string, unknown>).__capturedUrl;
record(typeof url === "string" ? url : "", args[0]);
return (originalSend as (...a: unknown[]) => unknown).apply(this, args);
};
// Mirror it on fetch so the capture survives if the api client moves off XHR.
const originalFetch = window.fetch;
window.fetch = function (
this: typeof window,
input: RequestInfo | URL,
init?: RequestInit,
) {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
record(url, init?.body);
return originalFetch.call(this, input, init);
};
}, partName);
}
/** The captured part text for `pathname`, or undefined if nothing posted yet. */
export function readCapturedPart(
page: Page,
pathname: string,
): Promise<string | undefined> {
return page.evaluate(
([key, path]) =>
(
(window as unknown as Record<string, unknown>)[key] as
| Record<string, string>
| undefined
)?.[path],
[STORE_KEY, pathname] as const,
);
}
@@ -0,0 +1,474 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import {
captureMultipartPart,
readCapturedPart,
} from "@app/tests/helpers/multipart-capture";
import { readFileSync } from "fs";
import path from "path";
import type { Page, Route } from "@playwright/test";
import type { FieldEditBatch } from "@app/tools/formFill/types";
/**
* Form field editor with `/api/v1/form/*` mocked: panel UI, staged-change bookkeeping and the
* committed `/edit-fields` payload. The PDFBox round-trip lives in the live spec and JUnit tests.
*/
const SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/sample.pdf",
);
const PDF_BYTES = readFileSync(SAMPLE_PDF);
/** Two text fields on page 0, in the shape the backend emits. */
const STUB_FIELDS = [
{
name: "firstName",
label: "First name",
type: "text",
value: "",
options: null,
displayOptions: null,
required: false,
readOnly: false,
multiSelect: false,
multiline: false,
tooltip: null,
widgets: [
{
pageIndex: 0,
x: 100,
y: 100,
width: 180,
height: 20,
fontSize: 12,
cropBoxHeight: 792,
},
],
},
{
name: "lastName",
label: "Last name",
type: "text",
value: "",
options: null,
displayOptions: null,
required: false,
readOnly: false,
multiSelect: false,
multiline: false,
tooltip: null,
widgets: [
{
pageIndex: 0,
x: 100,
y: 140,
width: 180,
height: 20,
fontSize: 12,
cropBoxHeight: 792,
},
],
},
];
const EDIT_FIELDS_PATH = "/api/v1/form/edit-fields";
/**
* Stubs the form endpoints; `fields` is what extraction returns (default none, so create-mode
* drags land on an unobstructed overlay). Returns captured multipart envelopes (part headers only).
*/
async function stubFormEndpoints(page: Page, fields: unknown[] = []) {
const captured: Record<string, string> = {};
// The edits JSON is a Blob part, whose body Playwright cannot read back on
// WebKit; capture it in-page instead. Must be installed before navigation.
await captureMultipartPart(page, "edits");
await page.route("**/api/v1/form/fields-with-coordinates", (route: Route) =>
route.fulfill({ json: fields }),
);
// Trailing ** so the glob still matches once a query string is appended.
await page.route("**/api/v1/form/edit-fields**", (route: Route) => {
captured["edit-fields"] = route.request().postData() ?? "";
route.fulfill({
status: 200,
contentType: "application/pdf",
body: PDF_BYTES,
});
});
return captured;
}
/**
* Asserts the multipart envelope on the wire: part headers read on every engine (only Blob part
* bodies are elided on WebKit), and this is the shape the backend's `@RequestPart` binding needs.
*/
function expectEditFieldsEnvelope(envelope: string) {
expect(envelope).toContain('name="file"');
expect(envelope).toContain('filename="sample.pdf"');
expect(envelope).toContain('name="edits"');
expect(envelope).toMatch(/content-type:\s*application\/json/i);
}
/** The parsed `edits` JSON the app posted to /edit-fields. */
async function readEditBatch(page: Page): Promise<FieldEditBatch> {
await expect
.poll(() => readCapturedPart(page, EDIT_FIELDS_PATH))
.toBeTruthy();
const json = await readCapturedPart(page, EDIT_FIELDS_PATH);
return JSON.parse(json as string) as FieldEditBatch;
}
async function openFormTool(page: Page) {
await page.goto("/form-fill");
await page.waitForLoadState("domcontentloaded");
await uploadFiles(page, SAMPLE_PDF);
}
/** The Mantine SegmentedControl hides the radio input; the label is the target. */
function modeTab(page: Page, name: string) {
return page
.locator(".mantine-SegmentedControl-label")
.filter({ hasText: name });
}
async function selectMode(page: Page, name: string) {
await modeTab(page, name).click();
}
/**
* Draws on the create overlay, retrying until the commit button enables: pointer drags over the
* WASM-rendered page can drop under parallel load.
*/
async function drawField(page: Page) {
const overlay = page.getByTestId("form-create-overlay-0");
await expect(overlay).toBeVisible({ timeout: 30_000 });
const commit = page.getByTestId("form-create-commit");
for (let attempt = 0; attempt < 4; attempt++) {
const box = await overlay.boundingBox();
if (!box) continue;
const startX = box.x + box.width * 0.25;
const startY = box.y + box.height * 0.25;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + 60, startY + 20, { steps: 4 });
await page.mouse.move(startX + 120, startY + 40, { steps: 4 });
await page.mouse.up();
try {
await expect(commit).toBeEnabled({ timeout: 2000 });
return;
} catch {
// drag dropped under load - try again
}
}
}
test.describe("Form field editor", () => {
test("exposes Fill / Create / Modify modes", async ({ page }) => {
await stubFormEndpoints(page);
await openFormTool(page);
await expect(modeTab(page, "Fill")).toBeVisible();
await expect(modeTab(page, "Create")).toBeVisible();
await expect(modeTab(page, "Modify")).toBeVisible();
});
test("create mode: palette offers every creatable type", async ({ page }) => {
await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
for (const type of [
"text",
"checkbox",
"combobox",
"listbox",
"radio",
"button",
"signature",
]) {
await expect(page.getByTestId(`form-create-type-${type}`)).toBeVisible();
}
// Commit disabled with nothing queued.
await expect(page.getByTestId("form-create-commit")).toBeDisabled();
// Arming a type reveals the "draw on the page" hint.
await page.getByTestId("form-create-type-text").click();
await expect(
page.getByText(/Draw a Text field on the page/i),
).toBeVisible();
});
test("create mode: drawing a text field commits via /edit-fields", async ({
page,
}) => {
const captured = await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-text").click();
await drawField(page);
// A queued field appears with a commit affordance enabled.
await expect(page.getByTestId("form-create-commit")).toBeEnabled();
await page.getByTestId("form-create-commit").click();
const batch = await readEditBatch(page);
expect(batch.add).toHaveLength(1);
expect(batch.add?.[0]?.type).toBe("text");
// The drawn rectangle, not a degenerate one - guards the drag geometry.
expect(batch.add?.[0]?.pageIndex).toBe(0);
expect(batch.add?.[0]?.width).toBeGreaterThan(0);
expect(batch.add?.[0]?.height).toBeGreaterThan(0);
await expect.poll(() => captured["edit-fields"]).toBeTruthy();
expectEditFieldsEnvelope(captured["edit-fields"]);
// Committing clears the queue, so there is nothing left to add.
await expect(page.getByTestId("form-create-commit")).toBeDisabled();
});
test("create mode: a drawn field is selected and can be resized straight away", async ({
page,
}) => {
await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-text").click();
await drawField(page);
// Selected on placement, so the resize handles are live without another click.
const box = page.locator('[data-testid^="form-edit-field-pending-"]');
await expect(box).toHaveCount(1);
// WebKit paints a beat after the element exists, so measure only once it is visible.
await expect(box).toBeVisible();
const before = await box.boundingBox();
expect(before).not.toBeNull();
const handle = page.getByTestId("form-edit-handle-se");
await expect(handle).toBeVisible();
// hover() waits for actionability and centres on the grip; a hand-computed point
// intermittently misses a 9px target once the layout shifts under it.
await handle.hover();
const grip = await handle.boundingBox();
expect(grip).not.toBeNull();
await page.mouse.down();
await page.mouse.move(grip!.x + 80, grip!.y + 50, { steps: 12 });
await page.mouse.up();
await expect
.poll(async () => (await box.boundingBox())?.width ?? 0)
.toBeGreaterThan(before!.width + 20);
});
test("create mode: drawing a field shows one box, and moving it leaves none behind", async ({
page,
}) => {
await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-text").click();
await drawField(page);
// Measures geometry rather than hit-testing: the duplicate box was pointer-events:none,
// so elementsFromPoint skipped it and the bug sailed through. Counts every bordered box any
// overlay draws over the point.
const boxesAt = ([x, y]: [number, number]) =>
page.evaluate(
([px, py]) =>
Array.from(
document.querySelectorAll(
'[data-testid^="form-create-overlay-"] div, [data-testid^="form-edit-overlay-"] div',
),
).filter((el) => {
const style = getComputedStyle(el);
// The field box is drawn with an outline, not a border, so that its border does not
// indent the content box and push the field preview inside it out of alignment.
const framed =
style.borderStyle !== "none" || style.outlineStyle !== "none";
if (!framed || style.display === "none") {
return false;
}
const r = el.getBoundingClientRect();
// Ignore the resize grips, which are far smaller than any field box.
if (r.width < 12 || r.height < 12) return false;
return (
px >= r.left && px <= r.right && py >= r.top && py <= r.bottom
);
}).length,
[x, y],
);
const box = page
.locator('[data-testid^="form-edit-field-pending-"]')
.first();
// WebKit paints a beat after the element exists, so measure only once it is visible.
await expect(box).toBeVisible();
const before = await box.boundingBox();
expect(before).not.toBeNull();
const origin: [number, number] = [
before!.x + before!.width / 2,
before!.y + before!.height / 2,
];
await expect.poll(() => boxesAt(origin)).toBe(1);
await page.mouse.move(origin[0], origin[1]);
await page.mouse.down();
await page.mouse.move(origin[0] + 90, origin[1] + 60, { steps: 10 });
await page.mouse.up();
const after = await box.boundingBox();
expect(Math.abs(after!.x - before!.x)).toBeGreaterThan(20);
// Nothing left behind where it used to be.
expect(await boxesAt(origin)).toBe(0);
});
test("create mode: Delete removes the drawn field before it is applied", async ({
page,
}) => {
await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-text").click();
await drawField(page);
await expect(
page.locator('[data-testid^="form-edit-field-pending-"]'),
).toHaveCount(1);
await page.keyboard.press("Delete");
await expect(
page.locator('[data-testid^="form-edit-field-pending-"]'),
).toHaveCount(0);
// Nothing queued means nothing to apply.
await expect(page.getByTestId("form-create-commit")).toBeDisabled();
});
test("create mode: drawing a radio field commits a radio definition", async ({
page,
}) => {
const captured = await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-radio").click();
await drawField(page);
await expect(page.getByTestId("form-create-commit")).toBeEnabled();
await page.getByTestId("form-create-commit").click();
const batch = await readEditBatch(page);
expect(batch.add).toHaveLength(1);
expect(batch.add?.[0]?.type).toBe("radio");
expect(batch.add?.[0]?.options).toEqual(["Option 1", "Option 2"]);
await expect.poll(() => captured["edit-fields"]).toBeTruthy();
expectEditFieldsEnvelope(captured["edit-fields"]);
});
test("create mode: a choice field auto-shows seeded options", async ({
page,
}) => {
await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-listbox").click();
await drawField(page);
// The just-drawn field's property editor auto-expands with Options
// pre-seeded, so no manual expand is needed.
await expect(page.getByText("Options", { exact: true })).toBeVisible();
await expect(page.getByPlaceholder("Option 1")).toHaveValue("Option 1");
await expect(page.getByPlaceholder("Option 2")).toHaveValue("Option 2");
});
test("create mode: signature field explains it is a placeholder", async ({
page,
}) => {
await stubFormEndpoints(page);
await openFormTool(page);
await selectMode(page, "Create");
await page.getByTestId("form-create-type-signature").click();
await drawField(page);
// The editor makes clear you don't sign here - it's a placeholder a signer fills.
await expect(page.getByText(/Placeholder only/i)).toBeVisible();
});
test("modify mode: lists fields and deletes one via /edit-fields", async ({
page,
}) => {
const captured = await stubFormEndpoints(page, STUB_FIELDS);
await openFormTool(page);
await selectMode(page, "Modify");
// Both stubbed fields render as rows.
await expect(page.getByTestId("form-modify-row-firstName")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("form-modify-row-lastName")).toBeVisible();
// Mark one for deletion → commit count reflects it and button enables.
await page.getByTestId("form-modify-delete-firstName").click();
const commit = page.getByTestId("form-modify-commit");
await expect(commit).toContainText("1");
await expect(commit).toBeEnabled();
await commit.click();
const batch = await readEditBatch(page);
expect(batch.delete).toEqual(["firstName"]);
// A field queued for deletion is not also sent as a modification.
expect(batch.modify ?? []).toHaveLength(0);
await expect.poll(() => captured["edit-fields"]).toBeTruthy();
expectEditFieldsEnvelope(captured["edit-fields"]);
// The staged deletion is cleared once it has been saved.
await expect(commit).toBeDisabled();
});
test("modify mode: editing a property commits via /edit-fields", async ({
page,
}) => {
const captured = await stubFormEndpoints(page, STUB_FIELDS);
await openFormTool(page);
await selectMode(page, "Modify");
await page.getByTestId("form-modify-row-firstName").click();
// The property editor reveals the label input; change it.
const labelInput = page.getByLabel("Label").first();
await expect(labelInput).toBeVisible();
await labelInput.fill("Given name");
const commit = page.getByTestId("form-modify-commit");
await expect(commit).toBeEnabled();
await commit.click();
const batch = await readEditBatch(page);
expect(batch.modify).toHaveLength(1);
expect(batch.modify?.[0]?.targetName).toBe("firstName");
expect(batch.modify?.[0]?.label).toBe("Given name");
expect(batch.delete ?? []).toHaveLength(0);
await expect.poll(() => captured["edit-fields"]).toBeTruthy();
expectEditFieldsEnvelope(captured["edit-fields"]);
await expect(commit).toBeDisabled();
});
});
@@ -8,6 +8,7 @@
* Uses the same EPDF_RenderAnnotBitmap / FPDF_FFLDraw pipeline as
* SignatureFieldOverlay to produce the button's native PDF appearance.
*/
import { useStaleBakedFieldNames } from "@app/tools/formFill/FormFillContext";
import React, { useEffect, useMemo, useRef, useState, memo } from "react";
import {
renderButtonFieldAppearances,
@@ -66,6 +67,7 @@ function ButtonAppearanceOverlayInner({
pageWidth,
pageHeight,
}: ButtonAppearanceOverlayProps) {
const staleNames = useStaleBakedFieldNames();
const [appearances, setAppearances] = useState<SignatureFieldAppearance[]>(
[],
);
@@ -111,36 +113,38 @@ function ButtonAppearanceOverlayInner({
}}
data-button-appearance-page={pageIndex}
>
{pageAppearances.map((btn, idx) => {
const sx =
btn.sourcePageWidth > 0 ? pageWidth / btn.sourcePageWidth : 1;
const sy =
btn.sourcePageHeight > 0 ? pageHeight / btn.sourcePageHeight : 1;
const left = btn.x * sx;
const top = btn.y * sy;
const width = btn.width * sx;
const height = btn.height * sy;
{pageAppearances
.filter((btn) => !staleNames.has(btn.fieldName))
.map((btn, idx) => {
const sx =
btn.sourcePageWidth > 0 ? pageWidth / btn.sourcePageWidth : 1;
const sy =
btn.sourcePageHeight > 0 ? pageHeight / btn.sourcePageHeight : 1;
const left = btn.x * sx;
const top = btn.y * sy;
const width = btn.width * sx;
const height = btn.height * sy;
return (
<div
key={`btn-appearance-${btn.fieldName}-${idx}`}
style={{
position: "absolute",
left,
top,
width,
height,
overflow: "hidden",
}}
>
<ButtonBitmapCanvas
imageData={btn.imageData!}
cssWidth={width}
cssHeight={height}
/>
</div>
);
})}
return (
<div
key={`btn-appearance-${btn.fieldName}-${idx}`}
style={{
position: "absolute",
left,
top,
width,
height,
overflow: "hidden",
}}
>
<ButtonBitmapCanvas
imageData={btn.imageData!}
cssWidth={width}
cssHeight={height}
/>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,26 @@
/**
* Holds the viewer's interactions paused for as long as the create tool is armed, so drawing a
* field never also selects the text underneath.
*/
import { useEffect } from "react";
import { useInteractionManagerCapability } from "@embedpdf/plugin-interaction-manager/react";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
export function FormCreationInteractionLock() {
const { mode, creationType } = useFormFill();
const { provides: interactionManager } = useInteractionManagerCapability();
const armed = mode === "create" && creationType != null;
// Mounted once per document, not per page: the page overlays live inside a virtualising
// Scroller, so one scrolling out of view would otherwise resume mid-session.
useEffect(() => {
if (!armed || !interactionManager) return undefined;
// Never paused mid-gesture: pausing between a pointerdown and its pointerup strands the
// selection, which then keeps extending on every move once resumed.
interactionManager.pause();
return () => interactionManager.resume();
}, [armed, interactionManager]);
return null;
}
@@ -0,0 +1,326 @@
/** Left panel for "create" mode; drawing happens in FormFieldCreationOverlay. */
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Stack,
Text,
Group,
Alert,
Collapse,
Paper,
Tooltip,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineRounded";
import MyLocationIcon from "@mui/icons-material/MyLocation";
import { useViewer } from "@app/contexts/ViewerContext";
import {
pendingSelectionName,
pendingIdFrom,
} from "@app/tools/formFill/pendingSelection";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import {
CREATABLE_FIELD_TYPES,
type CreatableFieldType,
type NewFieldDefinition,
} from "@app/tools/formFill/types";
import {
FIELD_TYPE_ICON,
FIELD_TYPE_COLOR,
} from "@app/tools/formFill/fieldMeta";
import { FormFieldPropertyEditor } from "@app/tools/formFill/FormFieldPropertyEditor";
import { SkippedEditsAlert } from "@app/tools/formFill/SkippedEditsAlert";
import { useFormCommit } from "@app/tools/formFill/useFormCommit";
import styles from "@app/tools/formFill/FormFill.module.css";
interface FormFieldCreatePanelProps {
currentFile: File | Blob | null;
onApplied?: (blob: Blob) => void;
}
const TYPE_LABEL: Record<CreatableFieldType, string> = {
text: "Text",
checkbox: "Checkbox",
combobox: "Dropdown",
listbox: "List box",
radio: "Radio",
button: "Button",
signature: "Signature",
};
export function FormFieldCreatePanel({
currentFile,
onApplied,
}: FormFieldCreatePanelProps) {
const { t } = useTranslation();
const {
creationType,
setCreationType,
pendingFields,
updatePendingField,
removePendingField,
commitNewFields,
setPreviewing,
selectedFieldName,
setSelectedField,
} = useFormFill();
const [expandedId, setExpandedId] = useState<string | null>(null);
const { scrollActions } = useViewer();
const goToPage = (pageIndex: number) =>
scrollActions.scrollToPage(pageIndex + 1);
const { committing, error, commit } = useFormCommit(onApplied);
// Auto-expand the property editor of a freshly-drawn field so its settings
// (especially options for choice/radio) are visible immediately.
const prevCountRef = useRef(0);
const expandedRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (pendingFields.length > prevCountRef.current) {
setExpandedId(pendingFields[pendingFields.length - 1].id);
}
prevCountRef.current = pendingFields.length;
}, [pendingFields]);
// Clicking a box on the page should land you on its settings, not leave you hunting the list.
useEffect(() => {
const id = selectedFieldName ? pendingIdFrom(selectedFieldName) : null;
if (id) setExpandedId(id);
}, [selectedFieldName]);
// Newly drawn fields land at the bottom while the panel stays at the top, so their settings
// open somewhere the user cannot see.
useEffect(() => {
if (!expandedId) return;
expandedRowRef.current?.scrollIntoView({ block: "nearest" });
}, [expandedId]);
const handleCommit = useCallback(() => {
if (!currentFile || pendingFields.length === 0) return;
commit(
() => commitNewFields(currentFile),
"formFill.create.failed",
"Failed to add fields",
);
}, [currentFile, pendingFields, commitNewFields, commit]);
return (
<div className={styles.root}>
<div className={styles.header}>
<Text size="xs" c="dimmed">
{t(
"formFill.create.hint",
"Pick a field type, then draw it on the page.",
)}
</Text>
{/* Type palette */}
<Group gap={6} wrap="wrap">
{CREATABLE_FIELD_TYPES.map((type) => {
const armed = creationType === type;
return (
<Button
key={type}
size="sm"
variant={armed ? "primary" : "secondary"}
leftSection={FIELD_TYPE_ICON[type]}
onClick={() => setCreationType(armed ? null : type)}
data-testid={`form-create-type-${type}`}
>
{TYPE_LABEL[type]}
</Button>
);
})}
</Group>
{creationType && (
<Alert color="blue" variant="light" p="xs" radius="sm">
<Text size="xs">
{t(
"formFill.create.placing",
"Draw a {{type}} field on the page. Press Esc to stop.",
{ type: TYPE_LABEL[creationType] },
)}
</Text>
</Alert>
)}
{error && (
<Alert
icon={<WarningAmberIcon sx={{ fontSize: 16 }} />}
color="red"
variant="light"
p="xs"
radius="sm"
>
<Text size="xs">{error}</Text>
</Alert>
)}
<SkippedEditsAlert />
</div>
{/* Content stacks naturally; ToolPanel's own ScrollArea does the scrolling. */}
<div className={styles.fieldListInner}>
{pendingFields.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="md">
{t("formFill.create.empty", "No fields drawn yet.")}
</Text>
) : (
<Stack gap={6}>
{pendingFields.map((pf) => {
const expanded = expandedId === pf.id;
return (
<Paper
key={pf.id}
withBorder
p={6}
radius="sm"
ref={expanded ? expandedRowRef : undefined}
data-testid={`form-pending-row-${pf.id}`}
// The whole row opens its settings: hunting for the small pencil is a
// needless step when the row is the thing you just clicked.
onClick={() => setExpandedId(expanded ? null : pf.id)}
style={{ cursor: "pointer" }}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<span
style={{
color: `var(--mantine-color-${FIELD_TYPE_COLOR[pf.type]}-6)`,
display: "flex",
}}
>
{FIELD_TYPE_ICON[pf.type]}
</span>
<Text size="xs" truncate>
{pf.name}
</Text>
<Text size="xs" c="dimmed">
p{pf.pageIndex + 1}
</Text>
</Group>
<Group gap={2} wrap="nowrap">
<Tooltip
label={t(
"formFill.create.goToField",
"Go to this field",
)}
withArrow
>
<ActionIcon
size="sm"
variant="tertiary"
aria-label={t(
"formFill.create.goToField",
"Go to this field",
)}
onClick={(e) => {
e.stopPropagation();
setSelectedField(pendingSelectionName(pf.id));
setExpandedId(pf.id);
goToPage(pf.pageIndex);
}}
data-testid={`form-pending-goto-${pf.id}`}
>
<MyLocationIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
<Tooltip
label={t("formFill.create.removeField", "Remove field")}
withArrow
>
<ActionIcon
size="sm"
variant="tertiary"
accent="danger"
aria-label={t(
"formFill.create.removeField",
"Remove field",
)}
onClick={(e) => {
e.stopPropagation();
removePendingField(pf.id);
}}
data-testid={`form-pending-remove-${pf.id}`}
>
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Collapse in={expanded}>
{/* The row toggles on click, so the settings must not bubble into it or
every field you touch closes the panel you are typing in. */}
<div
style={{ marginTop: 8 }}
onClick={(e) => e.stopPropagation()}
>
<FormFieldPropertyEditor
value={pf}
onChange={(patch) =>
updatePendingField(
pf.id,
patch as Partial<NewFieldDefinition>,
)
}
showName
/>
</div>
</Collapse>
</Paper>
);
})}
</Stack>
)}
</div>
<div className={styles.footer}>
<Tooltip
label={t(
"formFill.create.previewHelp",
"Hold to see the fields as they will look once added, without the editing outlines.",
)}
openDelay={250}
withArrow
>
<Button
size="sm"
variant="secondary"
accent="neutral"
fullWidth
disabled={pendingFields.length === 0}
data-testid="form-create-preview"
// Held, not toggled: releasing always restores the editing view, so preview
// cannot be left switched on by accident.
onPointerDown={() => setPreviewing(true)}
onPointerUp={() => setPreviewing(false)}
onPointerLeave={() => setPreviewing(false)}
onPointerCancel={() => setPreviewing(false)}
onBlur={() => setPreviewing(false)}
leftSection={<VisibilityOutlinedIcon fontSize="small" />}
>
{t("formFill.create.preview", "Hold to preview")}
</Button>
</Tooltip>
<Button
size="sm"
onClick={handleCommit}
loading={committing}
disabled={!currentFile || pendingFields.length === 0}
data-testid="form-create-commit"
>
{t("formFill.create.commit", "Add {{count}} field(s) to PDF", {
count: pendingFields.length,
})}
</Button>
</div>
</div>
);
}
export default FormFieldCreatePanel;
@@ -0,0 +1,318 @@
/**
* Per-page drag-to-place layer for "create" mode. Uses FormFieldOverlay's scale
* basis (pageWidthPx / pdfPage.size.width) so placements round-trip on reload.
*/
import React, {
useCallback,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { pendingSelectionName } from "@app/tools/formFill/pendingSelection";
import type { CreatableFieldType } from "@app/tools/formFill/types";
import {
pixelsToBackendRect,
backendRectToPixels,
clampPixelRect,
roundPdfRect,
type PixelRect,
} from "@app/tools/formFill/formCoordinateUtils";
import {
collectSnapTargets,
snapMove,
type SnapGuide,
} from "@app/tools/formFill/formSnapUtils";
import {
usePageScale,
getLocalPoint,
isTextEntryTarget,
} from "@app/tools/formFill/usePageScale";
import { SnapGuides } from "@app/tools/formFill/SnapGuides";
import { FORM_COLORS } from "@app/tools/formFill/formFieldColors";
interface FormFieldCreationOverlayProps {
documentId: string;
pageIndex: number;
pageWidth: number;
pageHeight: number;
fileId?: string | null;
}
/** Minimum drawn size (pixels) below which we treat the gesture as a click. */
const MIN_DRAG_PX = 5;
/** Default field size in PDF points, used for click-to-place. */
const DEFAULT_SIZE_PTS: Record<CreatableFieldType, { w: number; h: number }> = {
text: { w: 150, h: 24 },
checkbox: { w: 16, h: 16 },
combobox: { w: 150, h: 24 },
listbox: { w: 150, h: 64 },
radio: { w: 16, h: 16 },
button: { w: 120, h: 28 },
signature: { w: 200, h: 60 },
};
export function FormFieldCreationOverlay({
documentId,
pageIndex,
pageWidth,
pageHeight,
fileId,
}: FormFieldCreationOverlayProps) {
const {
mode,
creationType,
setCreationType,
pendingFields,
addPendingField,
selectedFieldName,
setSelectedField,
previewing,
state,
forFileId,
} = useFormFill();
const rootRef = useRef<HTMLDivElement>(null);
const [dragRect, setDragRect] = useState<PixelRect | null>(null);
const [guides, setGuides] = useState<SnapGuide[]>([]);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const { scaleX, scaleY, pageHeightPts, pageWidthPts, rotation } =
usePageScale(documentId, pageIndex, pageWidth, pageHeight);
// Pixel rects of the OTHER fields on this page, used as snap targets.
const snapRects = useMemo<PixelRect[]>(() => {
const rects: PixelRect[] = [];
for (const field of state.fields) {
for (const w of field.widgets ?? []) {
if (w.pageIndex !== pageIndex) continue;
rects.push({
left: w.x * scaleX,
top: w.y * scaleY,
width: w.width * scaleX,
height: w.height * scaleY,
});
}
}
for (const pf of pendingFields) {
if (pf.pageIndex !== pageIndex) continue;
rects.push(backendRectToPixels(pf, scaleX, scaleY, pageHeightPts));
}
return rects;
}, [state.fields, pendingFields, pageIndex, scaleX, scaleY, pageHeightPts]);
// Precompute snap edges once (not on every pointermove).
const snapTargets = useMemo(() => collectSnapTargets(snapRects), [snapRects]);
const active = mode === "create" && creationType != null && !previewing;
// Stale-file guard: don't draw on a page whose fields belong to another file.
const fileMismatch =
fileId != null && forFileId != null && fileId !== forFileId;
// Whether this gesture began with something selected; see handlePointerDown.
const startedSelectedRef = useRef(false);
const localPoint = useCallback(
(e: React.PointerEvent) => getLocalPoint(e, rootRef.current, rotation),
[rotation],
);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (!active) return;
// Before the bare-overlay check: a press on a pending outline must not select text either.
e.preventDefault();
// Only start a drag on the bare overlay, never on a pending outline.
if (e.target !== rootRef.current) return;
// A click away from a selection means "deselect"; a drag always means "draw". Which one
// this is cannot be known until the pointer lifts, so start the drag either way.
startedSelectedRef.current = Boolean(selectedFieldName);
rootRef.current?.setPointerCapture(e.pointerId);
const p = localPoint(e);
dragStartRef.current = p;
setDragRect({ left: p.x, top: p.y, width: 0, height: 0 });
},
[active, localPoint, selectedFieldName, setSelectedField],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!active || !dragStartRef.current) return;
const p = localPoint(e);
const start = dragStartRef.current;
let rect: PixelRect = {
left: Math.min(start.x, p.x),
top: Math.min(start.y, p.y),
width: Math.abs(p.x - start.x),
height: Math.abs(p.y - start.y),
};
const snapped = snapMove(rect, snapTargets, 6);
rect = { ...rect, left: snapped.left, top: snapped.top };
setGuides(snapped.guides);
setDragRect(rect);
},
[active, localPoint, snapTargets],
);
// A cancelled gesture (system swipe, focus loss) must not leave a half-drawn rect behind.
const cancelDrag = useCallback((e: React.PointerEvent) => {
dragStartRef.current = null;
startedSelectedRef.current = false;
setDragRect(null);
setGuides([]);
try {
rootRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* pointer capture may already be released */
}
}, []);
const finishDrag = useCallback(
(e: React.PointerEvent) => {
if (!active || !dragStartRef.current || !creationType) return;
const start = dragStartRef.current;
dragStartRef.current = null;
setGuides([]);
try {
rootRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* pointer capture may already be released */
}
const current = dragRect;
setDragRect(null);
if (!current) return;
const dragged =
// Either axis is enough: requiring both threw away a deliberate thin drag, such as a
// signature line, and replaced it with a default box centred on the press point.
Math.max(current.width, current.height) >= MIN_DRAG_PX;
// A click that went nowhere only clears the selection it started with.
if (!dragged && startedSelectedRef.current) {
startedSelectedRef.current = false;
setSelectedField(null);
return;
}
startedSelectedRef.current = false;
let pixelRect: PixelRect;
if (dragged) {
pixelRect = current;
} else {
// Click-to-place: default size centred on the click point.
const def = DEFAULT_SIZE_PTS[creationType];
const wPx = def.w * scaleX;
const hPx = def.h * scaleY;
pixelRect = {
left: start.x - wPx / 2,
top: start.y - hPx / 2,
width: wPx,
height: hPx,
};
}
pixelRect = clampPixelRect(pixelRect, pageWidth, pageHeight);
const pdf = roundPdfRect(
pixelsToBackendRect(pixelRect, scaleX, scaleY, pageHeightPts),
);
const id = addPendingField({
type: creationType,
pageIndex,
x: pdf.x,
y: pdf.y,
width: pdf.width,
height: pdf.height,
});
// Selected the moment it lands, so it can be nudged or resized without another click.
setSelectedField(pendingSelectionName(id));
},
[
active,
creationType,
dragRect,
scaleX,
scaleY,
pageHeightPts,
pageWidth,
pageHeight,
pageIndex,
addPendingField,
setSelectedField,
],
);
// Escape disarms placement / cancels the in-progress drag.
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
// Never steal keys from a field the user is typing in.
if (isTextEntryTarget(e.target)) return;
if (e.key === "Escape") {
dragStartRef.current = null;
setDragRect(null);
setGuides([]);
setCreationType(null);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [active, setCreationType]);
if (mode !== "create" || fileMismatch || !pageWidthPts) return null;
return (
<div
ref={rootRef}
data-testid={`form-create-overlay-${pageIndex}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishDrag}
onPointerCancel={cancelDrag}
style={{
position: "absolute",
inset: 0,
pointerEvents: active ? "auto" : "none",
// Without this the browser claims the gesture as a pan and drags never start on touch.
touchAction: active ? "none" : "auto",
// Crosshair means the next click draws; the arrow means it clears the selection.
cursor: !active
? "default"
: selectedFieldName
? "default"
: "crosshair",
userSelect: "none",
WebkitUserSelect: "none",
zIndex: 5,
}}
>
{/* Queued fields are drawn by FormFieldEditOverlay, which also moves and resizes
them; drawing them here too would show two boxes and leave one behind on drag. */}
{/* Live drag preview */}
{dragRect && creationType && (
<div
style={{
position: "absolute",
left: dragRect.left,
top: dragRect.top,
width: dragRect.width,
height: dragRect.height,
border: `2px dashed ${FORM_COLORS.accent}`,
background: FORM_COLORS.accentFill,
pointerEvents: "none",
boxSizing: "border-box",
}}
/>
)}
{/* Alignment guides */}
<SnapGuides guides={guides} />
</div>
);
}
export default FormFieldCreationOverlay;
@@ -0,0 +1,792 @@
/**
* Per-page select/move/resize layer for "modify" mode. Geometry is staged in
* CropBox-relative, lower-left-origin points on FormFieldOverlay's scale basis.
*/
import React, {
useCallback,
useMemo,
useRef,
useState,
useEffect,
} from "react";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import {
pendingIdFrom,
pendingSelectionName,
} from "@app/tools/formFill/pendingSelection";
import type { FormField } from "@app/tools/formFill/types";
import {
pixelsToBackendRect,
radioOptionRects,
backendRectToPixels,
widgetRectToPixels,
clampPixelRect,
roundPdfRect,
type PixelRect,
} from "@app/tools/formFill/formCoordinateUtils";
import {
collectSnapTargets,
snapMove,
snapResize,
type SnapGuide,
} from "@app/tools/formFill/formSnapUtils";
import {
usePageScale,
getLocalPoint,
isTextEntryTarget,
} from "@app/tools/formFill/usePageScale";
import { SnapGuides } from "@app/tools/formFill/SnapGuides";
import { FORM_COLORS } from "@app/tools/formFill/formFieldColors";
interface FormFieldEditOverlayProps {
documentId: string;
pageIndex: number;
pageWidth: number;
pageHeight: number;
fileId?: string | null;
}
type HandleId = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
const HANDLES: { id: HandleId; cursor: string }[] = [
{ id: "nw", cursor: "nwse-resize" },
{ id: "n", cursor: "ns-resize" },
{ id: "ne", cursor: "nesw-resize" },
{ id: "e", cursor: "ew-resize" },
{ id: "se", cursor: "nwse-resize" },
{ id: "s", cursor: "ns-resize" },
{ id: "sw", cursor: "nesw-resize" },
{ id: "w", cursor: "ew-resize" },
];
const MIN_PX = 8;
const HANDLE_SIZE = 9;
interface Interaction {
kind: "move" | "resize";
handle?: HandleId;
fieldName: string;
startX: number;
startY: number;
startRect: PixelRect;
}
function handleEdges(h: HandleId) {
return {
left: h === "nw" || h === "w" || h === "sw",
right: h === "ne" || h === "e" || h === "se",
top: h === "nw" || h === "n" || h === "ne",
bottom: h === "sw" || h === "s" || h === "se",
};
}
function handlePosition(h: HandleId, rect: PixelRect) {
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const map: Record<HandleId, { x: number; y: number }> = {
nw: { x: rect.left, y: rect.top },
n: { x: cx, y: rect.top },
ne: { x: rect.left + rect.width, y: rect.top },
e: { x: rect.left + rect.width, y: cy },
se: { x: rect.left + rect.width, y: rect.top + rect.height },
s: { x: cx, y: rect.top + rect.height },
sw: { x: rect.left, y: rect.top + rect.height },
w: { x: rect.left, y: cy },
};
return map[h];
}
/**
* What a queued field will look like once applied. Without this a drawn box is empty, so the
* default text and a radio group's options are invisible until after saving.
*/
function PendingPreview({
field,
rect,
}: {
field: FormField;
rect: PixelRect;
}) {
// Blank entries are dropped server-side (sanitizeOptions), so counting them here would
// preview one more button than actually gets written.
const options = (field.options ?? [])
.map((o) => o?.trim() ?? "")
.filter((o) => o.length > 0);
if (field.type === "radio" && options.length > 0) {
// The drawn box is the whole group; radioOptionRects splits it exactly as the backend does.
const rows = radioOptionRects(
rect,
options.length,
field.optionGap,
field.optionSize,
);
return (
<>
{rows.map((row, i) => (
<div
key={`${options[i]}-${i}`}
style={{
position: "absolute",
left: 0,
top: row.top,
width: rect.width,
height: row.size,
display: "flex",
alignItems: "center",
gap: 6,
pointerEvents: "none",
}}
>
<span
style={{
width: row.size,
height: row.size,
borderRadius: "50%",
border: `1.5px solid ${FORM_COLORS.neutralBorder}`,
// Without this the border sits outside the width and each button renders 3px
// taller than its slot, so the stack overflows the box it was laid out in.
boxSizing: "border-box",
flex: "0 0 auto",
}}
/>
<span
style={{
fontSize: Math.max(8, Math.min(12, row.size * 0.9)),
color: FORM_COLORS.neutralChip,
whiteSpace: "nowrap",
overflow: "hidden",
}}
>
{options[i]}
</span>
</div>
))}
</>
);
}
const sample =
field.value ||
(field.type === "combobox" || field.type === "listbox"
? (options[0] ?? "")
: "");
if (!sample) return null;
return (
<span
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
padding: "0 4px",
fontSize: Math.min(12, Math.max(9, rect.height - 8)),
color: FORM_COLORS.neutralChip,
whiteSpace: "nowrap",
overflow: "hidden",
pointerEvents: "none",
}}
>
{sample}
</span>
);
}
export function FormFieldEditOverlay({
documentId,
pageIndex,
pageWidth,
pageHeight,
fileId,
}: FormFieldEditOverlayProps) {
const {
mode,
state,
selectedFieldName,
setSelectedField,
modifiedFields,
stageModification,
deletedFieldNames,
forFileId,
dragActiveRef,
pendingFields,
updatePendingField,
previewing,
} = useFormFill();
const rootRef = useRef<HTMLDivElement>(null);
const interactionRef = useRef<Interaction | null>(null);
const [liveRect, setLiveRect] = useState<PixelRect | null>(null);
const [guides, setGuides] = useState<SnapGuide[]>([]);
const { scaleX, scaleY, pageHeightPts, pageWidthPts, rotation } =
usePageScale(documentId, pageIndex, pageWidth, pageHeight);
/** First-widget pixel rect for a field on this page, honouring staged geometry. */
const fieldRect = useCallback(
(field: FormField): PixelRect | null => {
const widget = field.widgets?.find((w) => w.pageIndex === pageIndex);
if (!widget) return null;
// A queued field stores PDF coordinates, not the top-left widget ones the extractor
// produces, so it needs the other transform or it renders far from where it was drawn.
if (pendingIdFrom(field.name)) {
return backendRectToPixels(
{
x: widget.x,
y: widget.y,
width: widget.width,
height: widget.height,
},
scaleX,
scaleY,
pageHeightPts,
);
}
const staged = modifiedFields[field.name];
if (
staged &&
staged.x != null &&
staged.y != null &&
staged.width != null &&
staged.height != null
) {
return backendRectToPixels(
{
x: staged.x,
y: staged.y,
width: staged.width,
height: staged.height,
},
scaleX,
scaleY,
pageHeightPts,
);
}
return widgetRectToPixels(widget, scaleX, scaleY);
},
[modifiedFields, pageIndex, scaleX, scaleY, pageHeightPts],
);
// A drawn-but-unapplied field is shown as a one-widget field so selection, dragging and the
// resize handles all work on it before it has a PDF name.
const pendingOnPage = useMemo(
() =>
pendingFields
.filter((f) => f.pageIndex === pageIndex)
.map((f): FormField => ({
name: pendingSelectionName(f.id),
label: f.label || f.name,
type: f.type,
value: f.defaultValue ?? "",
options: f.options ?? null,
displayOptions: null,
required: f.required ?? false,
readOnly: f.readOnly ?? false,
multiSelect: f.multiSelect ?? false,
multiline: f.multiline ?? false,
tooltip: f.tooltip ?? null,
widgets: [
{
pageIndex: f.pageIndex,
x: f.x,
y: f.y,
width: f.width,
height: f.height,
},
],
})),
[pendingFields, pageIndex],
);
const fieldsOnPage = useMemo(
() => [
...state.fields.filter((f) =>
f.widgets?.some((w) => w.pageIndex === pageIndex),
),
...pendingOnPage,
],
[state.fields, pageIndex, pendingOnPage],
);
/** Pending geometry lives in the queue; committed geometry is staged as a modification. */
const commitGeometry = useCallback(
(
fieldName: string,
pdf: { x: number; y: number; width: number; height: number },
) => {
const pendingId = pendingIdFrom(fieldName);
if (pendingId) {
updatePendingField(pendingId, { pageIndex, ...pdf });
return;
}
stageModification(fieldName, { pageIndex, ...pdf });
},
[pageIndex, stageModification, updatePendingField],
);
const selectedField = useMemo(
() => fieldsOnPage.find((f) => f.name === selectedFieldName) ?? null,
[fieldsOnPage, selectedFieldName],
);
const selectedSingleWidget =
!!selectedField && (selectedField.widgets?.length ?? 0) === 1;
const snapRects = useMemo<PixelRect[]>(() => {
const rects: PixelRect[] = [];
for (const f of fieldsOnPage) {
if (f.name === selectedFieldName) continue;
const r = fieldRect(f);
if (r) rects.push(r);
}
return rects;
}, [fieldsOnPage, selectedFieldName, fieldRect]);
// Precompute snap edges once (not on every pointermove).
const snapTargets = useMemo(() => collectSnapTargets(snapRects), [snapRects]);
const localPoint = useCallback(
(e: React.PointerEvent) => getLocalPoint(e, rootRef.current, rotation),
[rotation],
);
const beginInteraction = useCallback(
(
e: React.PointerEvent,
field: FormField,
kind: "move" | "resize",
handle?: HandleId,
) => {
const rect = fieldRect(field);
if (!rect) return;
e.stopPropagation();
e.preventDefault();
rootRef.current?.setPointerCapture(e.pointerId);
dragActiveRef.current = true;
const p = localPoint(e);
interactionRef.current = {
kind,
handle,
fieldName: field.name,
startX: p.x,
startY: p.y,
startRect: rect,
};
setLiveRect(rect);
},
[fieldRect, localPoint],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
const it = interactionRef.current;
if (!it) return;
const p = localPoint(e);
const dx = p.x - it.startX;
const dy = p.y - it.startY;
const targets = snapTargets;
if (it.kind === "move") {
let rect: PixelRect = {
...it.startRect,
left: it.startRect.left + dx,
top: it.startRect.top + dy,
};
const snapped = snapMove(rect, targets, 6);
rect = { ...rect, left: snapped.left, top: snapped.top };
rect = clampPixelRect(rect, pageWidth, pageHeight);
setGuides(snapped.guides);
setLiveRect(rect);
} else if (it.kind === "resize" && it.handle) {
const edges = handleEdges(it.handle);
let { left, top, width, height } = it.startRect;
if (edges.left) {
left = it.startRect.left + dx;
width = it.startRect.width - dx;
}
if (edges.right) {
width = it.startRect.width + dx;
}
if (edges.top) {
top = it.startRect.top + dy;
height = it.startRect.height - dy;
}
if (edges.bottom) {
height = it.startRect.height + dy;
}
// Keep a positive minimum, anchoring the opposite edge.
if (width < MIN_PX) {
if (edges.left)
left = it.startRect.left + it.startRect.width - MIN_PX;
width = MIN_PX;
}
if (height < MIN_PX) {
if (edges.top) top = it.startRect.top + it.startRect.height - MIN_PX;
height = MIN_PX;
}
let rect: PixelRect = { left, top, width, height };
const snapped = snapResize(rect, edges, targets, 6);
rect = snapped.rect;
setGuides(snapped.guides);
setLiveRect(rect);
}
},
[localPoint, snapTargets, pageWidth, pageHeight],
);
// Scrolling this page out of view mid-gesture would otherwise strand the shared flag
// at true. Only the overlay that owns the drag may clear it, or an unrelated page
// scrolling away would release a live one.
useEffect(
() => () => {
if (interactionRef.current) dragActiveRef.current = false;
},
[dragActiveRef],
);
// A cancelled gesture (system swipe, focus loss) must not leave a half-applied drag behind.
const cancelInteraction = useCallback(
(e: React.PointerEvent) => {
interactionRef.current = null;
dragActiveRef.current = false;
setGuides([]);
setLiveRect(null);
try {
rootRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* already released */
}
},
[setGuides, setLiveRect],
);
const endInteraction = useCallback(
(e: React.PointerEvent) => {
const it = interactionRef.current;
interactionRef.current = null;
dragActiveRef.current = false;
setGuides([]);
try {
rootRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* already released */
}
const rect = liveRect;
setLiveRect(null);
if (!it || !rect) return;
// A plain click produces no movement; staging it would mark the field dirty.
const moved =
Math.abs(rect.left - it.startRect.left) > 0.5 ||
Math.abs(rect.top - it.startRect.top) > 0.5 ||
Math.abs(rect.width - it.startRect.width) > 0.5 ||
Math.abs(rect.height - it.startRect.height) > 0.5;
if (!moved) return;
const clamped = clampPixelRect(rect, pageWidth, pageHeight);
const pdf = roundPdfRect(
pixelsToBackendRect(clamped, scaleX, scaleY, pageHeightPts),
);
commitGeometry(it.fieldName, {
x: pdf.x,
y: pdf.y,
width: pdf.width,
height: pdf.height,
});
},
[
liveRect,
pageWidth,
pageHeight,
scaleX,
scaleY,
pageHeightPts,
pageIndex,
stageModification,
],
);
// Arrow keys nudge the selected field; Escape cancels an in-progress drag.
// Every mounted overlay listens, but only the selection's page acts (fieldRect is null elsewhere).
useEffect(() => {
if (mode !== "modify" || !selectedField) return;
const onKey = (e: KeyboardEvent) => {
// Read only: every page's overlay runs this, so writing the shared flag here
// would let an idle page clear the flag of the page actually being dragged.
const dragging = interactionRef.current != null;
if (!dragging && isTextEntryTarget(e.target)) return;
if (e.key === "Escape") {
// Only release the shared flag if THIS overlay was the one dragging.
if (dragging) dragActiveRef.current = false;
interactionRef.current = null;
setLiveRect(null);
setGuides([]);
if (!dragging) setSelectedField(null);
return;
}
if (dragging) return;
if (
!selectedField ||
!selectedSingleWidget ||
deletedFieldNames.includes(selectedField.name)
) {
return;
}
const step = e.shiftKey ? 10 : 1;
let dx = 0;
let dy = 0;
switch (e.key) {
case "ArrowLeft":
dx = -step;
break;
case "ArrowRight":
dx = step;
break;
case "ArrowUp":
dy = -step;
break;
case "ArrowDown":
dy = step;
break;
default:
return;
}
const base = fieldRect(selectedField);
if (!base) return; // selected field's widget isn't on this page
e.preventDefault();
const moved = clampPixelRect(
{ ...base, left: base.left + dx, top: base.top + dy },
pageWidth,
pageHeight,
);
const pdf = roundPdfRect(
pixelsToBackendRect(moved, scaleX, scaleY, pageHeightPts),
);
commitGeometry(selectedField.name, {
x: pdf.x,
y: pdf.y,
width: pdf.width,
height: pdf.height,
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [
mode,
setSelectedField,
selectedField,
selectedSingleWidget,
deletedFieldNames,
fieldRect,
pageWidth,
pageHeight,
scaleX,
scaleY,
pageHeightPts,
pageIndex,
stageModification,
]);
const fileMismatch =
fileId != null && forFileId != null && fileId !== forFileId;
const creating = mode === "create";
if ((mode !== "modify" && !creating) || fileMismatch || !pageWidthPts) {
return null;
}
// Preview drops the chrome but still draws queued fields, plainly: they have no widget in the
// document yet, so hiding them outright would show everything except what is being added.
if (previewing) {
return (
<div
data-testid={`form-preview-overlay-${pageIndex}`}
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
zIndex: 5,
}}
>
{pendingOnPage.map((field) => {
const rect = fieldRect(field);
if (!rect) return null;
return (
<div
key={field.name}
data-testid={`form-preview-field-${pendingIdFrom(field.name)}`}
style={{
position: "absolute",
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
// A radio group has no box of its own; its buttons are the whole visual, so a
// frame around them is chrome the finished PDF will not have.
border:
field.type === "radio"
? undefined
: `1px solid ${FORM_COLORS.neutralBorder}`,
borderRadius: 2,
boxSizing: "border-box",
}}
>
{/* A preview of an empty rectangle is not a preview; draw what the field holds. */}
<PendingPreview field={field} rect={rect} />
</div>
);
})}
</div>
);
}
const selectedRect = selectedField
? (liveRect ?? fieldRect(selectedField))
: null;
return (
<div
ref={rootRef}
data-testid={`form-edit-overlay-${pageIndex}`}
onPointerDown={(e) => {
// Clicking empty space deselects. preventDefault stops the underlying
// PDF text layer from starting a text selection.
e.preventDefault();
setSelectedField(null);
}}
onPointerMove={handlePointerMove}
onPointerUp={endInteraction}
onPointerCancel={cancelInteraction}
style={{
position: "absolute",
inset: 0,
// In create mode the bare page belongs to the drawing layer underneath, so only the
// boxes below take pointer events; the root would otherwise swallow every new drag.
pointerEvents: creating ? "none" : "auto",
// touch-action stays on the draggable boxes below, not here: claiming every
// gesture over the page would stop touch users panning the document at all.
userSelect: "none",
WebkitUserSelect: "none",
zIndex: creating ? 6 : 5,
}}
>
{fieldsOnPage.map((field) => {
const rect =
field.name === selectedFieldName && selectedRect
? selectedRect
: fieldRect(field);
if (!rect) return null;
const isSelected = field.name === selectedFieldName;
const isDeleted = deletedFieldNames.includes(field.name);
return (
<div
key={field.name}
data-testid={`form-edit-field-${pendingIdFrom(field.name) ?? field.name}`}
onPointerDown={(e) => {
if (isDeleted) return;
e.stopPropagation();
// Select and start moving in one gesture; a click without movement
// just selects, since endInteraction ignores a zero delta.
if (field.name !== selectedFieldName)
setSelectedField(field.name);
// Moving is safe for a group too: the backend applies the delta to every widget
// on the anchor page. Only resizing is still single-widget, so the handles stay off.
beginInteraction(e, field, "move");
}}
style={{
position: "absolute",
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
// Outline, not border: a border indents the content box, which pushed the field
// preview inside it off by the border width and made the buttons overhang.
outline: isDeleted
? `1.5px dashed ${FORM_COLORS.danger}`
: isSelected
? `2px solid ${FORM_COLORS.accent}`
: `1.5px solid ${FORM_COLORS.neutralBorder}`,
outlineOffset: 0,
background: isDeleted
? FORM_COLORS.dangerFill
: isSelected
? FORM_COLORS.accentFill
: FORM_COLORS.neutralFill,
borderRadius: 2,
boxSizing: "border-box",
// Claim the gesture only where a drag can actually start, so touch users can
// still pan the page over boxes that are not draggable.
pointerEvents: "auto",
touchAction: isDeleted ? "auto" : "none",
cursor: isDeleted ? "not-allowed" : "move",
textDecoration: isDeleted ? "line-through" : undefined,
}}
>
{pendingIdFrom(field.name) && (
<PendingPreview field={field} rect={rect} />
)}
<span
style={{
position: "absolute",
top: -16,
left: 0,
fontSize: 10,
lineHeight: "14px",
padding: "0 4px",
background: isDeleted
? FORM_COLORS.danger
: isSelected
? FORM_COLORS.accent
: FORM_COLORS.neutralChip,
color: "#fff",
borderRadius: 2,
whiteSpace: "nowrap",
opacity: isSelected || isDeleted ? 1 : 0.75,
// The chip floats above its field, over blank page; it must never eat a
// press meant for the drawing surface underneath.
pointerEvents: "none",
}}
>
{field.label || field.name}
</span>
</div>
);
})}
{/* Resize handles for the selected single-widget field */}
{selectedRect &&
selectedSingleWidget &&
!deletedFieldNames.includes(selectedFieldName ?? "") &&
HANDLES.map((h) => {
const pos = handlePosition(h.id, selectedRect);
return (
<div
key={h.id}
data-testid={`form-edit-handle-${h.id}`}
onPointerDown={(e) =>
selectedField &&
beginInteraction(e, selectedField, "resize", h.id)
}
style={{
position: "absolute",
left: pos.x - HANDLE_SIZE / 2,
top: pos.y - HANDLE_SIZE / 2,
width: HANDLE_SIZE,
height: HANDLE_SIZE,
background: "#fff",
border: `1.5px solid ${FORM_COLORS.accent}`,
borderRadius: 2,
touchAction: "none",
cursor: h.cursor,
boxSizing: "border-box",
// The root is transparent while creating, so handles claim events themselves.
pointerEvents: "auto",
}}
/>
);
})}
{/* Alignment guides */}
<SnapGuides guides={guides} />
</div>
);
}
export default FormFieldEditOverlay;
@@ -0,0 +1,473 @@
/** Left panel for "modify" mode; page highlighting lives in FormFieldEditOverlay. */
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Text,
Group,
Alert,
Collapse,
Paper,
NumberInput,
ScrollArea,
Tooltip,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineRounded";
import RestoreIcon from "@mui/icons-material/Restore";
import MyLocationIcon from "@mui/icons-material/MyLocation";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { useViewer } from "@app/contexts/ViewerContext";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import type {
FormField,
ModifyFieldDefinition,
} from "@app/tools/formFill/types";
import {
FIELD_TYPE_ICON,
FIELD_TYPE_COLOR,
} from "@app/tools/formFill/fieldMeta";
import {
FormFieldPropertyEditor,
type EditableFieldProps,
} from "@app/tools/formFill/FormFieldPropertyEditor";
import { isTextEntryTarget } from "@app/tools/formFill/usePageScale";
import { SkippedEditsAlert } from "@app/tools/formFill/SkippedEditsAlert";
import { useFormCommit } from "@app/tools/formFill/useFormCommit";
import styles from "@app/tools/formFill/FormFill.module.css";
interface FormFieldModifyPanelProps {
currentFile: File | Blob | null;
onApplied?: (blob: Blob) => void;
}
/** Current backend (lower-left origin) coords for a field's first widget. */
function currentCoords(field: FormField, staged?: ModifyFieldDefinition) {
const w = field.widgets?.[0];
if (!w) return null;
if (
staged &&
staged.x != null &&
staged.y != null &&
staged.width != null &&
staged.height != null
) {
return {
x: staged.x,
y: staged.y,
width: staged.width,
height: staged.height,
};
}
const cropH = w.cropBoxHeight ?? 0;
return {
x: w.x,
y: cropH ? cropH - w.y - w.height : w.y,
width: w.width,
height: w.height,
};
}
export function FormFieldModifyPanel({
currentFile,
onApplied,
}: FormFieldModifyPanelProps) {
const { t } = useTranslation();
const {
state,
selectedFieldName,
setSelectedField,
modifiedFields,
stageModification,
deletedFieldNames,
toggleFieldDeleted,
commitModifications,
hasUncommittedChanges,
dragActiveRef,
} = useFormFill();
const { committing, error, commit } = useFormCommit(onApplied);
const selectedRowRef = useRef<HTMLDivElement>(null);
// A long form is easier to scan a page at a time; pages start open so nothing hides itself.
const [collapsedPages, setCollapsedPages] = useState<Set<number>>(new Set());
const togglePage = (pageIdx: number) =>
setCollapsedPages((prev) => {
const next = new Set(prev);
if (next.has(pageIdx)) next.delete(pageIdx);
else next.add(pageIdx);
return next;
});
const { scrollActions } = useViewer();
const { sortedPages, fieldsByPage } = useMemo(() => {
const byPage = new Map<number, FormField[]>();
for (const field of state.fields) {
const pageIndex = field.widgets?.[0]?.pageIndex ?? 0;
if (!byPage.has(pageIndex)) byPage.set(pageIndex, []);
byPage.get(pageIndex)!.push(field);
}
return {
sortedPages: Array.from(byPage.keys()).sort((a, b) => a - b),
fieldsByPage: byPage,
};
}, [state.fields]);
// Auto-scroll the list to the selected field (e.g. selected via the overlay).
useEffect(() => {
if (selectedFieldName && selectedRowRef.current) {
selectedRowRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [selectedFieldName]);
// Escape clears the selection. Lives here (single instance) so it still works
// when the selected field's page has scrolled out of view and unmounted.
useEffect(() => {
if (!selectedFieldName) return;
const onKey = (e: KeyboardEvent) => {
// A drag owns Escape (the overlay cancels it), and an input owns its own.
if (dragActiveRef.current || isTextEntryTarget(e.target)) return;
if (e.key === "Escape") setSelectedField(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [selectedFieldName, setSelectedField, dragActiveRef]);
const changeCount =
Object.keys(modifiedFields).length + deletedFieldNames.length;
const handleCommit = useCallback(() => {
if (!currentFile || !hasUncommittedChanges) return;
commit(
() => commitModifications(currentFile),
"formFill.modify.failed",
"Failed to save changes",
);
}, [currentFile, hasUncommittedChanges, commitModifications, commit]);
const editorValue = useCallback(
(field: FormField): EditableFieldProps => {
const staged = modifiedFields[field.name];
return {
name: staged?.name ?? field.name,
label: staged?.label ?? field.label,
type: staged?.type ?? field.type,
defaultValue: staged?.defaultValue ?? field.value,
tooltip: staged?.tooltip ?? field.tooltip ?? "",
fontSize: staged?.fontSize ?? field.widgets?.[0]?.fontSize,
required: staged?.required ?? field.required,
readOnly: staged?.readOnly ?? field.readOnly,
multiline: staged?.multiline ?? field.multiline,
multiSelect: staged?.multiSelect ?? field.multiSelect,
options: staged?.options ?? field.options ?? [],
// Test for the staged KEY, not its value, or an explicit clear reads as
// "unchanged" and the input snaps back.
maxLength:
staged && "maxLength" in staged
? staged.maxLength
: (field.maxLength ?? undefined),
buttonAction:
staged && "buttonAction" in staged
? staged.buttonAction
: (field.buttonActionSpec ?? undefined),
};
},
[modifiedFields],
);
return (
<div className={styles.root}>
<div className={styles.header}>
<Text size="xs" c="dimmed">
{t(
"formFill.modify.hint",
"Select a field to edit its properties, drag it on the page, or delete it.",
)}
</Text>
{error && (
<Alert
icon={<WarningAmberIcon sx={{ fontSize: 16 }} />}
color="red"
variant="light"
p="xs"
radius="sm"
>
<Text size="xs">{error}</Text>
</Alert>
)}
<SkippedEditsAlert />
{state.fields.length === 0 && !state.loading && (
<Text size="xs" c="dimmed" ta="center" py="md">
{t("formFill.modify.empty", "This PDF has no form fields yet.")}
</Text>
)}
</div>
<ScrollArea className={styles.fieldList}>
<div className={styles.fieldListInner}>
{sortedPages.map((pageIdx, i) => (
<React.Fragment key={pageIdx}>
<div
className={styles.pageDivider}
style={{
...(i === 0 ? { marginTop: 0 } : {}),
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 4,
}}
onClick={() => togglePage(pageIdx)}
data-testid={`form-page-header-${pageIdx}`}
>
<ExpandMoreIcon
sx={{
fontSize: 16,
transform: collapsedPages.has(pageIdx)
? "rotate(-90deg)"
: undefined,
transition: "transform 120ms",
}}
/>
<Text className={styles.pageDividerLabel}>
{t("formFill.page", "Page")} {pageIdx + 1}
</Text>
<Text size="xs" c="dimmed">
{fieldsByPage.get(pageIdx)!.length}
</Text>
<Tooltip
label={t("formFill.goToPage", "Go to this page")}
withArrow
>
<ActionIcon
size="sm"
variant="tertiary"
aria-label={t("formFill.goToPage", "Go to this page")}
data-testid={`form-page-goto-${pageIdx}`}
onClick={(e) => {
e.stopPropagation();
scrollActions.scrollToPage(pageIdx + 1);
}}
>
<MyLocationIcon sx={{ fontSize: 15 }} />
</ActionIcon>
</Tooltip>
</div>
<Collapse in={!collapsedPages.has(pageIdx)}>
{fieldsByPage.get(pageIdx)!.map((field) => {
const selected = selectedFieldName === field.name;
const deleted = deletedFieldNames.includes(field.name);
const coords = currentCoords(
field,
modifiedFields[field.name],
);
// Resizing a group here would only resize its first widget.
const multiWidget = (field.widgets?.length ?? 0) > 1;
return (
<Paper
key={field.name}
ref={selected ? selectedRowRef : undefined}
withBorder
p={6}
radius="sm"
style={{
cursor: "pointer",
borderColor: selected
? "var(--mantine-color-blue-5)"
: undefined,
opacity: deleted ? 0.55 : 1,
}}
onClick={() =>
setSelectedField(selected ? null : field.name)
}
data-testid={`form-modify-row-${field.name}`}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<span
style={{
color: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-6)`,
display: "flex",
}}
>
{FIELD_TYPE_ICON[field.type]}
</span>
<Text
size="xs"
truncate
td={deleted ? "line-through" : undefined}
>
{field.label || field.name}
</Text>
</Group>
<Tooltip
label={
deleted
? t("formFill.modify.restore", "Restore")
: t("formFill.modify.delete", "Delete")
}
withArrow
>
<ActionIcon
size="sm"
variant="tertiary"
accent={deleted ? "default" : "danger"}
aria-label={
deleted
? t("formFill.modify.restore", "Restore")
: t("formFill.modify.delete", "Delete")
}
onClick={(e) => {
e.stopPropagation();
toggleFieldDeleted(field.name);
}}
data-testid={`form-modify-delete-${field.name}`}
>
{deleted ? (
<RestoreIcon sx={{ fontSize: 16 }} />
) : (
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
)}
</ActionIcon>
</Tooltip>
</Group>
{/* Children are built only while open: Collapse keeps them mounted, so
every unopened row would otherwise carry a full property editor. */}
<Collapse in={selected && !deleted}>
{selected && !deleted && (
<div
style={{ marginTop: 8 }}
onClick={(e) => e.stopPropagation()}
>
<FormFieldPropertyEditor
value={editorValue(field)}
onChange={(patch) =>
stageModification(
field.name,
patch as Partial<ModifyFieldDefinition>,
)
}
showName
allowTypeChange
/>
{coords && (
<Group gap={6} mt="xs" grow>
<NumberInput
size="xs"
label="X"
value={Math.round(coords.x)}
onChange={(v) =>
typeof v === "number" &&
stageModification(field.name, {
pageIndex: pageIdx,
x: v,
y: coords.y,
width: coords.width,
height: coords.height,
})
}
/>
<NumberInput
size="xs"
label="Y"
value={Math.round(coords.y)}
onChange={(v) =>
typeof v === "number" &&
stageModification(field.name, {
pageIndex: pageIdx,
x: coords.x,
y: v,
width: coords.width,
height: coords.height,
})
}
/>
<NumberInput
size="xs"
label="W"
value={Math.round(coords.width)}
min={1}
disabled={multiWidget}
description={
multiWidget
? t(
"formFill.modify.groupSizeHint",
"Use Option size",
)
: undefined
}
onChange={(v) =>
typeof v === "number" &&
stageModification(field.name, {
pageIndex: pageIdx,
x: coords.x,
y: coords.y,
width: v,
height: coords.height,
})
}
/>
<NumberInput
size="xs"
label="H"
value={Math.round(coords.height)}
min={1}
disabled={multiWidget}
onChange={(v) =>
typeof v === "number" &&
stageModification(field.name, {
pageIndex: pageIdx,
x: coords.x,
y: coords.y,
width: coords.width,
height: v,
})
}
/>
</Group>
)}
</div>
)}
</Collapse>
</Paper>
);
})}
</Collapse>
</React.Fragment>
))}
</div>
</ScrollArea>
<div className={styles.footer}>
<Button
size="sm"
onClick={handleCommit}
loading={committing}
disabled={!currentFile || !hasUncommittedChanges}
data-testid="form-modify-commit"
>
{t("formFill.modify.commit", "Save {{count}} change(s)", {
count: changeCount,
})}
</Button>
</div>
</div>
);
}
export default FormFieldModifyPanel;
@@ -175,6 +175,8 @@ interface WidgetInputProps {
onFocus: (fieldName: string) => void;
onChange: (fieldName: string, value: string) => void;
onButtonClick: (field: FormField, action?: ButtonAction | null) => void;
/** True while the structural editor owns the page, so widgets show but do not respond. */
editing: boolean;
}
/**
@@ -192,6 +194,7 @@ function WidgetInputInner({
onFocus,
onChange,
onButtonClick,
editing,
}: WidgetInputProps) {
// Per-field value subscription — only this widget re-renders when its value changes
const value = useFieldValue(field.name);
@@ -231,7 +234,7 @@ function WidgetInputInner({
? `0 0 0 2px ${error ? "rgba(244, 67, 54, 0.25)" : "rgba(33, 150, 243, 0.25)"}`
: "none",
cursor: field.readOnly ? "default" : "text",
pointerEvents: "auto",
pointerEvents: editing ? "none" : "auto",
display: "flex",
alignItems: field.multiline ? "stretch" : "center",
};
@@ -365,8 +368,10 @@ function WidgetInputInner({
style={{
width: "85%",
height: "85%",
maxWidth: height * 0.9, // Prevent it from getting too wide in rectangular boxes
maxHeight: width * 0.9,
// Keep the tick square in a rectangular box; these were transposed, so a wide
// short box clamped the wrong axis and let the mark spill out.
maxWidth: width * 0.9,
maxHeight: height * 0.9,
fontSize: `${Math.max(10, height * 0.75)}px`,
lineHeight: 1,
color: isChecked ? "#2196F3" : "transparent",
@@ -457,6 +462,8 @@ function WidgetInputInner({
if (widgetIndex < 0) return null;
const widgetIndexStr = String(widgetIndex);
const isSelected = value === widgetIndexStr;
const optionLabel =
field.options?.[widgetIndex] ?? widget.exportValue ?? "";
return (
<div
{...commonProps}
@@ -491,14 +498,35 @@ function WidgetInputInner({
height: Math.min(width, height) * 0.8,
borderRadius: "50%",
border: `1.5px solid ${isSelected ? "#2196F3" : isActive ? "#2196F3" : "#999"}`,
// The border must sit inside the width, or the dot outgrows its widget.
boxSizing: "border-box",
background: isSelected ? "#2196F3" : "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: isSelected ? "inset 0 0 0 2px white" : "none",
transition: "background 0.15s, border-color 0.15s",
flex: "0 0 auto",
}}
/>
{optionLabel && (
// A PDF radio widget carries no caption of its own, so without this the options are
// indistinguishable circles. Drawn outside the rect so the hit area stays the widget.
<span
style={{
position: "absolute",
left: "100%",
marginLeft: 4,
whiteSpace: "nowrap",
fontSize: Math.max(9, Math.min(12, height * 0.9)),
lineHeight: `${height}px`,
color: "#333",
pointerEvents: "none",
}}
>
{optionLabel}
</span>
)}
</div>
);
}
@@ -605,8 +633,17 @@ export function FormFieldOverlay({
pageHeight,
fileId,
}: FormFieldOverlayProps) {
const { setValue, setActiveField, fieldsByPage, state, forFileId } =
useFormFill();
const {
setValue,
setActiveField,
effectiveFieldsByPage,
state,
forFileId,
mode,
} = useFormFill();
// While the editor owns the page the outlines and handles do the interacting; these widgets
// are here to show what the field looks like, not to be typed into.
const editing = mode !== "fill";
const { activeFieldName, validationErrors } = state;
const { printActions, scrollActions, exportActions } = useViewer();
@@ -647,8 +684,8 @@ export function FormFieldOverlay({
}, [documentState, pageIndex, pageWidth, pageHeight]);
const pageFields = useMemo(
() => fieldsByPage.get(pageIndex) || [],
[fieldsByPage, pageIndex],
() => effectiveFieldsByPage.get(pageIndex) || [],
[effectiveFieldsByPage, pageIndex],
);
const handleFocus = useCallback(
@@ -779,6 +816,7 @@ export function FormFieldOverlay({
onFocus={handleFocus}
onChange={handleChange}
onButtonClick={handleButtonClick}
editing={editing}
/>
);
}),
@@ -0,0 +1,556 @@
/**
* Property form shared by the create and modify panels; reports edits as partial patches.
*/
import React from "react";
import {
Stack,
TextInput,
NumberInput,
Select,
Switch,
Group,
Text,
Alert,
Tooltip,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineRounded";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
/** A switch's help has to wrap the control: Mantine does not surface a tooltip from its label. */
function SwitchWithHelp({
help,
children,
}: {
help: string;
children: React.ReactElement;
}) {
return (
<Tooltip
label={help}
multiline
w={250}
withArrow
openDelay={250}
position="top-start"
>
{children}
</Tooltip>
);
}
/** Labels carry their own explanation on hover, so the panel stays uncluttered. */
function LabelWithHelp({ text, help }: { text: string; help: string }) {
return (
<Tooltip
label={help}
multiline
w={250}
withArrow
openDelay={250}
position="top-start"
>
<span style={{ cursor: "help" }}>{text}</span>
</Tooltip>
);
}
export interface EditableFieldProps {
name?: string;
label?: string;
type: string;
defaultValue?: string;
tooltip?: string;
fontSize?: number;
optionGap?: number;
optionSize?: number;
required?: boolean;
readOnly?: boolean;
multiline?: boolean;
multiSelect?: boolean;
options?: string[];
maxLength?: number;
buttonAction?: string;
}
interface FormFieldPropertyEditorProps {
value: EditableFieldProps;
onChange: (patch: Partial<EditableFieldProps>) => void;
/** Show the field-name input (create mode). */
showName?: boolean;
/** Allow changing the field type (modify mode). */
allowTypeChange?: boolean;
}
const TYPE_LABEL: Record<string, string> = {
text: "Text",
checkbox: "Checkbox",
combobox: "Dropdown",
listbox: "List box",
radio: "Radio group",
button: "Button",
signature: "Signature",
};
// Type-change is only safe between the "simple" single-widget types; retyping
// into radio/button/signature needs dedicated creation, so it's create-only.
const TYPE_CHANGE_OPTIONS = ["text", "checkbox", "combobox", "listbox"];
/** Split a stored buttonAction string into a kind + optional url. */
function parseButtonAction(action: string | undefined): {
kind: string;
url: string;
} {
if (!action) return { kind: "none", url: "" };
if (action === "reset") return { kind: "reset", url: "" };
if (action === "print") return { kind: "print", url: "" };
if (action.startsWith("uri:")) return { kind: "uri", url: action.slice(4) };
if (action.startsWith("submit:"))
return { kind: "submit", url: action.slice(7) };
return { kind: "none", url: "" };
}
function buildButtonAction(kind: string, url: string): string {
switch (kind) {
case "reset":
return "reset";
case "print":
return "print";
case "uri":
return `uri:${url}`;
case "submit":
return `submit:${url}`;
default:
return "";
}
}
export function FormFieldPropertyEditor({
value,
onChange,
showName = true,
allowTypeChange = false,
}: FormFieldPropertyEditorProps) {
const { t } = useTranslation();
const hasOptions =
value.type === "combobox" ||
value.type === "listbox" ||
value.type === "radio";
const isVariableText =
value.type === "text" ||
value.type === "combobox" ||
value.type === "listbox";
const isText = value.type === "text";
const isButton = value.type === "button";
const isSignature = value.type === "signature";
const isFillable = !isButton && !isSignature;
const canRetype = TYPE_CHANGE_OPTIONS.includes(value.type);
const updateOption = (index: number, next: string) => {
const options = [...(value.options ?? [])];
options[index] = next;
onChange({ options });
};
const addOption = () => onChange({ options: [...(value.options ?? []), ""] });
const removeOption = (index: number) =>
onChange({ options: (value.options ?? []).filter((_, i) => i !== index) });
const action = parseButtonAction(value.buttonAction);
return (
<Stack gap="xs">
{isSignature && (
<Alert
color="blue"
variant="light"
p="xs"
radius="sm"
icon={<InfoOutlinedIcon sx={{ fontSize: 16 }} />}
>
<Text size="xs">
{t(
"formFill.editor.signatureNote",
"Placeholder only - you don't sign here. It marks where a signature belongs so a PDF signer (Adobe Acrobat, a signing service, etc.) places the signature in this spot when the document is signed.",
)}
</Text>
</Alert>
)}
{showName && (
<TextInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.name", "Field name")}
help={t(
"formFill.editor.nameHelp",
"The field's internal name. Used when exporting data or filling the form from another system, so keep it unique and free of spaces.",
)}
/>
}
value={value.name ?? ""}
onChange={(e) => onChange({ name: e.currentTarget.value })}
/>
)}
<TextInput
size="xs"
label={
<LabelWithHelp
text={
isButton
? t("formFill.editor.caption", "Button caption")
: t("formFill.editor.label", "Label")
}
help={
isButton
? t(
"formFill.editor.captionHelp",
"The text printed on the button face.",
)
: t(
"formFill.editor.labelHelp",
"The wording shown to whoever fills the form. Leave it blank to fall back to the field name.",
)
}
/>
}
value={value.label ?? ""}
onChange={(e) => onChange({ label: e.currentTarget.value })}
/>
{allowTypeChange && (
<Select
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.type", "Type")}
help={t(
"formFill.editor.typeHelp",
"What kind of field this is. Changing it rebuilds the field, so its current value is not carried over.",
)}
/>
}
value={canRetype ? value.type : null}
data={TYPE_CHANGE_OPTIONS.map((tp) => ({
value: tp,
label: TYPE_LABEL[tp],
}))}
disabled={!canRetype}
placeholder={canRetype ? undefined : TYPE_LABEL[value.type]}
onChange={(v) => v && onChange({ type: v })}
comboboxProps={{ withinPortal: true }}
/>
)}
{hasOptions && (
<Stack gap={4}>
<Text size="xs" fw={600}>
<LabelWithHelp
text={t("formFill.editor.options", "Options")}
help={t(
"formFill.editor.optionsHelp",
"The choices offered in the list. Each one is stored as typed, so keep them short and distinct.",
)}
/>
</Text>
{(value.options ?? []).length === 0 && (
<Text size="xs" c="dimmed">
{t("formFill.editor.optionsEmpty", "Add at least one option.")}
</Text>
)}
{(value.options ?? []).map((opt, i) => (
<Group key={i} gap={4} wrap="nowrap">
<TextInput
size="xs"
style={{ flex: 1 }}
value={opt}
placeholder={t(
"formFill.editor.optionPlaceholder",
"Option {{n}}",
{
n: i + 1,
},
)}
onChange={(e) => updateOption(i, e.currentTarget.value)}
/>
<ActionIcon
size="sm"
variant="tertiary"
accent="danger"
aria-label={t("formFill.editor.removeOption", "Remove option")}
onClick={() => removeOption(i)}
>
<DeleteOutlineIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</Group>
))}
<Button
size="sm"
variant="tertiary"
leftSection={<AddIcon sx={{ fontSize: 14 }} />}
onClick={addOption}
>
{t("formFill.editor.addOption", "Add option")}
</Button>
{value.type === "radio" && (
// The group fits the drawn box by default; these are for tightening it by hand.
<Group grow gap={6} align="flex-start">
<NumberInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.optionSize", "Option size")}
help={t(
"formFill.editor.optionSizeHelp",
"Width and height of each button, in points. Leave blank to fit them to the box you drew.",
)}
/>
}
value={value.optionSize ?? ""}
min={1}
max={144}
data-testid="form-option-size"
onChange={(v) =>
onChange({
optionSize: typeof v === "number" ? v : undefined,
})
}
/>
<NumberInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.optionGap", "Option spacing")}
help={t(
"formFill.editor.optionGapHelp",
"Gap between buttons, in points. Leave blank to spread them evenly down the box.",
)}
/>
}
value={value.optionGap ?? ""}
min={0}
max={144}
data-testid="form-option-gap"
onChange={(v) =>
onChange({ optionGap: typeof v === "number" ? v : undefined })
}
/>
</Group>
)}
</Stack>
)}
{isFillable && (
<TextInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.defaultValue", "Default value")}
help={t(
"formFill.editor.defaultValueHelp",
"What the field contains before anyone fills it in. Leave blank for an empty field.",
)}
/>
}
value={value.defaultValue ?? ""}
onChange={(e) => onChange({ defaultValue: e.currentTarget.value })}
/>
)}
<TextInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.tooltip", "Tooltip")}
help={t(
"formFill.editor.tooltipHelp",
"The hint shown when someone hovers the field in a PDF reader.",
)}
/>
}
value={value.tooltip ?? ""}
onChange={(e) => onChange({ tooltip: e.currentTarget.value })}
/>
{isButton && (
<>
<Select
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.action", "Button action")}
help={t(
"formFill.editor.actionHelp",
"What the button does when clicked.",
)}
/>
}
value={action.kind}
data={[
{ value: "none", label: t("formFill.editor.actionNone", "None") },
{
value: "reset",
label: t("formFill.editor.actionReset", "Reset form"),
},
{
value: "print",
label: t("formFill.editor.actionPrint", "Print"),
},
{
value: "uri",
label: t("formFill.editor.actionUri", "Open URL"),
},
{
value: "submit",
label: t("formFill.editor.actionSubmit", "Submit to URL"),
},
]}
onChange={(v) =>
onChange({
buttonAction: buildButtonAction(v ?? "none", action.url),
})
}
comboboxProps={{ withinPortal: true }}
/>
{(action.kind === "uri" || action.kind === "submit") && (
<TextInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.actionUrl", "URL")}
help={t(
"formFill.editor.actionUrlHelp",
"The address the button opens or submits to.",
)}
/>
}
value={action.url}
onChange={(e) =>
onChange({
buttonAction: buildButtonAction(
action.kind,
e.currentTarget.value,
),
})
}
/>
)}
</>
)}
{isVariableText && (
<NumberInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.fontSize", "Font size")}
help={t(
"formFill.editor.fontSizeHelp",
"Text size inside the field. Leave blank to let the reader size it to fit.",
)}
/>
}
value={value.fontSize ?? ""}
min={1}
max={144}
onChange={(v) =>
onChange({ fontSize: typeof v === "number" ? v : undefined })
}
/>
)}
{isText && (
<>
<SwitchWithHelp
help={t(
"formFill.editor.multilineHelp",
"Allows more than one line of text and wraps at the field's edge.",
)}
>
<Switch
size="xs"
label={t("formFill.editor.multiline", "Multi-line")}
checked={!!value.multiline}
onChange={(e) => onChange({ multiline: e.currentTarget.checked })}
/>
</SwitchWithHelp>
<NumberInput
size="xs"
label={
<LabelWithHelp
text={t("formFill.editor.maxLength", "Max length (comb)")}
help={t(
"formFill.editor.maxLengthHelp",
"Caps how many characters fit, drawn as evenly spaced boxes.",
)}
/>
}
value={value.maxLength ? value.maxLength : ""}
min={0}
max={500}
onChange={(v) =>
// 0 is the clear signal; undefined would read as "unchanged" server-side.
onChange({ maxLength: typeof v === "number" ? v : 0 })
}
/>
</>
)}
{value.type === "listbox" && (
<SwitchWithHelp
help={t(
"formFill.editor.multiSelectHelp",
"Lets more than one option be chosen at once.",
)}
>
<Switch
size="xs"
label={t("formFill.editor.multiSelect", "Allow multiple selection")}
checked={!!value.multiSelect}
onChange={(e) => onChange({ multiSelect: e.currentTarget.checked })}
/>
</SwitchWithHelp>
)}
{isFillable && (
<SwitchWithHelp
help={t(
"formFill.editor.requiredHelp",
"The form cannot be submitted until this field is filled in.",
)}
>
<Switch
size="xs"
label={t("formFill.editor.required", "Required")}
checked={!!value.required}
onChange={(e) => onChange({ required: e.currentTarget.checked })}
/>
</SwitchWithHelp>
)}
{/* Read-only belongs to an existing field; one being drawn has nothing to protect yet. */}
{isFillable && allowTypeChange && (
<SwitchWithHelp
help={t(
"formFill.editor.readOnlyHelp",
"Shows a value but stops anyone editing it.",
)}
>
<Switch
size="xs"
label={t("formFill.editor.readOnly", "Read-only")}
checked={!!value.readOnly}
onChange={(e) => onChange({ readOnly: e.currentTarget.checked })}
/>
</SwitchWithHelp>
)}
</Stack>
);
}
export default FormFieldPropertyEditor;
@@ -1,57 +1,77 @@
/* No height:100% / overflow here: the tool lives inside ToolPanel's ScrollArea, and
either one would make the sticky chrome below pin to a box that never scrolls. */
.root {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: transparent;
}
/* Sticky, not flex-pinned: the tool renders inside ToolPanel's ScrollArea, whose
display:table viewport makes height:100% resolve to content height. */
.modeTabs {
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 2;
border-bottom: 1px solid var(--c-border, var(--mantine-color-default-border));
background: transparent;
background: var(--c-bg-raised, var(--mantine-color-body));
padding: 0.25rem;
}
.segmentedRoot {
background: rgba(0, 0, 0, 0.05) !important;
border-radius: var(--radius-sm) !important;
}
:global([data-mantine-color-scheme="dark"]) .segmentedRoot {
background: rgba(255, 255, 255, 0.05) !important;
}
.segmentedIndicator {
background-color: var(--mantine-color-blue-filled) !important;
box-shadow: var(--shadow-sm) !important;
border-radius: var(--radius-sm) !important;
}
/* The shared SegmentedControl sizes its label box for one line and hides the overflow,
so the stacked icon-over-text tabs need that box opened up (see .modeTabs overrides). */
.segmentedLabel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
gap: 0.125rem;
padding: 0.125rem 0;
min-height: 2.25rem;
min-width: 0;
width: 100%;
}
/* Equal-width segments that can shrink, so a long translation truncates inside its
own tab instead of pushing the others off the edge. */
/* Mantine's size="xs" writes height:26px as an INLINE style, which would clip the
taller stacked tabs; only !important can outrank it. */
.modeTabs :global(.sui-seg.mantine-SegmentedControl-root) {
height: auto !important;
}
.modeTabs :global(.mantine-SegmentedControl-control) {
flex: 1 1 0;
min-width: 0;
min-height: 2.75rem;
}
.modeTabs :global(.mantine-SegmentedControl-label) {
min-width: 0;
padding-left: 0.25rem;
padding-right: 0.25rem;
}
/* Colour inherited: the shared SegmentedControl flips the active segment's text,
so setting it here would leave the label unreadable on the accent fill. */
.segmentedInnerLabel {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--c-text-subtle);
transition: color 0.15s ease;
color: inherit;
line-height: 1;
/* Longer translations must ellipsise inside their segment; the control clips at
18px so they cannot wrap, and unconstrained they push the other tabs off. */
min-width: 0;
align-self: stretch;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modeTabIcon {
font-size: 1rem !important;
margin-bottom: 0.125rem;
opacity: 0.8;
flex-shrink: 0;
}
.header {
@@ -84,6 +104,20 @@
gap: 0.5rem;
}
/* Pinned footer. Sticky for the same reason as .modeTabs, and opaque so the list
scrolls underneath it rather than showing through. */
.footer {
position: sticky;
bottom: 0;
z-index: 2;
padding: 0.75rem 1rem;
background: var(--c-bg-raised, var(--mantine-color-body));
border-top: 1px solid var(--c-border, var(--mantine-color-default-border));
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.primaryActions {
display: flex;
gap: 0.5rem;
@@ -108,7 +142,6 @@
.fieldList {
flex: 1;
overflow: hidden;
background: transparent;
}
@@ -1,13 +1,5 @@
/**
* FormFill: The tool component that renders in the left ToolPanel
* when the "Fill Form" tool is selected.
*
* Redesigned with:
* - Mode tabs for future extensibility (Fill / Make / Batch / Modify)
* - Clean visual hierarchy with proper spacing
* - Shared FieldInput component (eliminates duplication)
* - CSS module for theme-consistent styling
* - Status bar at bottom for contextual info
* Form Editor tool panel: fill values, create new fields, or modify existing ones.
*/
import React, {
useEffect,
@@ -25,6 +17,8 @@ import {
Progress,
Tooltip,
} from "@mantine/core";
import { UnsavedChangesDialog } from "@app/components/shared/UnsavedChangesDialog";
import { SegmentedControl } from "@app/ui/SegmentedControl";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
@@ -34,6 +28,8 @@ import {
useAllFormValues,
} from "@app/tools/formFill/FormFillContext";
import { useNavigation } from "@app/contexts/NavigationContext";
import { useFieldShortcuts } from "@app/tools/formFill/useFieldShortcuts";
import { serverMessage } from "@app/tools/formFill/useFormCommit";
import { useViewer } from "@app/contexts/ViewerContext";
import { useAllFiles, useFileState } from "@app/contexts/FileContext";
import { Skeleton } from "@mantine/core";
@@ -50,7 +46,6 @@ import RefreshIcon from "@mui/icons-material/Refresh";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import EditNoteIcon from "@mui/icons-material/EditNote";
import PostAddIcon from "@mui/icons-material/PostAdd";
import FileCopyIcon from "@mui/icons-material/FileCopy";
import BuildCircleIcon from "@mui/icons-material/BuildCircle";
import DescriptionIcon from "@mui/icons-material/Description";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
@@ -58,72 +53,34 @@ import {
extractFormFieldsCsv,
extractFormFieldsXlsx,
} from "@app/tools/formFill/formApi";
import type { FormMode } from "@app/tools/formFill/types";
import { FormFieldCreatePanel } from "@app/tools/formFill/FormFieldCreatePanel";
import { FormFieldModifyPanel } from "@app/tools/formFill/FormFieldModifyPanel";
import { dispatchFormApply } from "@app/tools/formFill/formFillEvents";
import styles from "@app/tools/formFill/FormFill.module.css";
// ---------------------------------------------------------------------------
// Mode tabs — extensible for future form tools
// ---------------------------------------------------------------------------
type FormMode = "fill" | "make" | "batch" | "modify";
interface ModeTabDef {
id: FormMode;
label: string;
icon: React.ReactNode;
ready: boolean;
}
const _MODE_TABS: ModeTabDef[] = [
{
id: "fill",
label: "Fill",
icon: <EditNoteIcon className={styles.modeTabIcon} />,
ready: true,
},
{
id: "make",
label: "Create",
icon: <PostAddIcon className={styles.modeTabIcon} />,
ready: false,
},
{
id: "batch",
label: "Batch",
icon: <FileCopyIcon className={styles.modeTabIcon} />,
ready: false,
},
{
id: "modify",
label: "Modify",
icon: <BuildCircleIcon className={styles.modeTabIcon} />,
ready: false,
},
];
// ---------------------------------------------------------------------------
// Coming-soon placeholder for unimplemented tabs
// ---------------------------------------------------------------------------
// ComingSoonPlaceholder — re-enable when mode tabs are exposed
// function ComingSoonPlaceholder({ mode }: { mode: ModeTabDef }) {
// return (
// <div className={styles.comingSoon}>
// <DescriptionIcon className={styles.comingSoonIcon} />
// <div className={styles.comingSoonTitle}>{mode.label} Forms</div>
// <div className={styles.comingSoonDesc}>
// This feature is coming soon. Stay tuned!
// </div>
// </div>
// );
// }
// ---------------------------------------------------------------------------
// Main FormFill component
// ---------------------------------------------------------------------------
const FormFill = (_props: BaseToolProps) => {
const { t } = useTranslation();
const { selectedTool } = useNavigation();
const {
selectedTool,
registerUnsavedChangesChecker,
unregisterUnsavedChangesChecker,
setHasUnsavedChanges,
} = useNavigation();
const { state: fileState } = useFileState();
const {
@@ -133,18 +90,43 @@ const FormFill = (_props: BaseToolProps) => {
setValue,
setActiveField,
validateForm,
mode,
setMode,
hasUncommittedChanges,
forFileId,
commitNewFields,
commitModifications,
setCreationType,
setSelectedField,
setPreviewing,
} = useFormFill();
const MODE_TABS: ModeTabDef[] = useMemo(
() => [
{
id: "fill",
label: t("formFill.mode.fill", "Fill"),
icon: <EditNoteIcon className={styles.modeTabIcon} />,
},
{
id: "create",
label: t("formFill.mode.create", "Create"),
icon: <PostAddIcon className={styles.modeTabIcon} />,
},
{
id: "modify",
label: t("formFill.mode.modify", "Modify"),
icon: <BuildCircleIcon className={styles.modeTabIcon} />,
},
],
[t],
);
const allValues = useAllFormValues();
const { validationErrors } = formState;
const { scrollActions } = useViewer();
// Mode system is temporarily restricted to 'fill' only.
// Other modes (make, batch, modify) are defined above but not yet exposed in the UI.
// When ready, uncomment the SegmentedControl and mode state below.
// const [mode, setMode] = useState<FormMode>('fill');
const mode: FormMode = "fill";
const [flatten, setFlatten] = useState(false);
const [saving, setSaving] = useState(false);
const [extracting, setExtracting] = useState(false);
@@ -175,9 +157,48 @@ const FormFill = (_props: BaseToolProps) => {
}
}, [allValues]);
const activeFieldRef = useRef<HTMLDivElement>(null);
useFieldShortcuts();
// A document with nothing to fill lands on Create: Fill would show an empty panel and leave
// the user hunting for the tab that does what they came for. Decided once per document, and
// only after the field list has settled - fields arrive a beat after the fetch reports done,
// so deciding immediately sends a form that does have fields to the wrong tab.
const autoModeFileRef = useRef<string | null>(null);
const fieldCountRef = useRef(0);
fieldCountRef.current = formState.fields.length;
const modeRef = useRef(mode);
modeRef.current = mode;
useEffect(() => {
if (formState.loading || !forFileId) return undefined;
if (autoModeFileRef.current === forFileId) return undefined;
const settle = window.setTimeout(() => {
autoModeFileRef.current = forFileId;
if (fieldCountRef.current === 0 && modeRef.current === "fill") {
setMode("create");
}
}, 300);
return () => window.clearTimeout(settle);
}, [formState.loading, formState.fields.length, forFileId, setMode]);
const isDirtyRef = useRef(formState.isDirty);
isDirtyRef.current = formState.isDirty;
// Fields drawn or edited but never applied are lost on navigation, so the app's existing
// leave-warning needs to hear about them the way other tools report theirs.
const hasUncommittedRef = useRef(false);
hasUncommittedRef.current = hasUncommittedChanges;
useEffect(() => {
registerUnsavedChangesChecker(
() => hasUncommittedRef.current || isDirtyRef.current,
);
return () => unregisterUnsavedChangesChecker();
}, [registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
// requestNavigation gates on this flag rather than on the checker above.
useEffect(() => {
setHasUnsavedChanges(hasUncommittedChanges || formState.isDirty);
}, [hasUncommittedChanges, formState.isDirty, setHasUnsavedChanges]);
// Subscribing read: getFiles() during render doesn't re-run when the workbench
// changes, so the panel kept showing the pre-hydration (or pre-version) file.
const { files: activeFiles } = useAllFiles();
@@ -233,6 +254,15 @@ const FormFill = (_props: BaseToolProps) => {
const isActive = selectedTool === "formFill";
// Arming lives in an app-level provider, so leaving the tool would otherwise keep the page
// in crosshair mode and stage fields for whatever the user opened next.
useEffect(() => {
if (isActive) return;
setCreationType(null);
setSelectedField(null);
setPreviewing(false);
}, [isActive, setCreationType, setSelectedField, setPreviewing]);
useEffect(() => {
if (formState.activeFieldName && activeFieldRef.current) {
activeFieldRef.current.scrollIntoView({
@@ -264,23 +294,20 @@ const FormFill = (_props: BaseToolProps) => {
// Track the flatten value at save so toggling it later re-enables Save
setLastSavedFlatten(flatten);
// Dispatch to the viewer's handleFormApply via custom event.
// This ensures the viewer tracks the new file ID, preserves
// scroll position and rotation — instead of our own consumeFiles
// call which would lose the viewer's file tracking context.
const event = new CustomEvent("formfill:apply", {
detail: { blob: filledBlob },
});
window.dispatchEvent(event);
// Route through the viewer's handleFormApply: a direct consumeFiles call
// loses the viewer's file tracking, and with it scroll position and rotation.
dispatchFormApply(filledBlob);
} catch (err) {
const status = isAxiosError(err) ? err.response?.status : undefined;
const message =
status === 413
? "File too large. Try reducing the PDF size first."
: status === 400
? "Invalid form data. Please check all fields."
: (err instanceof Error ? err.message : undefined) ||
"Failed to save filled form";
: (await serverMessage(err)) ||
(status === 400
? "Invalid form data. Please check all fields."
: undefined) ||
(err instanceof Error ? err.message : undefined) ||
"Failed to save filled form";
setSaveError(message);
console.error("[FormFill] Save failed:", err);
} finally {
@@ -289,6 +316,64 @@ const FormFill = (_props: BaseToolProps) => {
}
}, [currentFile, submitForm, flatten, validateForm]);
const applyCurrentMode = useCallback(async () => {
if (!currentFile) return;
if (mode === "create") {
dispatchFormApply(await commitNewFields(currentFile));
return;
}
if (mode === "modify") {
dispatchFormApply(await commitModifications(currentFile));
return;
}
await handleSave();
}, [mode, currentFile, commitNewFields, commitModifications, handleSave]);
// Switching tabs throws away that tab's working state, so it asks first. Owned here rather
// than through the app-wide warning handlers, which hold a single registration app-wide.
const [pendingMode, setPendingMode] = useState<FormMode | null>(null);
const [switching, setSwitching] = useState(false);
const requestMode = useCallback(
(next: FormMode) => {
if (next === mode) return;
if (hasUncommittedChanges) {
setPendingMode(next);
return;
}
setMode(next);
},
[mode, hasUncommittedChanges, setMode],
);
const discardAndSwitch = useCallback(() => {
const next = pendingMode;
setPendingMode(null);
if (next) setMode(next);
}, [pendingMode, setMode]);
const applyAndSwitch = useCallback(async () => {
const next = pendingMode;
setSwitching(true);
try {
await applyCurrentMode();
setPendingMode(null);
if (next) setMode(next);
} catch (err) {
// Swallowing this left the dialog open with no explanation when the backend refused a
// name, so the reason it gives has to reach the user.
setSaveError(
(await serverMessage(err)) ||
(err instanceof Error ? err.message : null) ||
t("formFill.applyFailed", "Could not apply the changes"),
);
setPendingMode(null);
console.error("[FormFill] Could not apply before switching tabs:", err);
} finally {
setSwitching(false);
}
}, [pendingMode, applyCurrentMode, setMode, t]);
// Keyboard shortcut: Ctrl+S to save
const flattenChangedRef = useRef(flattenChanged);
flattenChangedRef.current = flattenChanged;
@@ -391,35 +476,44 @@ const FormFill = (_props: BaseToolProps) => {
return (
<div className={styles.root}>
{/* ---- Mode selection (commented out until additional modes are implemented) ----
<UnsavedChangesDialog
opened={pendingMode !== null}
saving={switching}
onKeepWorking={() => setPendingMode(null)}
onDiscard={discardAndSwitch}
onSave={applyAndSwitch}
/>
{/* ---- Mode selection ---- */}
<div className={styles.modeTabs}>
<SegmentedControl
value={mode}
onChange={(val) => setMode(val as FormMode)}
data={MODE_TABS.map((tab) => ({
onChange={(val) => requestMode(val as FormMode)}
options={MODE_TABS.map((tab) => ({
value: tab.id,
label: (
<div className={styles.segmentedLabel}>
// title carries the full label for locales where it has to ellipsise.
<div className={styles.segmentedLabel} title={tab.label}>
{tab.icon}
<span>{tab.label}</span>
<span className={styles.segmentedInnerLabel}>{tab.label}</span>
</div>
),
}))}
fullWidth
radius="xs"
size="xs"
classNames={{
root: styles.segmentedRoot,
indicator: styles.segmentedIndicator,
control: styles.segmentedControl,
label: styles.segmentedInnerLabel,
}}
ariaLabel={t("formFill.mode.label", "Form editor mode")}
/>
</div>
---- */}
{/* ---- Coming-soon for non-ready tabs (hidden while mode tabs are disabled) ---- */}
{/* !currentModeDef.ready && <ComingSoonPlaceholder mode={currentModeDef} /> */}
{/* ---- Create mode ---- */}
{mode === "create" && (
<FormFieldCreatePanel currentFile={currentFile as File | Blob | null} />
)}
{/* ---- Modify mode ---- */}
{mode === "modify" && (
<FormFieldModifyPanel currentFile={currentFile as File | Blob | null} />
)}
{/* ---- Fill Form content ---- */}
{mode === "fill" && (
@@ -0,0 +1,340 @@
/**
* Staged edits are keyed to the file they were made against, and a commit's skip
* report must survive the re-fetch that the commit itself triggers.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import React from "react";
import {
FormFillProvider,
useFormFill,
} from "@app/tools/formFill/FormFillContext";
import type { FieldEditResult } from "@app/tools/formFill/types";
const applyFieldEdits = vi.fn();
const fetchFields = vi.fn();
vi.mock("@app/tools/formFill/formApi", () => ({
applyFieldEdits: (...args: unknown[]) => applyFieldEdits(...args),
}));
// Defined inside each factory: vi.mock is hoisted above any module-level binding.
vi.mock("@app/tools/formFill/providers/PdfBoxFormProvider", () => ({
PdfBoxFormProvider: class {
fetchFields(...args: unknown[]) {
return fetchFields(...args);
}
fillForm() {
return Promise.resolve(new Blob());
}
},
}));
vi.mock("@app/tools/formFill/providers/PdfiumFormProvider", () => ({
PdfiumFormProvider: class {
fetchFields(...args: unknown[]) {
return fetchFields(...args);
}
fillForm() {
return Promise.resolve(new Blob());
}
},
}));
vi.mock("@app/services/pdfiumService", () => ({
fetchSignatureFieldsWithAppearances: vi.fn().mockResolvedValue([]),
}));
const wrapper = ({ children }: { children: React.ReactNode }) => (
<FormFillProvider>{children}</FormFillProvider>
);
const blob = () => new Blob(["%PDF-1.4"], { type: "application/pdf" });
function result(skipped: FieldEditResult["skipped"]): FieldEditResult {
return { blob: blob(), skipped, skippedTotal: skipped.length };
}
describe("FormFillContext staged-edit ownership", () => {
beforeEach(() => {
applyFieldEdits.mockReset();
fetchFields.mockReset();
fetchFields.mockResolvedValue([]);
});
it("keeps the skip report across the re-fetch a commit triggers", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.stageModification("f", { x: 1 }));
applyFieldEdits.mockResolvedValue(
result([
{
operation: "delete",
target: "ghost",
reason: "no field with that name exists",
},
]),
);
await act(async () => {
await hook.current.commitModifications(blob());
});
expect(hook.current.skippedEdits).toHaveLength(1);
// Committing produces a NEW workbench file, so the viewer re-fetches under a new id.
await act(async () => {
await hook.current.fetchFields(blob(), "file-A-edited");
});
await waitFor(() => expect(hook.current.skippedEdits).toHaveLength(1));
});
it("drops edits staged against a different file", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.stageModification("f", { x: 1 }));
expect(hook.current.hasUncommittedChanges).toBe(true);
await act(async () => {
await hook.current.fetchFields(blob(), "file-B");
});
await waitFor(() => expect(hook.current.hasUncommittedChanges).toBe(false));
});
it("drops them even when the new file's fetch fails", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.stageModification("f", { x: 1 }));
fetchFields.mockRejectedValueOnce(new Error("corrupt PDF"));
await act(async () => {
await hook.current.fetchFields(blob(), "file-B");
});
await waitFor(() => expect(hook.current.hasUncommittedChanges).toBe(false));
});
it("keeps edits across a re-fetch of the same file", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.stageModification("f", { x: 1 }));
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
expect(hook.current.hasUncommittedChanges).toBe(true);
});
it("drops the skip report once an unrelated document is opened", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.stageModification("f", { x: 1 }));
applyFieldEdits.mockResolvedValue(
result([
{
operation: "delete",
target: "ghost",
reason: "no field with that name exists",
},
]),
);
await act(async () => {
await hook.current.commitModifications(blob());
});
// The commit's own re-fetch keeps the report...
await act(async () => {
await hook.current.fetchFields(blob(), "file-A-edited");
});
expect(hook.current.skippedEdits).toHaveLength(1);
// ...but opening a different document must not carry it over.
await act(async () => {
await hook.current.fetchFields(blob(), "file-B-unrelated");
});
await waitFor(() => expect(hook.current.skippedEdits).toHaveLength(0));
expect(hook.current.skippedTotal).toBe(0);
});
});
describe("FormFillContext bundled field list", () => {
beforeEach(() => {
applyFieldEdits.mockReset();
fetchFields.mockReset();
fetchFields.mockResolvedValue([]);
});
const bundled = [
{ name: "bundled", type: "text", widgets: [{ pageIndex: 0, x: 1, y: 2 }] },
] as unknown as FieldEditResult["fields"];
async function commitWith(
hook: { current: ReturnType<typeof useFormFill> },
edited: Blob,
fields: FieldEditResult["fields"],
) {
// The viewer switches to pdfbox whenever the form tool is open, which is when commits happen.
act(() => hook.current.setProviderMode("pdfbox"));
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.stageModification("f", { x: 1 }));
applyFieldEdits.mockResolvedValue({
blob: edited,
skipped: [],
skippedTotal: 0,
fields,
});
await act(async () => {
await hook.current.commitModifications(blob());
});
}
it("skips the follow-up request when the commit already returned the fields", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
const edited = blob();
await commitWith(hook, edited, bundled);
const callsBefore = fetchFields.mock.calls.length;
await act(async () => {
await hook.current.fetchFields(edited, "file-A-edited");
});
// The whole point: no second upload for the post-commit fetch.
expect(fetchFields.mock.calls).toHaveLength(callsBefore);
await waitFor(() =>
expect(hook.current.state.fields.map((f) => f.name)).toEqual(["bundled"]),
);
});
it("still asks the backend when the commit returned no fields", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
const edited = blob();
await commitWith(hook, edited, undefined);
const callsBefore = fetchFields.mock.calls.length;
await act(async () => {
await hook.current.fetchFields(edited, "file-A-edited");
});
expect(fetchFields.mock.calls.length).toBe(callsBefore + 1);
});
it("ignores a bundle whose size does not match the file being fetched", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await commitWith(hook, blob(), bundled);
const callsBefore = fetchFields.mock.calls.length;
// A different document must never adopt the previous commit's field list.
await act(async () => {
await hook.current.fetchFields(
new Blob(["%PDF-1.4 a longer unrelated document"]),
"file-B",
);
});
expect(fetchFields.mock.calls.length).toBe(callsBefore + 1);
});
it("does not reuse the bundle for a second fetch", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
const edited = blob();
await commitWith(hook, edited, bundled);
await act(async () => {
await hook.current.fetchFields(edited, "file-A-edited");
});
const afterFirst = fetchFields.mock.calls.length;
await act(async () => {
await hook.current.fetchFields(edited, "file-A-edited");
});
expect(fetchFields.mock.calls.length).toBe(afterFirst + 1);
});
});
describe("FormFillContext value retention", () => {
beforeEach(() => {
applyFieldEdits.mockReset();
fetchFields.mockReset();
fetchFields.mockResolvedValue([
{ name: "who", type: "text", value: "", widgets: [{ pageIndex: 0 }] },
]);
});
it("keeps what the user typed when the same file is re-fetched", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.setValue("who", "Ada"));
expect(hook.current.getValue("who")).toBe("Ada");
// Opening the form tool switches provider, which re-fetches the very same document.
act(() => hook.current.setProviderMode("pdfbox"));
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
expect(hook.current.getValue("who")).toBe("Ada");
});
it("drops retained values when a different document is opened", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.setValue("who", "Ada"));
await act(async () => {
await hook.current.fetchFields(blob(), "file-B");
});
expect(hook.current.getValue("who")).toBe("");
});
it("does not report unsaved changes when nothing was typed", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.setProviderMode("pdfbox"));
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
// A sticky dirty flag makes the leave-page warning fire on every navigation.
expect(hook.current.state.isDirty).toBe(false);
});
it("still reports unsaved changes when something was typed", async () => {
const { result: hook } = renderHook(() => useFormFill(), { wrapper });
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
act(() => hook.current.setValue("who", "Ada"));
act(() => hook.current.setProviderMode("pdfbox"));
await act(async () => {
await hook.current.fetchFields(blob(), "file-A");
});
expect(hook.current.state.isDirty).toBe(true);
});
});
@@ -31,15 +31,32 @@ import React, {
} from "react";
import { useDebouncedCallback } from "@mantine/hooks";
import { isAxiosError } from "axios";
import { applyStagedGeometry } from "@app/tools/formFill/formCoordinateUtils";
import type {
FormField,
FormFillState,
WidgetCoordinates,
FormMode,
CreatableFieldType,
NewFieldDefinition,
ModifyFieldDefinition,
SkippedFieldEdit,
} from "@app/tools/formFill/types";
import { pendingIdFrom } from "@app/tools/formFill/pendingSelection";
import type { IFormDataProvider } from "@app/tools/formFill/providers/types";
import { PdfBoxFormProvider } from "@app/tools/formFill/providers/PdfBoxFormProvider";
import { PdfiumFormProvider } from "@app/tools/formFill/providers/PdfiumFormProvider";
import { fetchSignatureFieldsWithAppearances } from "@app/services/pdfiumService";
import { applyFieldEdits } from "@app/tools/formFill/formApi";
import { mergeSignatureAppearances } from "@app/tools/formFill/formFieldMerge";
/** Marks a skip report as belonging to whichever document the commit just produced. */
const PENDING_SKIP_REPORT = "__pending__";
/** A field queued for creation, with a client-side id for list keys. */
export interface PendingField extends NewFieldDefinition {
id: string;
}
// ---------------------------------------------------------------------------
// FormValuesStore — external store for field values (outside React state)
@@ -158,8 +175,20 @@ function reducer(state: FormFillState, action: Action): FormFillState {
return { ...state, isDirty: true };
case "SET_ACTIVE_FIELD":
return { ...state, activeFieldName: action.fieldName };
case "SET_VALIDATION_ERRORS":
return { ...state, validationErrors: action.errors };
case "SET_VALIDATION_ERRORS": {
// The debounce mints a fresh identical map ~3x/s while typing; without this every
// keystroke re-renders every consumer and every mounted page overlay.
const prev = state.validationErrors;
const next = action.errors;
const keys = Object.keys(next);
if (
keys.length === Object.keys(prev).length &&
keys.every((k) => prev[k] === next[k])
) {
return state;
}
return { ...state, validationErrors: next };
}
case "CLEAR_VALIDATION_ERROR": {
if (!state.validationErrors[action.fieldName]) return state;
const { [action.fieldName]: _, ...rest } = state.validationErrors;
@@ -196,6 +225,8 @@ export interface FormFillContextValue {
reset: () => void;
/** Pre-computed map of page index to fields for performance */
fieldsByPage: Map<number, FormField[]>;
/** fieldsByPage with staged moves, resizes, retypes and deletions already applied. */
effectiveFieldsByPage: Map<number, FormField[]>;
/** Name of the currently active provider ('pdf-lib' | 'pdfbox') */
activeProviderName: string;
/**
@@ -206,6 +237,69 @@ export interface FormFillContextValue {
setProviderMode: (mode: "pdflib" | "pdfbox") => void;
/** The file ID that the current form fields belong to (null if no fields loaded) */
forFileId: string | null;
// --- Structural editing (create / modify modes) ---
/** Current tool mode. */
mode: FormMode;
/** Switch mode. Switching clears the other mode's uncommitted working state. */
setMode: (mode: FormMode) => void;
// --- Create mode ---
/** Field type currently armed for placement (null = not placing). */
creationType: CreatableFieldType | null;
/** Steps back one staged edit; false when there was nothing left to undo. */
undo: () => boolean;
canUndo: boolean;
/** True while the user holds Preview, which hides the editing chrome. */
previewing: boolean;
setPreviewing: (previewing: boolean) => void;
setCreationType: (type: CreatableFieldType | null) => void;
/** Fields drawn but not yet committed to the PDF. */
pendingFields: PendingField[];
/** Queue a new field (id + default name auto-assigned). Returns the new id. */
addPendingField: (
field: Omit<NewFieldDefinition, "name"> & { name?: string },
) => string;
updatePendingField: (id: string, patch: Partial<NewFieldDefinition>) => void;
removePendingField: (id: string) => void;
clearPendingFields: () => void;
/** POST queued fields to the backend; resolves to the updated PDF blob. */
commitNewFields: (file: File | Blob) => Promise<Blob>;
// --- Modify mode ---
/** Field currently selected for editing in modify mode. */
selectedFieldName: string | null;
setSelectedField: (name: string | null) => void;
/** Staged (uncommitted) property/geometry changes, keyed by original field name. */
modifiedFields: Record<string, ModifyFieldDefinition>;
/** Merge a partial change for a field into the staged set. */
stageModification: (
targetName: string,
patch: Partial<ModifyFieldDefinition>,
) => void;
/** Discard staged changes for a single field. */
clearModification: (targetName: string) => void;
/** Field names marked for deletion. */
deletedFieldNames: string[];
/** Toggle a field's deletion mark. */
toggleFieldDeleted: (name: string) => void;
/** Discard all staged modifications and deletions. */
clearModifications: () => void;
/** POST staged modifications + deletions; resolves to the updated PDF blob. */
commitModifications: (file: File | Blob) => Promise<Blob>;
/** True when create or modify mode has uncommitted work. */
hasUncommittedChanges: boolean;
/** Edits the last commit asked for but the document could not take. */
skippedEdits: SkippedFieldEdit[];
/** May exceed skippedEdits.length when the report was truncated. */
skippedTotal: number;
clearSkippedEdits: () => void;
/** True while a field is being dragged, so Escape handlers elsewhere stand down. */
dragActiveRef: React.MutableRefObject<boolean>;
}
const FormFillContext = createContext<FormFillContextValue | null>(null);
@@ -307,6 +401,112 @@ export function FormFillProvider({
// This prevents full context re-renders on every keystroke.
const [valuesStore] = useState(() => new FormValuesStore());
// --- Structural editing state (create / modify modes) ---
const [mode, setModeState] = useState<FormMode>("fill");
const [creationType, setCreationType] = useState<CreatableFieldType | null>(
null,
);
const [pendingFields, setPendingFields] = useState<PendingField[]>([]);
const [previewing, setPreviewing] = useState(false);
// Undo covers the staged edits, which is what the user has been doing here; the applied
// document keeps its own version history elsewhere.
const undoStackRef = useRef<
{
pending: PendingField[];
modified: Record<string, ModifyFieldDefinition>;
deleted: string[];
}[]
>([]);
const [canUndo, setCanUndo] = useState(false);
const liveEditsRef = useRef({
pending: [] as PendingField[],
modified: {} as Record<string, ModifyFieldDefinition>,
deleted: [] as string[],
});
const rememberForUndo = useCallback(() => {
const live = liveEditsRef.current;
undoStackRef.current.push({
pending: [...live.pending],
modified: { ...live.modified },
deleted: [...live.deleted],
});
// A long session should not grow without bound.
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
setCanUndo(true);
}, []);
const undo = useCallback(() => {
const previous = undoStackRef.current.pop();
setCanUndo(undoStackRef.current.length > 0);
if (!previous) return false;
setPendingFields(previous.pending);
setModifiedFields(previous.modified);
setDeletedFieldNames(previous.deleted);
// A selection pointing at a field the undo removed would swallow the next click.
setSelectedField((current) => {
const id = pendingIdFrom(current);
if (!id) return current;
return previous.pending.some((f) => f.id === id) ? current : null;
});
return true;
}, []);
const [selectedFieldName, setSelectedField] = useState<string | null>(null);
const [modifiedFields, setModifiedFields] = useState<
Record<string, ModifyFieldDefinition>
>({});
const [deletedFieldNames, setDeletedFieldNames] = useState<string[]>([]);
liveEditsRef.current = {
pending: pendingFields,
modified: modifiedFields,
deleted: deletedFieldNames,
};
const [skippedEdits, setSkippedEdits] = useState<SkippedFieldEdit[]>([]);
const [skippedTotal, setSkippedTotal] = useState(0);
const clearSkippedEdits = useCallback(() => {
setSkippedEdits([]);
setSkippedTotal(0);
skipReportFileIdRef.current = null;
}, []);
/** Which file the staged edits belong to, so they cannot be committed onto another. */
const editedFileIdRef = useRef<string | null>(null);
/** Survives an in-flight fetch, unlike forFileIdRef, so staged edits can be stamped. */
const lastKnownFileIdRef = useRef<string | null>(null);
/** The file the current skip report describes, so it cannot outlive that document. */
const skipReportFileIdRef = useRef<string | null>(null);
/** Set by the edit overlay so other Escape handlers do not steal a drag's cancel. */
const dragActiveRef = useRef(false);
// Monotonic counter for client-side pending-field ids and default names.
const pendingCounterRef = useRef(0);
// Fields the last commit's response carried, adopted by the next fetch instead of uploading
// the document again to ask. Size-checked so a different document cannot pick them up.
const bundledFieldsRef = useRef<{ fields: FormField[]; size: number } | null>(
null,
);
// What the user typed, carried across the re-fetch that opening the form tool triggers.
const retainedValuesRef = useRef<{
fileId: string | null;
values: Record<string, string>;
} | null>(null);
const clearEditingState = useCallback(() => {
setCreationType(null);
setPendingFields([]);
setSelectedField(null);
setModifiedFields({});
setDeletedFieldNames([]);
editedFileIdRef.current = null;
// Report and field list both describe the batch just discarded, so neither outlives it.
bundledFieldsRef.current = null;
setSkippedEdits([]);
setSkippedTotal(0);
}, []);
const fetchFields = useCallback(
async (file: File | Blob, fileId?: string) => {
// Increment version so any in-flight fetch for a previous file is discarded.
@@ -316,15 +516,60 @@ export function FormFillProvider({
// correctly discarded.
const version = ++fetchVersionRef.current;
// Staged edits reference the previous document's fields; committing them against a
// different file would edit the wrong PDF. Checked here, before any await, because
// forFileIdRef is null for the whole fetch and the success path may never run.
if (
editedFileIdRef.current != null &&
(fileId ?? null) !== editedFileIdRef.current
) {
clearEditingState();
}
// A commit produces a new file id, so the first fetch after one adopts the report;
// any later switch to a different document clears it.
if (skipReportFileIdRef.current === PENDING_SKIP_REPORT) {
skipReportFileIdRef.current = fileId ?? null;
} else if (
skipReportFileIdRef.current != null &&
(fileId ?? null) !== skipReportFileIdRef.current
) {
skipReportFileIdRef.current = null;
setSkippedEdits([]);
setSkippedTotal(0);
}
// Same document reloading means the typed values are still the user's; a different one
// means they belong to a document that is no longer open.
const sameDocument = (fileId ?? null) === lastKnownFileIdRef.current;
const carried =
retainedValuesRef.current?.fileId === (fileId ?? null)
? retainedValuesRef.current.values
: sameDocument
? { ...valuesStore.values }
: {};
retainedValuesRef.current = null;
lastKnownFileIdRef.current = fileId ?? null;
// Immediately clear previous state so FormFieldOverlay's stale-file guards
// prevent rendering fields from a previous document during the fetch.
forFileIdRef.current = null;
setForFileId(null);
valuesStore.reset({});
dispatch({ type: "RESET" });
// Deliberately keeps create/modify state: the viewer re-fetches on provider
// switch and file load, which must not wipe in-progress edits.
dispatch({ type: "FETCH_START" });
try {
let fields = await providerRef.current.fetchFields(file);
// Only pdfbox mode can use them: the bundle is PDFBox's own view of the document.
const bundled = bundledFieldsRef.current;
bundledFieldsRef.current = null;
const usable =
bundled &&
bundled.size === file.size &&
providerModeRef.current === "pdfbox";
let fields = usable
? bundled.fields
: await providerRef.current.fetchFields(file);
// If another fetch or reset happened while we were waiting, discard this result
if (fetchVersionRef.current !== version) {
console.debug(
@@ -333,8 +578,8 @@ export function FormFillProvider({
return;
}
// When the pdfbox provider is active the backend doesn't return signature fields
// (they're not fillable). Fetch them via pdflib so their appearances still render.
// pdfbox returns signature fields without a rendered appearance; merge the
// pdfium ones by name, since appending would list a signature twice.
if (providerModeRef.current === "pdfbox") {
try {
// Convert File/Blob to ArrayBuffer for pdfiumService
@@ -342,9 +587,7 @@ export function FormFillProvider({
const sigFields =
await fetchSignatureFieldsWithAppearances(arrayBuffer);
if (fetchVersionRef.current !== version) return; // stale check after async
if (sigFields.length > 0) {
fields = [...fields, ...sigFields];
}
fields = mergeSignatureAppearances(fields, sigFields);
} catch (e) {
console.warn(
"[FormFill] Failed to extract signature appearances for pdfbox mode:",
@@ -355,13 +598,21 @@ export function FormFillProvider({
// Initialise values in the external store
const values: Record<string, string> = {};
let edited = false;
for (const field of fields) {
values[field.name] = field.value ?? "";
const stored = field.value ?? "";
values[field.name] = carried[field.name] ?? stored;
edited = edited || values[field.name] !== stored;
}
valuesStore.reset(values);
forFileIdRef.current = fileId ?? null;
setForFileId(fileId ?? null);
dispatch({ type: "FETCH_SUCCESS", fields });
// After FETCH_SUCCESS, which clears the flag; and only when a value genuinely differs,
// or the unsaved-changes prompt cries wolf on every navigation.
if (edited) {
dispatch({ type: "MARK_DIRTY" });
}
} catch (err) {
if (fetchVersionRef.current !== version) return; // stale
const msg =
@@ -373,7 +624,7 @@ export function FormFillProvider({
dispatch({ type: "FETCH_ERROR", error: msg });
}
},
[valuesStore],
[valuesStore, clearEditingState],
);
const validateFieldDebounced = useDebouncedCallback((fieldName: string) => {
@@ -448,6 +699,11 @@ export function FormFillProvider({
fetchVersionRef.current++;
forFileIdRef.current = null;
setForFileId(null);
// The viewer re-fetches straight after this, and that fetch is where they are restored.
retainedValuesRef.current = {
fileId: lastKnownFileIdRef.current,
values: { ...valuesStore.values },
};
valuesStore.reset({});
dispatch({ type: "RESET" });
@@ -481,7 +737,171 @@ export function FormFillProvider({
setForFileId(null);
valuesStore.reset({});
dispatch({ type: "RESET" });
}, [valuesStore]);
clearEditingState();
}, [valuesStore, clearEditingState]);
// --- Mode switching ---
const setMode = useCallback(
(next: FormMode) => {
setModeState((prev) => {
if (prev === next) return prev;
// Leaving a mode discards its uncommitted working state so the user
// doesn't carry half-drawn fields or staged edits between modes.
clearEditingState();
return next;
});
},
[clearEditingState],
);
// --- Create mode ---
const addPendingField = useCallback(
(field: Omit<NewFieldDefinition, "name"> & { name?: string }): string => {
rememberForUndo();
editedFileIdRef.current = lastKnownFileIdRef.current;
const seq = ++pendingCounterRef.current;
const id = `pending-${seq}`;
// Friendly, readable default names that match how the viewer labels
// fields, instead of cryptic "Field_5".
const TYPE_DEFAULT_NAME: Record<string, string> = {
text: "Text field",
checkbox: "Checkbox",
combobox: "Dropdown",
listbox: "List",
radio: "Radio group",
button: "Button",
signature: "Signature",
};
const defaultName =
field.name?.trim() ||
`${TYPE_DEFAULT_NAME[field.type] ?? "Field"} ${seq}`;
// Choice/radio fields are useless without options, so seed defaults.
const needsOptions =
field.type === "combobox" ||
field.type === "listbox" ||
field.type === "radio";
const options =
field.options ?? (needsOptions ? ["Option 1", "Option 2"] : undefined);
setPendingFields((prev) => [
...prev,
{ ...field, name: defaultName, options, id } as PendingField,
]);
return id;
},
[],
);
const updatePendingField = useCallback(
(id: string, patch: Partial<NewFieldDefinition>) => {
rememberForUndo();
setPendingFields((prev) =>
prev.map((f) => (f.id === id ? { ...f, ...patch } : f)),
);
},
[],
);
const removePendingField = useCallback(
(id: string) => {
rememberForUndo();
setPendingFields((prev) => prev.filter((f) => f.id !== id));
},
[rememberForUndo],
);
const clearPendingFields = useCallback(() => {
setPendingFields([]);
setCreationType(null);
}, []);
const commitNewFields = useCallback(
async (file: File | Blob): Promise<Blob> => {
// Strip the client-side id before sending to the backend.
const definitions: NewFieldDefinition[] = pendingFields.map(
({ id: _id, ...rest }) => rest,
);
const result = await applyFieldEdits(file, { add: definitions });
bundledFieldsRef.current = result.fields
? { fields: result.fields, size: result.blob.size }
: null;
setSkippedEdits(result.skipped);
setSkippedTotal(result.skippedTotal);
skipReportFileIdRef.current = PENDING_SKIP_REPORT;
setPendingFields([]);
setCreationType(null);
// The batch is gone, so the stamp must not survive to trigger a clear on the
// post-commit re-fetch; that would wipe the skip report we just set.
editedFileIdRef.current = null;
return result.blob;
},
[pendingFields],
);
// --- Modify mode ---
const stageModification = useCallback(
(targetName: string, patch: Partial<ModifyFieldDefinition>) => {
rememberForUndo();
editedFileIdRef.current = lastKnownFileIdRef.current;
setModifiedFields((prev) => ({
...prev,
[targetName]: { ...prev[targetName], targetName, ...patch },
}));
},
[],
);
const clearModification = useCallback((targetName: string) => {
setModifiedFields((prev) => {
if (!(targetName in prev)) return prev;
const { [targetName]: _removed, ...rest } = prev;
return rest;
});
}, []);
const toggleFieldDeleted = useCallback((name: string) => {
rememberForUndo();
editedFileIdRef.current = lastKnownFileIdRef.current;
setDeletedFieldNames((prev) =>
prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name],
);
}, []);
const clearModifications = useCallback(() => {
setModifiedFields({});
setDeletedFieldNames([]);
setSelectedField(null);
}, []);
const commitModifications = useCallback(
async (file: File | Blob): Promise<Blob> => {
// Apply property/geometry changes (for fields not being deleted) and the
// deletions in a single backend round-trip.
const updates = Object.values(modifiedFields).filter(
(m) => !deletedFieldNames.includes(m.targetName),
);
const result = await applyFieldEdits(file, {
modify: updates,
delete: deletedFieldNames,
});
bundledFieldsRef.current = result.fields
? { fields: result.fields, size: result.blob.size }
: null;
setSkippedEdits(result.skipped);
setSkippedTotal(result.skippedTotal);
skipReportFileIdRef.current = PENDING_SKIP_REPORT;
setModifiedFields({});
setDeletedFieldNames([]);
setSelectedField(null);
editedFileIdRef.current = null;
return result.blob;
},
[modifiedFields, deletedFieldNames],
);
const hasUncommittedChanges =
pendingFields.length > 0 ||
Object.keys(modifiedFields).length > 0 ||
deletedFieldNames.length > 0;
const fieldsByPage = useMemo(() => {
const map = new Map<number, FormField[]>();
@@ -493,6 +913,24 @@ export function FormFillProvider({
return map;
}, [state.fields]);
/**
* The fields as they would look once the staged edits are applied. Drawing from this keeps one
* visual per field that follows a drag, instead of a stale copy left at the old coordinates.
*/
const effectiveFieldsByPage = useMemo(() => {
const map = new Map<number, FormField[]>();
const deleted = new Set(deletedFieldNames);
for (const field of state.fields) {
if (deleted.has(field.name)) continue;
const staged = modifiedFields[field.name];
const effective = staged ? applyStagedGeometry(field, staged) : field;
const pageIdx = effective.widgets?.[0]?.pageIndex ?? 0;
if (!map.has(pageIdx)) map.set(pageIdx, []);
map.get(pageIdx)!.push(effective);
}
return map;
}, [state.fields, modifiedFields, deletedFieldNames]);
// Context value — does NOT depend on values, so keystrokes don't
// trigger re-renders of all context consumers.
const value = useMemo<FormFillContextValue>(
@@ -508,9 +946,39 @@ export function FormFillProvider({
validateForm,
reset,
fieldsByPage,
effectiveFieldsByPage,
activeProviderName: providerRef.current.name,
setProviderMode,
forFileId,
// editing
mode,
setMode,
creationType,
previewing,
setPreviewing,
undo,
canUndo,
setCreationType,
pendingFields,
addPendingField,
updatePendingField,
removePendingField,
clearPendingFields,
commitNewFields,
selectedFieldName,
setSelectedField,
modifiedFields,
stageModification,
clearModification,
deletedFieldNames,
toggleFieldDeleted,
clearModifications,
commitModifications,
hasUncommittedChanges,
skippedEdits,
skippedTotal,
clearSkippedEdits,
dragActiveRef,
}),
[
state,
@@ -524,9 +992,36 @@ export function FormFillProvider({
validateForm,
reset,
fieldsByPage,
effectiveFieldsByPage,
providerMode,
setProviderMode,
forFileId,
mode,
setMode,
creationType,
previewing,
setPreviewing,
undo,
canUndo,
pendingFields,
addPendingField,
updatePendingField,
removePendingField,
clearPendingFields,
commitNewFields,
selectedFieldName,
modifiedFields,
stageModification,
clearModification,
deletedFieldNames,
toggleFieldDeleted,
clearModifications,
commitModifications,
hasUncommittedChanges,
skippedEdits,
skippedTotal,
clearSkippedEdits,
dragActiveRef,
],
);
@@ -540,3 +1035,26 @@ export function FormFillProvider({
}
export default FormFillContext;
/**
* Fields whose PDF-baked visuals (button and signature appearance bitmaps) no longer match the
* staged state. Those layers render from the un-edited file, so they must be hidden while editing
* or they leave a ghost at the original rect.
*/
export function useStaleBakedFieldNames(): Set<string> {
// Read the context directly: the viewer's appearance overlays render outside the form tool
// (and in isolation in Storybook), where there is no provider and nothing is staged.
const ctx = useContext(FormFillContext);
const mode = ctx?.mode ?? "fill";
const modifiedFields = ctx?.modifiedFields;
const deletedFieldNames = ctx?.deletedFieldNames;
return useMemo(() => {
if (mode === "fill" || !modifiedFields || !deletedFieldNames) {
return new Set<string>();
}
return new Set<string>([
...Object.keys(modifiedFields),
...deletedFieldNames,
]);
}, [mode, modifiedFields, deletedFieldNames]);
}
@@ -0,0 +1,54 @@
/**
* Reports edits the backend could not apply. The mutating endpoints return the
* PDF itself, so partial failures arrive in a response header, not the body.
*/
import { Alert, List, Text } from "@mantine/core";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { useTranslation } from "react-i18next";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
export function SkippedEditsAlert() {
const { t } = useTranslation();
const { skippedEdits, skippedTotal, clearSkippedEdits } = useFormFill();
if (skippedEdits.length === 0) return null;
return (
<Alert
icon={<WarningAmberIcon sx={{ fontSize: 16 }} />}
color="yellow"
variant="light"
p="xs"
radius="sm"
withCloseButton
onClose={clearSkippedEdits}
data-testid="form-skipped-edits"
>
<Text size="xs" fw={600}>
{t("formFill.skippedEdits", {
count: Math.max(skippedTotal, skippedEdits.length),
defaultValue: "{{count}} changes could not be applied:",
})}
</Text>
<List size="xs" spacing={2} mt={4}>
{skippedEdits.map((skip, index) => (
<List.Item key={`${skip.operation}-${skip.target ?? index}`}>
<Text size="xs">
{skip.target ? `${skip.target}: ` : ""}
{skip.reason}
</Text>
</List.Item>
))}
</List>
{skippedTotal > skippedEdits.length && (
<Text size="xs" c="dimmed" mt={4}>
{t("formFill.skippedEditsTruncated", "{{count}} more not listed.", {
count: skippedTotal - skippedEdits.length,
})}
</Text>
)}
</Alert>
);
}
export default SkippedEditsAlert;
@@ -0,0 +1,41 @@
/** Alignment guide lines shared by the create and edit overlays; positioned within the page overlay. */
import React from "react";
import type { SnapGuide } from "@app/tools/formFill/formSnapUtils";
import { FORM_COLORS } from "@app/tools/formFill/formFieldColors";
const GUIDE_COLOR = FORM_COLORS.guide;
export function SnapGuides({ guides }: { guides: SnapGuide[] }) {
return (
<>
{guides.map((g, i) => (
<div
key={i}
style={
g.orientation === "v"
? {
position: "absolute",
left: g.position,
top: 0,
width: 1,
height: "100%",
background: GUIDE_COLOR,
pointerEvents: "none",
}
: {
position: "absolute",
top: g.position,
left: 0,
height: 1,
width: "100%",
background: GUIDE_COLOR,
pointerEvents: "none",
}
}
/>
))}
</>
);
}
export default SnapGuides;
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { Blob as NodeBlob } from "node:buffer";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { readFieldBundle } from "@app/tools/formFill/fieldBundle";
/**
* Reads archives produced by the real backend (see FormFieldBundleTest), not by a JS lookalike.
* Java defers the deflated entry's sizes to a data descriptor, so the local header reports zero.
*/
/** node:buffer's Blob satisfies the reader at runtime; TypeScript treats it as a separate type. */
function fixture(name: string): Blob {
const blob = new NodeBlob([
readFileSync(join(import.meta.dirname, "__fixtures__", name)),
]);
return blob as unknown as Blob;
}
describe("readFieldBundle against real backend output", () => {
it("reads a mixed checkbox and radio form", async () => {
const result = await readFieldBundle(fixture("checkbox-and-radio.zip"));
expect(result).not.toBeNull();
expect(result!.fields.map((field) => field.name).sort()).toEqual([
"agree",
"plan",
]);
const pdf = new Uint8Array(await result!.pdf.arrayBuffer());
expect(new TextDecoder().decode(pdf.subarray(0, 5))).toBe("%PDF-");
});
it("reads a 120-field document without truncating the pdf", async () => {
const blob = fixture("many-fields.zip");
const result = await readFieldBundle(blob);
expect(result!.fields).toHaveLength(120);
const pdf = new Uint8Array(await result!.pdf.arrayBuffer());
expect(new TextDecoder().decode(pdf.subarray(0, 5))).toBe("%PDF-");
// The tail proves the stored slice ran to the entry's real end, not to the archive's.
expect(new TextDecoder().decode(pdf.subarray(-6))).toContain("%%EOF");
expect(pdf.length).toBeLessThan(blob.size);
});
it("carries widget coordinates through, which is why the second request can go", async () => {
const result = await readFieldBundle(fixture("many-fields.zip"));
const widget = result!.fields[0]?.widgets?.[0];
expect(widget).toBeDefined();
expect(typeof widget!.pageIndex).toBe("number");
expect(typeof widget!.x).toBe("number");
expect(typeof widget!.y).toBe("number");
});
});
@@ -0,0 +1,241 @@
import { describe, expect, it } from "vitest";
import { Blob as NodeBlob } from "node:buffer";
import { deflateRawSync } from "node:zlib";
import {
readFieldBundle,
supportsFieldBundle,
} from "@app/tools/formFill/fieldBundle";
/**
* Builds ZIPs the way the backend does, using node:buffer's Blob: jsdom's ignores slice() ranges
* and has no stream(), so it silently cannot exercise an archive reader at all.
*/
/** node:buffer's Blob satisfies the reader at runtime; TypeScript treats it as a separate type. */
function asBlob(blob: NodeBlob): Blob {
return blob as unknown as Blob;
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
function crc32(bytes: Uint8Array): number {
let c = 0xffffffff;
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
interface BuildEntry {
name: string;
data: Uint8Array;
deflate: boolean;
/** Mimics a writer that defers sizes to a trailing data descriptor. */
dataDescriptor?: boolean;
extra?: number;
}
function buildZip(entries: BuildEntry[]): Blob {
const chunks: Uint8Array[] = [];
const central: Uint8Array[] = [];
let offset = 0;
for (const entry of entries) {
const name = new TextEncoder().encode(entry.name);
const payload = entry.deflate
? new Uint8Array(deflateRawSync(Buffer.from(entry.data)))
: entry.data;
const crc = crc32(entry.data);
const extraLength = entry.extra ?? 0;
const local = new DataView(new ArrayBuffer(30));
local.setUint32(0, 0x04034b50, true);
local.setUint16(4, 20, true);
local.setUint16(6, entry.dataDescriptor ? 0x08 : 0, true);
local.setUint16(8, entry.deflate ? 8 : 0, true);
// A data-descriptor writer zeroes these in the local header; only the central copy is right.
local.setUint32(14, entry.dataDescriptor ? 0 : crc, true);
local.setUint32(18, entry.dataDescriptor ? 0 : payload.length, true);
local.setUint32(22, entry.dataDescriptor ? 0 : entry.data.length, true);
local.setUint16(26, name.length, true);
local.setUint16(28, extraLength, true);
const localHeaderOffset = offset;
const parts = [
new Uint8Array(local.buffer),
name,
new Uint8Array(extraLength),
payload,
];
for (const part of parts) {
chunks.push(part);
offset += part.length;
}
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(10, entry.deflate ? 8 : 0, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, payload.length, true);
cd.setUint32(24, entry.data.length, true);
cd.setUint16(28, name.length, true);
cd.setUint32(42, localHeaderOffset, true);
central.push(new Uint8Array(cd.buffer), name);
}
const centralStart = offset;
const centralSize = central.reduce((sum, part) => sum + part.length, 0);
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(8, entries.length, true);
eocd.setUint16(10, entries.length, true);
eocd.setUint32(12, centralSize, true);
eocd.setUint32(16, centralStart, true);
return asBlob(
new NodeBlob([...chunks, ...central, new Uint8Array(eocd.buffer)]),
);
}
const FIELDS = [
{ name: "signature", type: "text", widgets: [{ pageIndex: 0 }] },
];
async function bytesOf(blob: Blob): Promise<Uint8Array> {
return new Uint8Array(await blob.arrayBuffer());
}
const PDF_BYTES = new Uint8Array([
0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37, 0x0a, 0x00, 0xff, 0xfe, 0x80,
0x7f,
]);
function bundle(overrides: Partial<BuildEntry>[] = []): Blob {
const pdf = PDF_BYTES;
return buildZip([
{
name: "fields.json",
data: new TextEncoder().encode(JSON.stringify(FIELDS)),
deflate: true,
...overrides[0],
},
{ name: "document.pdf", data: pdf, deflate: false, ...overrides[1] },
]);
}
describe("readFieldBundle", () => {
it("reports support when DecompressionStream exists", () => {
expect(supportsFieldBundle()).toBe(
typeof DecompressionStream === "function",
);
});
it("returns both the pdf and the deflated field list", async () => {
const result = await readFieldBundle(bundle());
expect(result).not.toBeNull();
expect(result!.fields).toEqual(FIELDS);
expect(await bytesOf(result!.pdf)).toEqual(PDF_BYTES);
expect(result!.pdf.type).toBe("application/pdf");
});
it("survives an extra field in the local header", async () => {
// Some writers put an extra field in the local header only, so the payload offset
// has to be read from there rather than from the central directory.
const result = await readFieldBundle(bundle([{}, { extra: 9 }]));
// Byte-exact on purpose: a slice starting early still contains the header, so
// containment cannot detect a misread offset.
expect(await bytesOf(result!.pdf)).toEqual(PDF_BYTES);
});
it("reads sizes from the central directory when the local header defers them", async () => {
const result = await readFieldBundle(
bundle([{ dataDescriptor: true }, {}]),
);
expect(result!.fields).toEqual(FIELDS);
});
it("returns null for a bare pdf so the caller can fall back", async () => {
expect(
await readFieldBundle(
asBlob(new NodeBlob(["%PDF-1.7 not a zip at all"])),
),
).toBeNull();
});
it("throws when the expected entries are absent", async () => {
const wrong = buildZip([
{
name: "other.txt",
data: new TextEncoder().encode("nope"),
deflate: false,
},
]);
await expect(readFieldBundle(wrong)).rejects.toThrow(/missing/i);
});
it("throws when the field list is not an array", async () => {
const notArray = buildZip([
{
name: "fields.json",
data: new TextEncoder().encode('{"a":1}'),
deflate: true,
},
{
name: "document.pdf",
data: new TextEncoder().encode("%PDF-1.7"),
deflate: false,
},
]);
await expect(readFieldBundle(notArray)).rejects.toThrow(/not a list/i);
});
it("keeps the pdf byte-exact through the slice", async () => {
const bytes = new Uint8Array(4096);
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 31) % 256;
const zip = buildZip([
{
name: "fields.json",
data: new TextEncoder().encode("[]"),
deflate: true,
},
{ name: "document.pdf", data: bytes, deflate: false },
]);
const result = await readFieldBundle(zip);
expect(new Uint8Array(await result!.pdf.arrayBuffer())).toEqual(bytes);
});
it("throws rather than handing back a truncated archive as the document", async () => {
const whole = bundle();
// Losing the tail loses the central directory, which is how a cut-off download arrives.
const truncated = whole.slice(0, whole.size - 40);
// Returning this blob would let the caller save a ZIP over the user's PDF.
await expect(readFieldBundle(truncated)).rejects.toThrow(
/corrupt|truncated/i,
);
});
it("never resolves to a blob that is still the archive", async () => {
const whole = bundle();
const result = await readFieldBundle(whole);
expect(result!.pdf.size).toBeLessThan(whole.size);
const head = new Uint8Array(await result!.pdf.slice(0, 2).arrayBuffer());
expect(String.fromCharCode(...head)).not.toBe("PK");
});
});
@@ -0,0 +1,177 @@
import type { FormField } from "@app/tools/formFill/types";
/**
* Reads the ZIP that /edit-fields?includeFields=true returns: the edited PDF stored, the field
* list deflated. Slicing the stored entry hands back a Blob view, so the PDF is never copied.
*/
const EOCD_SIGNATURE = 0x06054b50;
const CENTRAL_HEADER_SIGNATURE = 0x02014b50;
const EOCD_MIN_SIZE = 22;
const CENTRAL_HEADER_SIZE = 46;
const LOCAL_HEADER_SIZE = 30;
const METHOD_STORED = 0;
const METHOD_DEFLATED = 8;
/** ZIP comments are 64KB at most, so the record cannot start further back than this. */
const EOCD_SEARCH_LIMIT = EOCD_MIN_SIZE + 0xffff;
export const FIELDS_ENTRY = "fields.json";
export const DOCUMENT_ENTRY = "document.pdf";
export interface FieldBundle {
pdf: Blob;
fields: FormField[];
}
interface CentralEntry {
name: string;
method: number;
compressedSize: number;
localHeaderOffset: number;
}
/**
* Without it the deflated entry cannot be read, so callers must fall back to a second request.
*/
export function supportsFieldBundle(): boolean {
return typeof DecompressionStream === "function";
}
/** ZIP local file headers start with "PK\x03\x04". */
async function looksZipped(blob: Blob): Promise<boolean> {
if (blob.size < EOCD_MIN_SIZE) return false;
const head = new Uint8Array(await blob.slice(0, 4).arrayBuffer());
return (
head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04
);
}
/** Scans back from the end for the end-of-central-directory record. */
async function readEocd(
blob: Blob,
): Promise<{ offset: number; size: number } | null> {
const tailSize = Math.min(blob.size, EOCD_SEARCH_LIMIT);
const tail = new DataView(
await blob.slice(blob.size - tailSize).arrayBuffer(),
);
for (let i = tailSize - EOCD_MIN_SIZE; i >= 0; i--) {
if (tail.getUint32(i, true) !== EOCD_SIGNATURE) continue;
return {
size: tail.getUint32(i + 12, true),
offset: tail.getUint32(i + 16, true),
};
}
return null;
}
function parseCentralDirectory(bytes: ArrayBuffer): CentralEntry[] {
const view = new DataView(bytes);
const decoder = new TextDecoder();
const entries: CentralEntry[] = [];
let cursor = 0;
while (cursor + CENTRAL_HEADER_SIZE <= view.byteLength) {
if (view.getUint32(cursor, true) !== CENTRAL_HEADER_SIGNATURE) break;
const nameLength = view.getUint16(cursor + 28, true);
const extraLength = view.getUint16(cursor + 30, true);
const commentLength = view.getUint16(cursor + 32, true);
entries.push({
name: decoder.decode(
new Uint8Array(bytes, cursor + CENTRAL_HEADER_SIZE, nameLength),
),
method: view.getUint16(cursor + 10, true),
compressedSize: view.getUint32(cursor + 20, true),
localHeaderOffset: view.getUint32(cursor + 42, true),
});
cursor += CENTRAL_HEADER_SIZE + nameLength + extraLength + commentLength;
}
return entries;
}
/**
* The local header carries its own extra field, whose length can differ from the central copy, so
* the payload offset has to be read from the local header rather than assumed.
*/
async function dataOffset(blob: Blob, entry: CentralEntry): Promise<number> {
const header = new DataView(
await blob
.slice(
entry.localHeaderOffset,
entry.localHeaderOffset + LOCAL_HEADER_SIZE,
)
.arrayBuffer(),
);
return (
entry.localHeaderOffset +
LOCAL_HEADER_SIZE +
header.getUint16(26, true) +
header.getUint16(28, true)
);
}
async function inflateRaw(part: Blob): Promise<ArrayBuffer> {
const stream = part
.stream()
.pipeThrough(new DecompressionStream("deflate-raw"));
return new Response(stream).arrayBuffer();
}
/** A stored entry is returned as a view, so the PDF is never copied. */
async function readEntry(
blob: Blob,
entry: CentralEntry,
type: string,
): Promise<Blob> {
const start = await dataOffset(blob, entry);
const part = blob.slice(start, start + entry.compressedSize, type);
if (entry.method === METHOD_STORED) return part;
return new Blob([await readEntryBytes(blob, entry)], { type });
}
async function readEntryBytes(
blob: Blob,
entry: CentralEntry,
): Promise<ArrayBuffer> {
const start = await dataOffset(blob, entry);
const part = blob.slice(start, start + entry.compressedSize);
if (entry.method === METHOD_STORED) return part.arrayBuffer();
if (entry.method !== METHOD_DEFLATED) {
throw new Error(`Unsupported ZIP compression method ${entry.method}`);
}
return inflateRaw(part);
}
/**
* Null means the body is a plain PDF, so the caller keeps it and fetches fields separately. A body
* that is an archive but unreadable throws: it is not the document, and saving it would destroy one.
*/
export async function readFieldBundle(blob: Blob): Promise<FieldBundle | null> {
if (!(await looksZipped(blob))) return null;
const eocd = await readEocd(blob);
if (!eocd) {
throw new Error("Edited PDF bundle is a truncated or corrupt archive");
}
const directory = parseCentralDirectory(
await blob.slice(eocd.offset, eocd.offset + eocd.size).arrayBuffer(),
);
const documentEntry = directory.find(
(entry) => entry.name === DOCUMENT_ENTRY,
);
const fieldsEntry = directory.find((entry) => entry.name === FIELDS_ENTRY);
if (!documentEntry || !fieldsEntry) {
throw new Error(
`Edited PDF bundle is missing ${DOCUMENT_ENTRY} or ${FIELDS_ENTRY}`,
);
}
const [pdf, fields] = await Promise.all([
readEntry(blob, documentEntry, "application/pdf"),
readEntryBytes(blob, fieldsEntry),
]);
const parsed: unknown = JSON.parse(new TextDecoder().decode(fields));
if (!Array.isArray(parsed)) {
throw new Error(`Edited PDF bundle's ${FIELDS_ENTRY} is not a list`);
}
return { pdf, fields: parsed as FormField[] };
}
@@ -2,7 +2,18 @@
* API service for form-related backend calls.
*/
import apiClient from "@app/services/apiClient";
import type { FormField } from "@app/tools/formFill/types";
import {
readFieldBundle,
supportsFieldBundle,
} from "@app/tools/formFill/fieldBundle";
import type {
FormField,
NewFieldDefinition,
ModifyFieldDefinition,
FieldEditBatch,
FieldEditResult,
SkippedFieldEdit,
} from "@app/tools/formFill/types";
/**
* Fetch form fields with coordinates from the backend.
@@ -89,3 +100,116 @@ export async function extractFormFieldsXlsx(
});
return response.data;
}
/** POST /api/v1/form/add-fields: create fields, returns the updated PDF blob. */
export async function addFormFields(
file: File | Blob,
fields: NewFieldDefinition[],
): Promise<Blob> {
const formData = new FormData();
formData.append("file", file);
formData.append(
"fields",
new Blob([JSON.stringify(fields)], { type: "application/json" }),
);
const response = await apiClient.post("/api/v1/form/add-fields", formData, {
responseType: "blob",
});
return response.data;
}
/** POST /api/v1/form/modify-fields: rename/retype/move/resize, returns the updated PDF blob. */
export async function modifyFormFields(
file: File | Blob,
updates: ModifyFieldDefinition[],
): Promise<Blob> {
const formData = new FormData();
formData.append("file", file);
formData.append(
"updates",
new Blob([JSON.stringify(updates)], { type: "application/json" }),
);
const response = await apiClient.post(
"/api/v1/form/modify-fields",
formData,
{ responseType: "blob" },
);
return response.data;
}
/**
* POST /api/v1/form/edit-fields: add + modify + delete in one request. Asks for the field list in
* the same response where the browser can unpack it, which saves uploading the result back again.
*/
export async function applyFieldEdits(
file: File | Blob,
batch: FieldEditBatch,
): Promise<FieldEditResult> {
const formData = new FormData();
formData.append("file", file);
formData.append(
"edits",
new Blob([JSON.stringify(batch)], { type: "application/json" }),
);
// Off for now. Skipping the follow-up fetch changes when the viewer settles its page scale:
// the page renders at ~1.5x and then stops responding to zoom. The reader and the endpoint are
// both tested and ready; this flips back on once that interaction is understood.
const BUNDLE_ENABLED = false;
const includeFields = BUNDLE_ENABLED && supportsFieldBundle();
const response = await apiClient.post("/api/v1/form/edit-fields", formData, {
responseType: "blob",
params: includeFields ? { includeFields: true } : undefined,
});
const result: FieldEditResult = {
blob: response.data,
skipped: parseSkippedEdits(response.headers?.[SKIPPED_EDITS_HEADER]),
skippedTotal: Number(response.headers?.[SKIPPED_EDITS_TOTAL_HEADER]) || 0,
};
if (!includeFields) return result;
// Null only when the backend ignored the flag and sent a bare PDF; an unreadable archive throws
// rather than letting the caller save the archive over the user's document.
const bundle = await readFieldBundle(response.data);
return bundle
? { ...result, blob: bundle.pdf, fields: bundle.fields }
: result;
}
/** Set by the backend when it could not apply every requested edit. */
const SKIPPED_EDITS_HEADER = "x-stirling-skipped-field-edits";
const SKIPPED_EDITS_TOTAL_HEADER = "x-stirling-skipped-field-edits-total";
/** Base64 JSON, so a reason containing spaces or non-ASCII survives the header intact. */
function parseSkippedEdits(raw: unknown): SkippedFieldEdit[] {
if (typeof raw !== "string" || !raw) return [];
try {
const bytes = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
return Array.isArray(parsed) ? (parsed as SkippedFieldEdit[]) : [];
} catch {
return [];
}
}
/** POST /api/v1/form/delete-fields: delete by name, returns the updated PDF blob. */
export async function deleteFormFields(
file: File | Blob,
names: string[],
): Promise<Blob> {
const formData = new FormData();
formData.append("file", file);
formData.append(
"names",
new Blob([JSON.stringify(names)], { type: "application/json" }),
);
const response = await apiClient.post(
"/api/v1/form/delete-fields",
formData,
{ responseType: "blob" },
);
return response.data;
}
@@ -0,0 +1,187 @@
/**
* Three spaces: page pixels (top-left), WidgetCoordinates points (top-left, CropBox-relative),
* and add/modify-fields points (lower-left, CropBox-relative). pageHeightPts is CropBox height.
*/
import type {
FormField,
ModifyFieldDefinition,
} from "@app/tools/formFill/types";
export interface PixelRect {
left: number;
top: number;
width: number;
height: number;
}
/** CropBox-relative, lower-left-origin PDF points (backend create/modify space). */
export interface PdfRect {
x: number;
y: number;
width: number;
height: number;
}
/** Top-left-origin PDF points, as stored on a WidgetCoordinates. */
export interface WidgetRect {
x: number;
y: number;
width: number;
height: number;
}
/** Convert a widget's top-left-origin point rect to pixel space for rendering. */
export function widgetRectToPixels(
widget: WidgetRect,
scaleX: number,
scaleY: number,
): PixelRect {
return {
left: widget.x * scaleX,
top: widget.y * scaleY,
width: widget.width * scaleX,
height: widget.height * scaleY,
};
}
/** Pixel rect (top-left) to backend PDF points (lower-left, CropBox-relative). */
export function pixelsToBackendRect(
rect: PixelRect,
scaleX: number,
scaleY: number,
pageHeightPts: number,
): PdfRect {
const xPts = rect.left / scaleX;
const widthPts = rect.width / scaleX;
const heightPts = rect.height / scaleY;
const topPts = rect.top / scaleY; // distance from page top, in points
// Flip to lower-left origin: y measures page bottom to the field's bottom edge.
const yPts = pageHeightPts - topPts - heightPts;
return { x: xPts, y: yPts, width: widthPts, height: heightPts };
}
/** Backend PDF points (lower-left, CropBox-relative) to a pixel rect (top-left). */
export function backendRectToPixels(
rect: PdfRect,
scaleX: number,
scaleY: number,
pageHeightPts: number,
): PixelRect {
const topPts = pageHeightPts - rect.y - rect.height;
return {
left: rect.x * scaleX,
top: topPts * scaleY,
width: rect.width * scaleX,
height: rect.height * scaleY,
};
}
/** Clamp a pixel rect so it stays within the page bounds. */
export function clampPixelRect(
rect: PixelRect,
pageWidthPx: number,
pageHeightPx: number,
): PixelRect {
const width = Math.min(rect.width, pageWidthPx);
const height = Math.min(rect.height, pageHeightPx);
const left = Math.max(0, Math.min(rect.left, pageWidthPx - width));
const top = Math.max(0, Math.min(rect.top, pageHeightPx - height));
return { left, top, width, height };
}
/** Round a PdfRect's components to a sane precision before sending to the API. */
export function roundPdfRect(rect: PdfRect): PdfRect {
const r = (n: number) => Math.round(n * 100) / 100;
return {
x: r(rect.x),
y: r(rect.y),
width: r(rect.width),
height: r(rect.height),
};
}
/**
* A field with its staged edit applied, back in the top-left widget space the extractor produces.
* Mirrors updateWidgetGeometry: the delta moves every widget on the anchor page, but only the
* first widget takes the new size.
*/
export function applyStagedGeometry(
field: FormField,
staged: ModifyFieldDefinition,
): FormField {
const next: FormField = {
...field,
type: staged.type ?? field.type,
options: staged.options ?? field.options,
readOnly: staged.readOnly ?? field.readOnly,
multiline: staged.multiline ?? field.multiline,
};
const widgets = field.widgets;
const anchor = widgets?.[0];
if (
!widgets ||
!anchor ||
staged.x == null ||
staged.y == null ||
staged.width == null ||
staged.height == null ||
anchor.cropBoxHeight == null
) {
return next;
}
const top = anchor.cropBoxHeight - staged.y - staged.height;
const dx = staged.x - anchor.x;
const dy = top - anchor.y;
next.widgets = widgets.map((w, i) =>
w.pageIndex === anchor.pageIndex
? {
...w,
x: w.x + dx,
y: w.y + dy,
width: i === 0 ? staged.width! : w.width,
height: i === 0 ? staged.height! : w.height,
}
: w,
);
return next;
}
/**
* Per-option rects inside the group box, in the box's own units. Mirrors FormUtils.
* radioOptionRects exactly - if these two drift, the preview stops matching the applied PDF.
*/
export function radioOptionRects(
box: { width: number; height: number },
count: number,
gapOverride?: number | null,
sizeOverride?: number | null,
): { top: number; size: number }[] {
const n = Math.max(1, count);
const h = box.height;
const slot = h / n;
let size: number;
if (sizeOverride != null && sizeOverride > 0) {
size = sizeOverride;
} else if (gapOverride != null && gapOverride >= 0) {
size = (h - (n - 1) * gapOverride) / n;
} else {
size = slot * 0.75;
}
size = Math.max(1, Math.min(size, box.width));
const gap =
gapOverride != null && gapOverride >= 0
? gapOverride
: n > 1
? Math.max(0, (h - n * size) / (n - 1))
: 0;
return Array.from({ length: n }, (_, i) => ({
top: i * (size + gap),
size,
}));
}
@@ -0,0 +1,22 @@
/**
* Form overlay palette, deliberately not per-type: slate = existing, blue = selected/new, red =
* delete. Field type is conveyed by the side-panel icon, not by a fill colour on the page.
*/
export const FORM_COLORS = {
/** Selected / active / newly-drawn fields. */
accent: "#2563eb",
accentFillSoft: "rgba(37, 99, 235, 0.06)",
accentFill: "rgba(37, 99, 235, 0.10)",
/** Existing (unselected) fields - quiet slate so the page stays readable. */
neutralBorder: "rgba(71, 85, 105, 0.55)",
neutralFill: "rgba(71, 85, 105, 0.05)",
neutralChip: "#475569",
/** Fields marked for deletion. */
danger: "#dc2626",
dangerFill: "rgba(220, 38, 38, 0.08)",
/** Alignment guides (thin lines, shown only while dragging). */
guide: "#2563eb",
} as const;
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { mergeSignatureAppearances } from "@app/tools/formFill/formFieldMerge";
import type { FormField } from "@app/tools/formFill/types";
function field(name: string, type: FormField["type"]): FormField {
return {
name,
label: name,
type,
value: "",
options: null,
displayOptions: null,
required: false,
readOnly: false,
multiSelect: false,
multiline: false,
tooltip: null,
widgets: [{ pageIndex: 0, x: 0, y: 0, width: 10, height: 10 }],
};
}
describe("mergeSignatureAppearances", () => {
it("does not duplicate a signature the backend already returned", () => {
// The backend now returns signature fields; the pdfium pass returns the
// same field with a rendered appearance. They must merge to ONE entry.
const backend = [field("FullName", "text"), field("Sign", "signature")];
const sig = {
...field("Sign", "signature"),
appearanceDataUrl: "data:img",
};
const merged = mergeSignatureAppearances(backend, [sig]);
expect(merged).toHaveLength(2);
expect(merged.filter((f) => f.name === "Sign")).toHaveLength(1);
// …and the surviving entry is enriched with the rendered appearance.
expect(merged.find((f) => f.name === "Sign")?.appearanceDataUrl).toBe(
"data:img",
);
});
it("appends a signature the backend did not return", () => {
const backend = [field("FullName", "text")];
const sig = { ...field("Ghost", "signature"), appearanceDataUrl: "data:x" };
const merged = mergeSignatureAppearances(backend, [sig]);
expect(merged).toHaveLength(2);
expect(merged.map((f) => f.name)).toEqual(["FullName", "Ghost"]);
});
it("returns the backend list unchanged when there are no signatures", () => {
const backend = [field("A", "text"), field("B", "checkbox")];
expect(mergeSignatureAppearances(backend, [])).toBe(backend);
});
it("does not overwrite an existing appearance", () => {
const backend = [
{ ...field("Sign", "signature"), appearanceDataUrl: "keep" },
];
const sig = { ...field("Sign", "signature"), appearanceDataUrl: "new" };
const merged = mergeSignatureAppearances(backend, [sig]);
expect(merged.find((f) => f.name === "Sign")?.appearanceDataUrl).toBe(
"keep",
);
});
});
@@ -0,0 +1,30 @@
/**
* The backend returns signature fields but cannot render their appearance; PDFium rasterises it
* separately. Merge the two BY NAME, never concatenate, or the signature is listed twice.
*/
import type { FormField } from "@app/tools/formFill/types";
/** Copies each pdfium `appearanceDataUrl` onto the same-named backend field, appending unmatched ones. */
export function mergeSignatureAppearances(
backendFields: FormField[],
signatureFields: FormField[],
): FormField[] {
if (signatureFields.length === 0) return backendFields;
const merged = backendFields.map((f) => ({ ...f }));
const byName = new Map(merged.map((f) => [f.name, f]));
for (const sig of signatureFields) {
const existing = byName.get(sig.name);
if (existing) {
if (sig.appearanceDataUrl && !existing.appearanceDataUrl) {
existing.appearanceDataUrl = sig.appearanceDataUrl;
}
} else {
merged.push({ ...sig });
byName.set(sig.name, merged[merged.length - 1]);
}
}
return merged;
}
@@ -0,0 +1,13 @@
/** Hands a produced PDF blob to EmbedPdfViewer, which reloads the file preserving scroll/rotation. */
export const FORM_APPLY_EVENT = "formfill:apply";
export interface FormApplyDetail {
blob: Blob;
}
/** Dispatch a produced PDF blob to the viewer for reload + refresh. */
export function dispatchFormApply(blob: Blob): void {
window.dispatchEvent(
new CustomEvent<FormApplyDetail>(FORM_APPLY_EVENT, { detail: { blob } }),
);
}
@@ -0,0 +1,130 @@
/** Alignment snapping in page pixel space (top-left origin); matched lines come back as guides. */
import type { PixelRect } from "@app/tools/formFill/formCoordinateUtils";
export const DEFAULT_SNAP_THRESHOLD = 6;
export interface SnapGuide {
/** "v" = vertical line at x; "h" = horizontal line at y. */
orientation: "v" | "h";
/** Pixel offset of the line within the page. */
position: number;
}
export interface SnapTargets {
/** Candidate vertical lines (left/right/centre of other fields). */
xs: number[];
/** Candidate horizontal lines (top/bottom/middle of other fields). */
ys: number[];
}
/** Build snap targets from the pixel rects of the other fields on a page. */
export function collectSnapTargets(rects: PixelRect[]): SnapTargets {
const xs: number[] = [];
const ys: number[] = [];
for (const r of rects) {
xs.push(r.left, r.left + r.width, r.left + r.width / 2);
ys.push(r.top, r.top + r.height, r.top + r.height / 2);
}
return { xs, ys };
}
function nearest(
value: number,
targets: number[],
threshold: number,
): { snapped: number; delta: number } | null {
let best: { snapped: number; delta: number } | null = null;
for (const t of targets) {
const delta = t - value;
if (
Math.abs(delta) <= threshold &&
(!best || Math.abs(delta) < Math.abs(best.delta))
) {
best = { snapped: t, delta };
}
}
return best;
}
/** Snap a moving rectangle (size fixed) and report the matched guides. */
export function snapMove(
rect: PixelRect,
targets: SnapTargets,
threshold = DEFAULT_SNAP_THRESHOLD,
): { left: number; top: number; guides: SnapGuide[] } {
const guides: SnapGuide[] = [];
let { left, top } = rect;
// X axis: try left edge, right edge, then centre.
const xCandidates = [left, left + rect.width, left + rect.width / 2];
let xSnap: { snapped: number; delta: number } | null = null;
for (const c of xCandidates) {
const hit = nearest(c, targets.xs, threshold);
if (hit && (!xSnap || Math.abs(hit.delta) < Math.abs(xSnap.delta)))
xSnap = hit;
}
if (xSnap) {
left += xSnap.delta;
guides.push({ orientation: "v", position: xSnap.snapped });
}
const yCandidates = [top, top + rect.height, top + rect.height / 2];
let ySnap: { snapped: number; delta: number } | null = null;
for (const c of yCandidates) {
const hit = nearest(c, targets.ys, threshold);
if (hit && (!ySnap || Math.abs(hit.delta) < Math.abs(ySnap.delta)))
ySnap = hit;
}
if (ySnap) {
top += ySnap.delta;
guides.push({ orientation: "h", position: ySnap.snapped });
}
return { left, top, guides };
}
/** Snap a resizing rectangle: only the sides flagged in `edges` move, opposites stay put. */
export function snapResize(
rect: PixelRect,
edges: { left?: boolean; right?: boolean; top?: boolean; bottom?: boolean },
targets: SnapTargets,
threshold = DEFAULT_SNAP_THRESHOLD,
): { rect: PixelRect; guides: SnapGuide[] } {
const guides: SnapGuide[] = [];
let { left, top, width, height } = rect;
const right = left + width;
const bottom = top + height;
if (edges.left) {
const hit = nearest(left, targets.xs, threshold);
if (hit) {
left = hit.snapped;
width = right - left;
guides.push({ orientation: "v", position: hit.snapped });
}
}
if (edges.right) {
const hit = nearest(right, targets.xs, threshold);
if (hit) {
width = hit.snapped - left;
guides.push({ orientation: "v", position: hit.snapped });
}
}
if (edges.top) {
const hit = nearest(top, targets.ys, threshold);
if (hit) {
top = hit.snapped;
height = bottom - top;
guides.push({ orientation: "h", position: hit.snapped });
}
}
if (edges.bottom) {
const hit = nearest(bottom, targets.ys, threshold);
if (hit) {
height = hit.snapped - top;
guides.push({ orientation: "h", position: hit.snapped });
}
}
return { rect: { left, top, width, height }, guides };
}
@@ -0,0 +1,20 @@
/**
* A field the user has drawn but not yet applied has no PDF name, so it borrows the selection
* channel committed fields use. The prefix is readable on purpose - it reaches test ids and logs -
* and a real field would have to be named this exactly to collide.
*/
const PENDING_PREFIX = "__pending__:";
export function pendingSelectionName(id: string): string {
return PENDING_PREFIX + id;
}
export function pendingIdFrom(name: string | null | undefined): string | null {
if (!name || !name.startsWith(PENDING_PREFIX)) return null;
return name.slice(PENDING_PREFIX.length);
}
/** True for a selection that has no PDF field behind it yet. */
export function isPendingSelection(name: string | null | undefined): boolean {
return pendingIdFrom(name) != null;
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { radioOptionRects } from "@app/tools/formFill/formCoordinateUtils";
/**
* These mirror FormUtilsRadioCaptionTest on the Java side. If the two layouts drift, the create
* preview stops matching the PDF that gets written, which is the bug this rule was added to fix.
*/
describe("radioOptionRects", () => {
it("fills exactly the drawn height", () => {
const rows = radioOptionRects({ width: 100, height: 90 }, 3);
expect(rows).toHaveLength(3);
const extent = rows[2].top + rows[2].size - rows[0].top;
expect(extent).toBeCloseTo(90, 5);
expect(rows[0].top).toBe(0);
});
it("agrees with the backend for the default 3-option case", () => {
// Java: size 22.5, gap 11.25 for a 100x90 box.
const rows = radioOptionRects({ width: 100, height: 90 }, 3);
expect(rows[0].size).toBeCloseTo(22.5, 5);
expect(rows[1].top - (rows[0].top + rows[0].size)).toBeCloseTo(11.25, 5);
});
it("uses an explicit size and gap verbatim", () => {
const rows = radioOptionRects({ width: 100, height: 90 }, 3, 20, 14);
expect(rows.every((r) => r.size === 14)).toBe(true);
expect(rows[1].top - (rows[0].top + rows[0].size)).toBeCloseTo(20, 5);
});
it("never lets an option exceed the box width", () => {
const rows = radioOptionRects({ width: 8, height: 90 }, 3);
expect(rows.every((r) => r.size <= 8)).toBe(true);
});
it("handles a single option", () => {
const rows = radioOptionRects({ width: 40, height: 40 }, 1);
expect(rows).toHaveLength(1);
expect(rows[0].size).toBeLessThanOrEqual(40);
});
});
@@ -13,6 +13,8 @@ export interface WidgetCoordinates {
exportValue?: string;
/** Font size in PDF points */
fontSize?: number;
/** CropBox height in PDF points; lets the editor reverse the backend's Y-flip when sending coordinates back. */
cropBoxHeight?: number;
}
export interface FormField {
@@ -34,8 +36,16 @@ export interface FormField {
buttonLabel?: string | null;
/** Action descriptor for push buttons */
buttonAction?: ButtonAction | null;
/** Same action as the editable spec string the backend round-trips ("reset", "uri:<url>", ...) */
buttonActionSpec?: string | null;
/** Text field /MaxLen; >0 also makes it a comb field */
maxLength?: number | null;
/** Pre-rendered appearance image for signed signature fields (data URL). */
appearanceDataUrl?: string;
/** Gap between radio options in points; derived from the drawn box when unset. */
optionGap?: number;
/** Radio option size in points; derived from the drawn box when unset. */
optionSize?: number;
}
export type FormFieldType =
@@ -67,6 +77,115 @@ export interface ButtonAction {
submitFlags?: number;
}
/** Field types that can be created/edited structurally through the editor. */
export type CreatableFieldType =
| "text"
| "checkbox"
| "combobox"
| "listbox"
| "radio"
| "button"
| "signature";
export const CREATABLE_FIELD_TYPES: CreatableFieldType[] = [
"text",
"checkbox",
"combobox",
"listbox",
"radio",
"button",
"signature",
];
/**
* A new field queued for creation. Coordinates are CropBox-relative,
* lower-left-origin PDF points - the reverse of what WidgetCoordinates carries.
*/
export interface NewFieldDefinition {
name: string;
label?: string;
type: CreatableFieldType;
pageIndex: number;
x: number;
y: number;
width: number;
height: number;
required?: boolean;
multiSelect?: boolean;
options?: string[];
defaultValue?: string;
tooltip?: string;
fontSize?: number;
readOnly?: boolean;
multiline?: boolean;
maxLength?: number; // text only; >0 also makes it a comb field
/** Push-button activation action: "reset" | "print" | "uri:<url>" | "submit:<url>" */
buttonAction?: string;
/** Gap between radio options in points; derived from the drawn box when unset. */
optionGap?: number;
/** Radio option size in points; derived from the drawn box when unset. */
optionSize?: number;
}
/**
* A change to an existing field. Only non-undefined properties are applied.
* Coordinates (when present) are CropBox-relative, lower-left-origin PDF points.
*/
export interface ModifyFieldDefinition {
targetName: string;
name?: string;
label?: string;
type?: FormFieldType;
pageIndex?: number;
x?: number;
y?: number;
width?: number;
height?: number;
required?: boolean;
multiSelect?: boolean;
options?: string[];
defaultValue?: string;
tooltip?: string;
fontSize?: number;
readOnly?: boolean;
multiline?: boolean;
maxLength?: number; // text only; >0 also makes it a comb field
/** Push-button activation action: "reset" | "print" | "uri:<url>" | "submit:<url>" */
buttonAction?: string;
/** Gap between radio options in points; derived from the drawn box when unset. */
optionGap?: number;
/** Radio option size in points; derived from the drawn box when unset. */
optionSize?: number;
}
/** A batch of field edits committed in one request via /api/v1/form/edit-fields. */
export interface FieldEditBatch {
add?: NewFieldDefinition[];
modify?: ModifyFieldDefinition[];
delete?: string[];
}
/** One requested edit the document could not take. The rest of the batch still applied. */
export interface SkippedFieldEdit {
operation: "add" | "modify" | "delete";
target?: string | null;
reason?: string | null;
}
/** The updated PDF plus whatever the backend had to drop. */
export interface FieldEditResult {
blob: Blob;
/** Capped at 20 entries so the response header stays inside Jetty's budget. */
skipped: SkippedFieldEdit[];
/** How many were skipped in total, which may exceed skipped.length. */
skippedTotal: number;
/** Present when the backend bundled the field list in, saving a second upload. */
fields?: FormField[];
}
/** The form tool's working mode. */
export type FormMode = "fill" | "create" | "modify";
export interface FormFillState {
/** Fields fetched from backend with coordinates */
fields: FormField[];
@@ -0,0 +1,138 @@
/**
* Delete, copy and paste for the selected form field, in both create and modify mode.
*/
import { useEffect, useRef } from "react";
import { useFormFill } from "@app/tools/formFill/FormFillContext";
import { isTextEntryTarget } from "@app/tools/formFill/usePageScale";
import {
pendingIdFrom,
pendingSelectionName,
} from "@app/tools/formFill/pendingSelection";
import type {
CreatableFieldType,
NewFieldDefinition,
} from "@app/tools/formFill/types";
/** Enough offset that the copy is visibly its own field rather than hiding the original. */
const PASTE_OFFSET_PT = 12;
type Copied = Omit<NewFieldDefinition, "name"> & { name?: string };
export function useFieldShortcuts() {
const {
mode,
state,
selectedFieldName,
setSelectedField,
pendingFields,
addPendingField,
removePendingField,
toggleFieldDeleted,
undo,
} = useFormFill();
const clipboardRef = useRef<Copied | null>(null);
// Refs, so the listener is installed once instead of on every selection change.
const latest = useRef({
mode,
state,
selectedFieldName,
setSelectedField,
pendingFields,
addPendingField,
removePendingField,
toggleFieldDeleted,
undo,
});
latest.current = {
mode,
state,
selectedFieldName,
setSelectedField,
pendingFields,
addPendingField,
removePendingField,
toggleFieldDeleted,
undo,
};
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const ctx = latest.current;
if (ctx.mode === "fill") return;
// Never steal a shortcut from a field the user is typing in.
if (isTextEntryTarget(event.target)) return;
const selected = ctx.selectedFieldName;
const pendingId = pendingIdFrom(selected);
const copyOrPaste = event.ctrlKey || event.metaKey;
// Undo steps back through the staged edits, newest first.
if (copyOrPaste && event.key.toLowerCase() === "z" && !event.shiftKey) {
if (ctx.undo()) event.preventDefault();
return;
}
if (copyOrPaste && event.key.toLowerCase() === "c") {
if (!selected) return;
const pending = pendingId
? ctx.pendingFields.find((f) => f.id === pendingId)
: null;
if (pending) {
const { id: _id, ...rest } = pending;
clipboardRef.current = rest;
event.preventDefault();
return;
}
const field = ctx.state.fields.find((f) => f.name === selected);
const widget = field?.widgets?.[0];
if (!field || !widget) return;
clipboardRef.current = {
name: field.name,
type: field.type as CreatableFieldType,
pageIndex: widget.pageIndex,
x: widget.x,
y: widget.y,
width: widget.width,
height: widget.height,
options: field.options ?? undefined,
required: field.required,
multiline: field.multiline,
};
event.preventDefault();
return;
}
if (copyOrPaste && event.key.toLowerCase() === "v") {
const copied = clipboardRef.current;
if (!copied) return;
// The name is dropped: two fields cannot share one, and the queue names it.
const { name: _name, ...geometry } = copied;
const id = ctx.addPendingField({
...geometry,
x: geometry.x + PASTE_OFFSET_PT,
y: geometry.y - PASTE_OFFSET_PT,
});
ctx.setSelectedField(pendingSelectionName(id));
event.preventDefault();
return;
}
if (event.key === "Delete" || event.key === "Backspace") {
if (!selected) return;
if (pendingId) {
ctx.removePendingField(pendingId);
} else {
ctx.toggleFieldDeleted(selected);
}
ctx.setSelectedField(null);
event.preventDefault();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
}
@@ -0,0 +1,64 @@
/**
* Shared commit flow for the create and modify panels: run an action producing
* the edited PDF blob, hand it to the viewer, track committing/error state.
*/
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { isAxiosError } from "axios";
import { dispatchFormApply } from "@app/tools/formFill/formFillEvents";
/**
* responseType "blob" means an error's ProblemDetail arrives as a Blob, so read
* the body to surface which field the backend refused.
*/
export async function serverMessage(err: unknown): Promise<string | null> {
if (!isAxiosError(err)) return null;
const data: unknown = err.response?.data;
try {
const text = data instanceof Blob ? await data.text() : null;
const parsed: unknown = text ? JSON.parse(text) : data;
if (parsed && typeof parsed === "object") {
const body = parsed as Record<string, unknown>;
for (const key of ["detail", "message", "error", "title"]) {
if (typeof body[key] === "string" && body[key]) return body[key];
}
}
return typeof text === "string" && text.trim() ? text.trim() : null;
} catch {
return null;
}
}
export function useFormCommit(onApplied?: (blob: Blob) => void) {
const { t } = useTranslation();
const [committing, setCommitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const commit = useCallback(
async (
run: () => Promise<Blob>,
errorKey: string,
errorFallback: string,
) => {
setCommitting(true);
setError(null);
try {
const blob = await run();
dispatchFormApply(blob);
onApplied?.(blob);
} catch (err) {
setError(
(await serverMessage(err)) ||
(err instanceof Error ? err.message : undefined) ||
t(errorKey, errorFallback),
);
console.error("[FormFill] commit failed:", err);
} finally {
setCommitting(false);
}
},
[onApplied, t],
);
return { committing, error, setError, commit };
}
@@ -0,0 +1,103 @@
/**
* Overlays sit inside EmbedPDF's <Rotate>, but getBoundingClientRect reports the
* axis-aligned screen box; these cases invert plugin-rotate's getRotationMatrix.
*/
import { describe, it, expect } from "vitest";
import {
getLocalPoint,
isTextEntryTarget,
} from "@app/tools/formFill/usePageScale";
/** An element whose un-rotated box is w x h, as it appears on screen after `turns`. */
function rotatedElement(w: number, h: number, turns: number): HTMLElement {
const swapped = turns === 1 || turns === 3;
const rect = {
left: 0,
top: 0,
width: swapped ? h : w,
height: swapped ? w : h,
};
return {
getBoundingClientRect: () => rect as DOMRect,
} as unknown as HTMLElement;
}
/** Forward transform: local -> screen, straight from EmbedPDF's matrix. */
function toScreen(x: number, y: number, w: number, h: number, turns: number) {
switch (turns) {
case 1:
return { clientX: h - y, clientY: x };
case 2:
return { clientX: w - x, clientY: h - y };
case 3:
return { clientX: y, clientY: w - x };
default:
return { clientX: x, clientY: y };
}
}
describe("getLocalPoint", () => {
const W = 400;
const H = 600;
const points = [
[0, 0],
[W, 0],
[0, H],
[W, H],
[123, 456],
];
for (const turns of [0, 1, 2, 3]) {
it(`inverts EmbedPDF's transform at ${turns} quarter turn(s)`, () => {
const el = rotatedElement(W, H, turns);
for (const [x, y] of points) {
const local = getLocalPoint(toScreen(x, y, W, H, turns), el, turns);
expect(local.x).toBeCloseTo(x, 5);
expect(local.y).toBeCloseTo(y, 5);
}
});
}
it("treats rotation as quarter turns, not degrees", () => {
// EmbedPDF's Rotation enum is Degree90 = 1; 90 would normalise to 2 and flip
// the page the wrong way, so 90 and 1 must NOT agree.
const el = rotatedElement(W, H, 1);
const screen = toScreen(10, 20, W, H, 1);
expect(getLocalPoint(screen, el, 1)).not.toEqual(
getLocalPoint(screen, el, 90),
);
});
it("normalises out-of-range and negative rotations", () => {
const el = rotatedElement(W, H, 1);
const screen = toScreen(10, 20, W, H, 1);
expect(getLocalPoint(screen, el, 5)).toEqual(getLocalPoint(screen, el, 1));
expect(getLocalPoint(screen, el, -3)).toEqual(getLocalPoint(screen, el, 1));
});
it("returns the origin when the element is gone", () => {
expect(getLocalPoint({ clientX: 5, clientY: 5 }, null)).toEqual({
x: 0,
y: 0,
});
});
});
describe("isTextEntryTarget", () => {
it("claims inputs, textareas, selects and contenteditable", () => {
for (const tag of ["input", "textarea", "select"]) {
expect(isTextEntryTarget(document.createElement(tag))).toBe(true);
}
const editable = document.createElement("div");
editable.contentEditable = "true";
// jsdom does not implement isContentEditable, so assert on the real getter.
Object.defineProperty(editable, "isContentEditable", { value: true });
expect(isTextEntryTarget(editable)).toBe(true);
});
it("leaves ordinary elements and non-elements alone", () => {
expect(isTextEntryTarget(document.createElement("div"))).toBe(false);
expect(isTextEntryTarget(null)).toBe(false);
expect(isTextEntryTarget(new EventTarget())).toBe(false);
});
});
@@ -0,0 +1,92 @@
/** Shared pixel<->PDF-point helpers, on the same basis as FormFieldOverlay so the overlays agree. */
import { useMemo } from "react";
import { useDocumentState } from "@embedpdf/core/react";
export interface PageScale {
scaleX: number;
scaleY: number;
/** CropBox height in PDF points; 0 until the page has rendered. */
pageHeightPts: number;
/** CropBox width in PDF points; 0 until the page has rendered. */
pageWidthPts: number;
/** Page rotation in clockwise quarter turns (0-3), as EmbedPDF's <Rotate> applies it. */
rotation: number;
}
/** Rotation as clockwise quarter turns, matching LocalEmbedPDF's normalizePageRotation. */
function normalizeRotation(rotation: number | null | undefined): number {
const value =
typeof rotation === "number" && Number.isFinite(rotation) ? rotation : 0;
return ((Math.round(value) % 4) + 4) % 4;
}
/**
* `scaleX = pageWidthPx / pageWidthPts`, from EmbedPDF's document state.
* pageWidthPts is 0 until the page has rendered, so guard on it before drawing.
*/
export function usePageScale(
documentId: string,
pageIndex: number,
pageWidth: number,
pageHeight: number,
): PageScale {
const documentState = useDocumentState(documentId);
return useMemo(() => {
const pdfPage = documentState?.document?.pages?.[pageIndex];
// Must match EmbedPDF's <Rotate>, which composes the page's own rotation
// with the viewer-level one; using only the page's is wrong once rotated.
const rotation = normalizeRotation(
(pdfPage?.rotation ?? 0) + (documentState?.rotation ?? 0),
);
if (!pdfPage?.size || !pageWidth || !pageHeight) {
const s = documentState?.scale ?? 1;
return {
scaleX: s,
scaleY: s,
pageHeightPts: 0,
pageWidthPts: 0,
rotation,
};
}
return {
scaleX: pageWidth / pdfPage.size.width,
scaleY: pageHeight / pdfPage.size.height,
pageHeightPts: pdfPage.size.height,
pageWidthPts: pdfPage.size.width,
rotation,
};
}, [documentState, pageIndex, pageWidth, pageHeight]);
}
/**
* Pointer position in the element's own un-rotated pixel space. getBoundingClientRect
* returns the axis-aligned screen box, so under <Rotate> it must be mapped back.
*/
export function getLocalPoint(
e: { clientX: number; clientY: number },
el: HTMLElement | null,
rotation: number = 0,
): { x: number; y: number } {
const rect = el?.getBoundingClientRect();
if (!rect) return { x: 0, y: 0 };
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
switch (normalizeRotation(rotation)) {
case 1:
return { x: sy, y: rect.width - sx };
case 2:
return { x: rect.width - sx, y: rect.height - sy };
case 3:
return { x: rect.height - sy, y: sx };
default:
return { x: sx, y: sy };
}
}
/** True when a key event targets somewhere the user is typing, so shortcuts must stand down. */
export function isTextEntryTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
}
@@ -28,7 +28,7 @@ class AITranslationHelper:
try:
with open(file_path, "rb") as f:
return tomllib.load(f)
except (FileNotFoundError, Exception) as e:
except (FileNotFoundError, Exception) as e: # noqa: BLE001
print(f"Error loading {file_path}: {e}")
return {}
@@ -52,7 +52,7 @@ class AITranslationHelper:
"target_languages": languages,
"max_entries_per_language": max_entries_per_language,
"instructions": {
"format": "Translate each entry maintaining JSON structure and placeholder variables like {n}, {total}, {filename}",
"format": "Translate each entry maintaining JSON structure and placeholder variables like {n}, {total}, {filename}", # noqa: E501
"context": "This is for a PDF manipulation tool. Keep technical terms consistent.",
"placeholders": "Preserve all placeholders: {n}, {total}, {filename}, etc.",
"style": "Keep translations concise and user-friendly",
+4 -4
View File
@@ -10,9 +10,9 @@ import json
import os
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
import time
import tomllib
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
@@ -152,7 +152,7 @@ def translate_batches(batch_files, language_code, api_key, timeout=600, model="g
print(f"\n[{i}/{total}] Translating {batch_file}...")
# Always pass API key since it's required
cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}" --model {model}'
cmd = f'python3 scripts/translations/batch_translator.py "{batch_file}" --language {language_code} --api-key "{api_key}" --model {model}' # noqa: E501
try:
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
@@ -223,7 +223,7 @@ def apply_translations(merged_file, language_code):
"""Apply merged translations to the language file."""
print(f"\n📝 Applying translations to {language_code}...")
cmd = f"python3 scripts/translations/translation_merger.py {language_code} apply-translations --translations-file {merged_file}"
cmd = f"python3 scripts/translations/translation_merger.py {language_code} apply-translations --translations-file {merged_file}" # noqa: E501
if not run_command(cmd):
print("✗ Failed to apply translations")
@@ -388,7 +388,7 @@ Examples:
except KeyboardInterrupt:
print("\n\n⚠ Translation interrupted by user")
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"\n\n✗ Error: {e}")
import traceback
+3 -3
View File
@@ -98,7 +98,7 @@ CRITICAL RULES - MUST FOLLOW EXACTLY:
- Do not remove any part of the original meaning
- Keep the same level of detail
Return ONLY the translated JSON. No markdown, no explanations, just the JSON object."""
Return ONLY the translated JSON. No markdown, no explanations, just the JSON object.""" # noqa: E501
def _record_usage(self, response) -> None:
"""Accumulate token usage/cost and print a per-batch line."""
@@ -182,7 +182,7 @@ Return ONLY the translated JSON. No markdown, no explanations, just the JSON obj
placeholder_pattern = r"\{[^}]+\}|\{\{[^}]+\}\}"
for key in original.keys():
for key in original:
if key not in translated:
continue
@@ -366,7 +366,7 @@ Examples:
if i < len(input_files):
time.sleep(args.delay)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"✗ Failed: {e}")
failed += 1
continue
+3 -3
View File
@@ -79,7 +79,7 @@ def get_language_completion(locales_dir: Path, language: str) -> float | None:
return (translated / total * 100) if total > 0 else 0.0
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Warning: Could not calculate completion for {language}: {e}")
return None
@@ -144,8 +144,8 @@ def translate_language(
except subprocess.TimeoutExpired:
safe_print(f"[{language}] ✗ Timeout exceeded")
return (language, False, "Timeout exceeded")
except Exception as e:
safe_print(f"[{language}] ✗ Error: {str(e)}")
except Exception as e: # noqa: BLE001
safe_print(f"[{language}] ✗ Error: {e!s}")
return (language, False, str(e))
+2 -2
View File
@@ -38,7 +38,7 @@ class CompactTranslationExtractor:
except FileNotFoundError:
print(f"Error: File not found: {file_path}", file=sys.stderr)
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Error: Invalid TOML file {file_path}: {e}", file=sys.stderr)
sys.exit(1)
@@ -51,7 +51,7 @@ class CompactTranslationExtractor:
with open(self.ignore_file, "rb") as f:
ignore_data = tomllib.load(f)
return {lang: set(data.get("ignore", [])) for lang, data in ignore_data.items()}
except Exception as e:
except Exception as e: # noqa: BLE001
print(
f"Warning: Could not load ignore file {self.ignore_file}: {e}",
file=sys.stderr,
+2 -2
View File
@@ -28,7 +28,7 @@ class TOMLBeautifier:
except FileNotFoundError:
print(f"Error: File not found: {file_path}")
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Error: Invalid TOML in {file_path}: {e}")
sys.exit(1)
@@ -172,7 +172,7 @@ class TOMLBeautifier:
def get_key_order(obj: dict, path: str = "") -> list[str]:
keys = []
for key in obj.keys():
for key in obj:
new_path = f"{path}.{key}" if path else key
keys.append(new_path)
if isinstance(obj[key], dict):
+3 -3
View File
@@ -33,7 +33,7 @@ def get_line_context(file_path, line_num, context_lines=3):
context.append(f"{marker}{i + 1:4d}: {lines[i].rstrip()}")
return "\n".join(context)
except Exception as e:
except Exception as e: # noqa: BLE001
return f"Could not read context: {e}"
@@ -56,7 +56,7 @@ def get_character_context(file_path, char_pos, context_chars=100):
"after": after,
"display": f"{before}[{error_char}]{after}",
}
except Exception:
except Exception: # noqa: BLE001
return None
@@ -90,7 +90,7 @@ def validate_toml_file(file_path):
result["valid"] = True
result["entry_count"] = count_keys(data)
except Exception as e:
except Exception as e: # noqa: BLE001
error_msg = str(e)
result["error"] = error_msg
+3 -3
View File
@@ -31,7 +31,7 @@ class TranslationAnalyzer:
except FileNotFoundError:
print(f"Error: File not found: {file_path}")
sys.exit(1)
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Error: Invalid file {file_path}: {e}")
sys.exit(1)
@@ -51,7 +51,7 @@ class TranslationAnalyzer:
for patterns in [data.get("ignore", [])]
if patterns
}
except Exception as e:
except Exception as e: # noqa: BLE001
print(f"Warning: Could not load ignore file {self.ignore_file}: {e}")
return {}
@@ -282,7 +282,7 @@ def main():
print("\nBottom 5 Languages Needing Attention:")
for result in sorted_by_completion[-5:]:
print(
f" {result['language']}: {result['completion_rate']:.1f}% ({result['missing_count']} missing, {result['untranslated_count']} untranslated)"
f" {result['language']}: {result['completion_rate']:.1f}% ({result['missing_count']} missing, {result['untranslated_count']} untranslated)" # noqa: E501
)

Some files were not shown because too many files have changed in this diff Show More