mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11aef9eb7a | ||
|
|
79686a3a09 | ||
|
|
193a5231a0 | ||
|
|
5efefa51d7 | ||
|
|
e4bb1b4b9f | ||
|
|
444f6ea01f | ||
|
|
580ad97321 | ||
|
|
639e3652b0 | ||
|
|
b39df8ca14 | ||
|
|
84cb0d8725 | ||
|
|
d0223008f6 | ||
|
|
da0ed4f7b2 | ||
|
|
1e3f2312ee | ||
|
|
27868fbe1c | ||
|
|
e009ac3150 | ||
|
|
06c6bec7ce |
@@ -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:")
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
+116
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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
|
||||
|
||||
+911
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -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");
|
||||
}
|
||||
}
|
||||
+57
@@ -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);
|
||||
}
|
||||
|
||||
+285
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -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,
|
||||
)
|
||||
|
||||
+374
@@ -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);
|
||||
}
|
||||
}
|
||||
+307
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
@@ -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
|
||||
|
||||
+3
-1
@@ -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();
|
||||
|
||||
@@ -46,3 +46,7 @@ logs/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Coverage
|
||||
.coverage
|
||||
coverage/
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
Generated
+75
-25
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
Binary file not shown.
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user