Compare commits
No commits in common. "main" and "luna-v1-rescue" have entirely different histories.
main
...
luna-v1-re
21 changed files with 157 additions and 1657 deletions
|
|
@ -51,6 +51,8 @@ jobs:
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
@ -92,67 +94,38 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
docker rm --force paperjet-ci >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
docker run \
|
docker run \
|
||||||
--detach \
|
--detach \
|
||||||
--name paperjet-ci \
|
--name paperjet-ci \
|
||||||
|
--publish 18080:80 \
|
||||||
--env PAPERJET_SECRET_KEY=ci-only-secret \
|
--env PAPERJET_SECRET_KEY=ci-only-secret \
|
||||||
--env PAPERJET_COOKIE_SECURE=false \
|
--env PAPERJET_COOKIE_SECURE=false \
|
||||||
--env PAPERJET_DEBUG=false \
|
--env PAPERJET_DEBUG=false \
|
||||||
paperjet:ci
|
paperjet:ci
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo "----- PaperJet container logs -----"
|
|
||||||
docker logs paperjet-ci || true
|
|
||||||
docker rm --force paperjet-ci >/dev/null 2>&1 || true
|
docker rm --force paperjet-ci >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
for attempt in $(seq 1 30); do
|
curl --fail --retry 30 --retry-delay 1 --retry-connrefused \
|
||||||
running=$(docker inspect --format '{{.State.Running}}' paperjet-ci 2>/dev/null || true)
|
http://127.0.0.1:18080/
|
||||||
if [ "$running" != "true" ]; then
|
curl --fail --retry 30 --retry-delay 1 --retry-connrefused \
|
||||||
echo "PaperJet container exited before becoming ready"
|
http://127.0.0.1:18080/api/v1/health
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if docker exec paperjet-ci \
|
|
||||||
python -c "import urllib.request; response = urllib.request.urlopen('http://127.0.0.1/', timeout=2); assert response.status == 200" \
|
|
||||||
>/dev/null 2>&1 \
|
|
||||||
&& docker exec paperjet-ci \
|
|
||||||
python -c "import urllib.request; response = urllib.request.urlopen('http://127.0.0.1/api/v1/health', timeout=2); assert response.status == 200" \
|
|
||||||
>/dev/null 2>&1; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
running=$(docker inspect --format '{{.State.Running}}' paperjet-ci)
|
|
||||||
if [ "$running" != "true" ]; then
|
|
||||||
echo "PaperJet container did not become ready within 30 seconds"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker exec paperjet-ci \
|
|
||||||
python -c "import urllib.request; response = urllib.request.urlopen('http://127.0.0.1/', timeout=2); assert response.status == 200"
|
|
||||||
docker exec paperjet-ci \
|
|
||||||
python -c "import urllib.request; response = urllib.request.urlopen('http://127.0.0.1/api/v1/health', timeout=2); assert response.status == 200"
|
|
||||||
|
|
||||||
for attempt in $(seq 1 30); do
|
for attempt in $(seq 1 30); do
|
||||||
status=$(docker inspect --format '{{.State.Health.Status}}' paperjet-ci)
|
status=$(docker inspect --format '{{.State.Health.Status}}' paperjet-ci)
|
||||||
if [ "$status" = "healthy" ]; then
|
if [ "$status" = "healthy" ]; then
|
||||||
echo "PaperJet smoke test passed"
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ "$status" = "unhealthy" ]; then
|
if [ "$status" = "unhealthy" ]; then
|
||||||
echo "PaperJet container healthcheck reported unhealthy"
|
docker logs paperjet-ci
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "PaperJet container did not become healthy within 30 seconds"
|
docker logs paperjet-ci
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Log into Local Registry
|
- name: Log into Local Registry
|
||||||
|
|
|
||||||
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
docker-entrypoint.sh text eol=lf
|
|
||||||
*.sh text eol=lf
|
|
||||||
18
Dockerfile
18
Dockerfile
|
|
@ -12,23 +12,6 @@ RUN npm ci --legacy-peer-deps
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# PyMuPDF embeds TrueType/OpenType fonts, while the browser packages ship
|
|
||||||
# WOFF2 files. Convert the exact browser assets so exported text and typed
|
|
||||||
# signatures use the same glyph metrics as the Fabric preview.
|
|
||||||
FROM python:3.12-slim AS frontend-fonts
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir fonttools brotli
|
|
||||||
|
|
||||||
COPY --from=frontend-build /frontend/node_modules/@fontsource/outfit /fontsource/outfit
|
|
||||||
COPY --from=frontend-build /frontend/node_modules/@fontsource/plus-jakarta-sans /fontsource/plus-jakarta-sans
|
|
||||||
COPY --from=frontend-build /frontend/node_modules/@fontsource/allura /fontsource/allura
|
|
||||||
COPY --from=frontend-build /frontend/node_modules/@fontsource/caveat /fontsource/caveat
|
|
||||||
COPY --from=frontend-build /frontend/node_modules/@fontsource/dancing-script /fontsource/dancing-script
|
|
||||||
COPY --from=frontend-build /frontend/node_modules/@fontsource/great-vibes /fontsource/great-vibes
|
|
||||||
COPY --from=frontend-build /frontend/node_modules/@fontsource/sacramento /fontsource/sacramento
|
|
||||||
COPY scripts/convert_frontend_fonts.py /usr/local/bin/convert-frontend-fonts.py
|
|
||||||
RUN python /usr/local/bin/convert-frontend-fonts.py /fontsource /web-fonts
|
|
||||||
|
|
||||||
# The runtime image contains both public-facing nginx and the loopback-only
|
# The runtime image contains both public-facing nginx and the loopback-only
|
||||||
# FastAPI/uvicorn process. This keeps deployment to one container while
|
# FastAPI/uvicorn process. This keeps deployment to one container while
|
||||||
# preserving the existing nginx -> API boundary.
|
# preserving the existing nginx -> API boundary.
|
||||||
|
|
@ -57,7 +40,6 @@ COPY backend/app ./app
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
COPY --from=frontend-build /frontend/dist /usr/share/nginx/html
|
COPY --from=frontend-build /frontend/dist /usr/share/nginx/html
|
||||||
COPY --from=frontend-fonts /web-fonts /app/app/assets/fonts/web
|
|
||||||
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/paperjet-entrypoint
|
COPY docker-entrypoint.sh /usr/local/bin/paperjet-entrypoint
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ class DrawProps(BaseModel):
|
||||||
svgPath: str | None = None
|
svgPath: str | None = None
|
||||||
strokeColor: str = "#000000"
|
strokeColor: str = "#000000"
|
||||||
strokeWidth: float = 2
|
strokeWidth: float = 2
|
||||||
strokeWidthUnit: Literal["pdf", "screen"] | None = None
|
|
||||||
opacity: float = 1.0
|
opacity: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,7 +65,6 @@ class SignatureTypeProps(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
fontFamily: str
|
fontFamily: str
|
||||||
color: str = "#000000"
|
color: str = "#000000"
|
||||||
fontSize: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SignatureAnnotation(AnnotationBase):
|
class SignatureAnnotation(AnnotationBase):
|
||||||
|
|
@ -100,7 +98,6 @@ class ShapeProps(BaseModel):
|
||||||
strokeColor: str = "#000000"
|
strokeColor: str = "#000000"
|
||||||
fillColor: str = "transparent"
|
fillColor: str = "transparent"
|
||||||
strokeWidth: float = 2
|
strokeWidth: float = 2
|
||||||
strokeWidthUnit: Literal["pdf", "screen"] | None = None
|
|
||||||
start: tuple[float, float] | None = None
|
start: tuple[float, float] | None = None
|
||||||
end: tuple[float, float] | None = None
|
end: tuple[float, float] | None = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable, Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
|
@ -89,148 +88,17 @@ _SIGNATURE_FONT_FILES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_WEB_FONT_FILES: dict[tuple[str, bool, bool], tuple[str, ...]] = {
|
|
||||||
("outfit", False, False): ("outfit-400.ttf",),
|
|
||||||
("outfit", True, False): ("outfit-700.ttf", "outfit-400.ttf"),
|
|
||||||
("plus jakarta sans", False, False): ("plus-jakarta-sans-400.ttf",),
|
|
||||||
("plus jakarta sans", True, False): ("plus-jakarta-sans-700.ttf", "plus-jakarta-sans-400.ttf"),
|
|
||||||
("plus jakarta sans", False, True): (
|
|
||||||
"plus-jakarta-sans-400-italic.ttf",
|
|
||||||
"plus-jakarta-sans-400.ttf",
|
|
||||||
),
|
|
||||||
("plus jakarta sans", True, True): (
|
|
||||||
"plus-jakarta-sans-700-italic.ttf",
|
|
||||||
"plus-jakarta-sans-700.ttf",
|
|
||||||
),
|
|
||||||
("great vibes", False, False): ("great-vibes-400.ttf", "GreatVibes-Regular.ttf"),
|
|
||||||
("allura", False, False): ("allura-400.ttf", "allura.ttf"),
|
|
||||||
("sacramento", False, False): ("sacramento-400.ttf", "sacramento.ttf"),
|
|
||||||
("dancing script", False, False): ("dancing-script-400.ttf", "dancing-script.ttf"),
|
|
||||||
("caveat", False, False): ("caveat-400.ttf", "caveat.ttf"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_SYSTEM_FONT_FAMILIES = {
|
|
||||||
"arial": "LiberationSans",
|
|
||||||
"helvetica": "LiberationSans",
|
|
||||||
"liberation sans": "LiberationSans",
|
|
||||||
"courier": "LiberationMono",
|
|
||||||
"courier new": "LiberationMono",
|
|
||||||
"liberation mono": "LiberationMono",
|
|
||||||
"mono": "LiberationMono",
|
|
||||||
"serif": "LiberationSerif",
|
|
||||||
"times": "LiberationSerif",
|
|
||||||
"times new roman": "LiberationSerif",
|
|
||||||
"liberation serif": "LiberationSerif",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _is_bold(value: Any) -> bool:
|
|
||||||
return value is True or str(value).strip().lower() in {"bold", "700", "800", "900"}
|
|
||||||
|
|
||||||
|
|
||||||
def _is_italic(value: Any) -> bool:
|
|
||||||
return value is True or str(value).strip().lower() in {"italic", "oblique"}
|
|
||||||
|
|
||||||
|
|
||||||
def _variant_filename(stem: str, bold: bool, italic: bool) -> str:
|
|
||||||
if bold and italic:
|
|
||||||
suffix = "-BoldItalic"
|
|
||||||
elif bold:
|
|
||||||
suffix = "-Bold"
|
|
||||||
elif italic:
|
|
||||||
suffix = "-Italic"
|
|
||||||
else:
|
|
||||||
suffix = "-Regular"
|
|
||||||
return f"{stem}{suffix}.ttf"
|
|
||||||
|
|
||||||
|
|
||||||
def _font_roots() -> tuple[Path, ...]:
|
|
||||||
source_root = Path(__file__).parents[2] / "assets" / "fonts"
|
|
||||||
configured_root = os.environ.get("PAPERJET_FONT_DIR")
|
|
||||||
roots = [Path(configured_root)] if configured_root else []
|
|
||||||
roots.extend(
|
|
||||||
[
|
|
||||||
source_root / "web",
|
|
||||||
source_root,
|
|
||||||
Path("/usr/share/fonts/truetype/liberation"),
|
|
||||||
Path("/usr/local/share/fonts"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return tuple(dict.fromkeys(roots))
|
|
||||||
|
|
||||||
|
|
||||||
def _font_file(family: Any, bold: Any = False, italic: Any = False) -> Path | None:
|
|
||||||
normalized = str(family or "").strip().lower()
|
|
||||||
bold_flag = _is_bold(bold)
|
|
||||||
italic_flag = _is_italic(italic)
|
|
||||||
key = (normalized, bold_flag, italic_flag)
|
|
||||||
filenames = _WEB_FONT_FILES.get(key)
|
|
||||||
if filenames is None and italic_flag:
|
|
||||||
filenames = _WEB_FONT_FILES.get((key[0], key[1], False))
|
|
||||||
if filenames is None:
|
|
||||||
system_stem = _SYSTEM_FONT_FAMILIES.get(normalized)
|
|
||||||
filenames = (_variant_filename(system_stem, bold_flag, italic_flag),) if system_stem else ()
|
|
||||||
for root in _font_roots():
|
|
||||||
for filename in filenames:
|
|
||||||
path = root / filename
|
|
||||||
if path.is_file():
|
|
||||||
return path
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _font_choice(family: Any, bold: Any = False, italic: Any = False) -> tuple[str, Path | None]:
|
|
||||||
font_file = _font_file(family, bold, italic)
|
|
||||||
if font_file is not None:
|
|
||||||
return f"paperjet-{font_file.stem.lower()}", font_file
|
|
||||||
return _font_name(family, _is_bold(bold), _is_italic(italic)), None
|
|
||||||
|
|
||||||
|
|
||||||
def _needs_synthetic_italic(family: Any, bold: Any = False, italic: Any = False) -> bool:
|
|
||||||
if not _is_italic(italic):
|
|
||||||
return False
|
|
||||||
normalized = str(family or "").strip().lower()
|
|
||||||
if not any(key[0] == normalized for key in _WEB_FONT_FILES):
|
|
||||||
return False
|
|
||||||
return (normalized, _is_bold(bold), True) not in _WEB_FONT_FILES
|
|
||||||
|
|
||||||
|
|
||||||
def _synthetic_italic_morph(origin: pymupdf.Point) -> tuple[pymupdf.Point, pymupdf.Matrix]:
|
|
||||||
# Match the browser's synthesized oblique for families without an italic face.
|
|
||||||
shear = math.tan(math.radians(14))
|
|
||||||
return origin, pymupdf.Matrix(1, 0, shear, 1, 0, 0)
|
|
||||||
|
|
||||||
|
|
||||||
def _signature_font_file(family: Any) -> Path | None:
|
def _signature_font_file(family: Any) -> Path | None:
|
||||||
normalized = str(family or "").strip().lower()
|
filename = _SIGNATURE_FONT_FILES.get(str(family or "").strip().lower())
|
||||||
for filename in _WEB_FONT_FILES.get((normalized, False, False), ()):
|
if not filename:
|
||||||
for root in _font_roots():
|
|
||||||
path = root / filename
|
|
||||||
if path.is_file():
|
|
||||||
return path
|
|
||||||
legacy_filename = _SIGNATURE_FONT_FILES.get(normalized)
|
|
||||||
if not legacy_filename:
|
|
||||||
return None
|
return None
|
||||||
path = Path(__file__).parents[2] / "assets" / "fonts" / legacy_filename
|
path = Path(__file__).parents[2] / "assets" / "fonts" / filename
|
||||||
return path if path.is_file() else None
|
return path if path.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
def _signature_font_size(
|
def _signature_font_size(
|
||||||
rect: pymupdf.Rect,
|
rect: pymupdf.Rect, text: str, font_file: Path | None, font_name: str
|
||||||
text: str,
|
|
||||||
font_file: Path | None,
|
|
||||||
font_name: str,
|
|
||||||
requested_size: Any = None,
|
|
||||||
) -> float:
|
) -> float:
|
||||||
if requested_size is not None:
|
|
||||||
try:
|
|
||||||
return max(1.0, float(requested_size))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return 48.0
|
|
||||||
|
|
||||||
# Legacy typed signatures did not persist a font size. Preserve their
|
|
||||||
# existing sizing behavior while new records use the preview's canonical
|
|
||||||
# size above.
|
|
||||||
size = max(1.0, min(48.0, rect.height * 0.55))
|
size = max(1.0, min(48.0, rect.height * 0.55))
|
||||||
try:
|
try:
|
||||||
font = (
|
font = (
|
||||||
|
|
@ -300,242 +168,10 @@ def _draw_polyline(
|
||||||
return
|
return
|
||||||
shape = page.new_shape()
|
shape = page.new_shape()
|
||||||
shape.draw_polyline(points)
|
shape.draw_polyline(points)
|
||||||
shape.finish(
|
shape.finish(color=color, width=max(0.1, width), stroke_opacity=opacity)
|
||||||
color=color,
|
|
||||||
width=max(0.1, width),
|
|
||||||
lineCap=1,
|
|
||||||
lineJoin=1,
|
|
||||||
closePath=False,
|
|
||||||
stroke_opacity=opacity,
|
|
||||||
)
|
|
||||||
shape.commit(overlay=True)
|
shape.commit(overlay=True)
|
||||||
|
|
||||||
|
|
||||||
_SVG_PATH_TOKEN = re.compile(r"[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
|
|
||||||
_SVG_PATH_ARITY = {"M": 2, "L": 2, "Q": 4, "C": 6}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_svg_path(value: str) -> list[tuple[str, tuple[float, ...]]]:
|
|
||||||
tokens = _SVG_PATH_TOKEN.findall(value)
|
|
||||||
commands: list[tuple[str, tuple[float, ...]]] = []
|
|
||||||
command: str | None = None
|
|
||||||
relative = False
|
|
||||||
move_pending = False
|
|
||||||
index = 0
|
|
||||||
current = (0.0, 0.0)
|
|
||||||
subpath_start = current
|
|
||||||
|
|
||||||
while index < len(tokens):
|
|
||||||
token = tokens[index]
|
|
||||||
if token.isalpha():
|
|
||||||
command = token.upper()
|
|
||||||
relative = token.islower()
|
|
||||||
index += 1
|
|
||||||
if command == "Z":
|
|
||||||
commands.append(("Z", ()))
|
|
||||||
current = subpath_start
|
|
||||||
command = None
|
|
||||||
continue
|
|
||||||
if command not in _SVG_PATH_ARITY:
|
|
||||||
return []
|
|
||||||
move_pending = command == "M"
|
|
||||||
|
|
||||||
if command is None:
|
|
||||||
return []
|
|
||||||
arity = _SVG_PATH_ARITY[command]
|
|
||||||
if index + arity > len(tokens):
|
|
||||||
return []
|
|
||||||
if any(tokens[index + offset].isalpha() for offset in range(arity)):
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
values = tuple(float(tokens[index + offset]) for offset in range(arity))
|
|
||||||
except ValueError:
|
|
||||||
return []
|
|
||||||
index += arity
|
|
||||||
|
|
||||||
base_x, base_y = current
|
|
||||||
if command in {"M", "L"}:
|
|
||||||
point = (
|
|
||||||
values[0] + (base_x if relative else 0.0),
|
|
||||||
values[1] + (base_y if relative else 0.0),
|
|
||||||
)
|
|
||||||
output_command = "M" if move_pending else "L"
|
|
||||||
commands.append((output_command, point))
|
|
||||||
current = point
|
|
||||||
if output_command == "M":
|
|
||||||
subpath_start = point
|
|
||||||
move_pending = False
|
|
||||||
command = "L" if command == "M" else command
|
|
||||||
elif command == "Q":
|
|
||||||
control = (
|
|
||||||
values[0] + (base_x if relative else 0.0),
|
|
||||||
values[1] + (base_y if relative else 0.0),
|
|
||||||
)
|
|
||||||
endpoint = (
|
|
||||||
values[2] + (base_x if relative else 0.0),
|
|
||||||
values[3] + (base_y if relative else 0.0),
|
|
||||||
)
|
|
||||||
commands.append(("Q", (*control, *endpoint)))
|
|
||||||
current = endpoint
|
|
||||||
else:
|
|
||||||
control_one = (
|
|
||||||
values[0] + (base_x if relative else 0.0),
|
|
||||||
values[1] + (base_y if relative else 0.0),
|
|
||||||
)
|
|
||||||
control_two = (
|
|
||||||
values[2] + (base_x if relative else 0.0),
|
|
||||||
values[3] + (base_y if relative else 0.0),
|
|
||||||
)
|
|
||||||
endpoint = (
|
|
||||||
values[4] + (base_x if relative else 0.0),
|
|
||||||
values[5] + (base_y if relative else 0.0),
|
|
||||||
)
|
|
||||||
commands.append(("C", (*control_one, *control_two, *endpoint)))
|
|
||||||
current = endpoint
|
|
||||||
|
|
||||||
return commands
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_svg_path(
|
|
||||||
page: pymupdf.Page,
|
|
||||||
rect: pymupdf.Rect,
|
|
||||||
svg_path: str,
|
|
||||||
*,
|
|
||||||
color: tuple[float, float, float],
|
|
||||||
width: float,
|
|
||||||
opacity: float,
|
|
||||||
) -> bool:
|
|
||||||
parsed = _parse_svg_path(svg_path)
|
|
||||||
if not parsed:
|
|
||||||
return False
|
|
||||||
|
|
||||||
segments: list[tuple[str, tuple[tuple[float, float], ...]]] = []
|
|
||||||
samples: list[tuple[float, float]] = []
|
|
||||||
current = (0.0, 0.0)
|
|
||||||
subpath_start = current
|
|
||||||
|
|
||||||
def add_line(start: tuple[float, float], end: tuple[float, float]) -> None:
|
|
||||||
segments.append(("L", (start, end)))
|
|
||||||
samples.extend((start, end))
|
|
||||||
|
|
||||||
def add_quadratic(
|
|
||||||
start: tuple[float, float],
|
|
||||||
control: tuple[float, float],
|
|
||||||
end: tuple[float, float],
|
|
||||||
) -> None:
|
|
||||||
segments.append(("Q", (start, control, end)))
|
|
||||||
for step in range(21):
|
|
||||||
t = step / 20
|
|
||||||
inverse = 1 - t
|
|
||||||
samples.append(
|
|
||||||
(
|
|
||||||
inverse * inverse * start[0]
|
|
||||||
+ 2 * inverse * t * control[0]
|
|
||||||
+ t * t * end[0],
|
|
||||||
inverse * inverse * start[1]
|
|
||||||
+ 2 * inverse * t * control[1]
|
|
||||||
+ t * t * end[1],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_cubic(
|
|
||||||
start: tuple[float, float],
|
|
||||||
control_one: tuple[float, float],
|
|
||||||
control_two: tuple[float, float],
|
|
||||||
end: tuple[float, float],
|
|
||||||
) -> None:
|
|
||||||
segments.append(("C", (start, control_one, control_two, end)))
|
|
||||||
for step in range(21):
|
|
||||||
t = step / 20
|
|
||||||
inverse = 1 - t
|
|
||||||
samples.append(
|
|
||||||
(
|
|
||||||
inverse**3 * start[0]
|
|
||||||
+ 3 * inverse**2 * t * control_one[0]
|
|
||||||
+ 3 * inverse * t**2 * control_two[0]
|
|
||||||
+ t**3 * end[0],
|
|
||||||
inverse**3 * start[1]
|
|
||||||
+ 3 * inverse**2 * t * control_one[1]
|
|
||||||
+ 3 * inverse * t**2 * control_two[1]
|
|
||||||
+ t**3 * end[1],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for command, values in parsed:
|
|
||||||
if command == "M":
|
|
||||||
current = (values[0], values[1])
|
|
||||||
subpath_start = current
|
|
||||||
samples.append(current)
|
|
||||||
elif command == "L":
|
|
||||||
endpoint = (values[0], values[1])
|
|
||||||
add_line(current, endpoint)
|
|
||||||
current = endpoint
|
|
||||||
elif command == "Q":
|
|
||||||
control = (values[0], values[1])
|
|
||||||
endpoint = (values[2], values[3])
|
|
||||||
add_quadratic(current, control, endpoint)
|
|
||||||
current = endpoint
|
|
||||||
elif command == "C":
|
|
||||||
control_one = (values[0], values[1])
|
|
||||||
control_two = (values[2], values[3])
|
|
||||||
endpoint = (values[4], values[5])
|
|
||||||
add_cubic(current, control_one, control_two, endpoint)
|
|
||||||
current = endpoint
|
|
||||||
elif command == "Z":
|
|
||||||
if current != subpath_start:
|
|
||||||
add_line(current, subpath_start)
|
|
||||||
current = subpath_start
|
|
||||||
|
|
||||||
if not segments or not samples:
|
|
||||||
return False
|
|
||||||
min_x = min(point[0] for point in samples)
|
|
||||||
max_x = max(point[0] for point in samples)
|
|
||||||
min_y = min(point[1] for point in samples)
|
|
||||||
max_y = max(point[1] for point in samples)
|
|
||||||
source_width = max(max_x - min_x, 1e-6)
|
|
||||||
source_height = max(max_y - min_y, 1e-6)
|
|
||||||
|
|
||||||
def map_point(point: tuple[float, float]) -> pymupdf.Point:
|
|
||||||
return pymupdf.Point(
|
|
||||||
rect.x0 + (point[0] - min_x) / source_width * rect.width,
|
|
||||||
rect.y0 + (point[1] - min_y) / source_height * rect.height,
|
|
||||||
)
|
|
||||||
|
|
||||||
shape = page.new_shape()
|
|
||||||
for command, points in segments:
|
|
||||||
mapped = tuple(map_point(point) for point in points)
|
|
||||||
if command == "L":
|
|
||||||
shape.draw_line(mapped[0], mapped[1])
|
|
||||||
elif command == "Q":
|
|
||||||
curve_start, curve_control, curve_end = mapped
|
|
||||||
mapped_control_one = pymupdf.Point(
|
|
||||||
curve_start.x + (curve_control.x - curve_start.x) * 2 / 3,
|
|
||||||
curve_start.y + (curve_control.y - curve_start.y) * 2 / 3,
|
|
||||||
)
|
|
||||||
mapped_control_two = pymupdf.Point(
|
|
||||||
curve_end.x + (curve_control.x - curve_end.x) * 2 / 3,
|
|
||||||
curve_end.y + (curve_control.y - curve_end.y) * 2 / 3,
|
|
||||||
)
|
|
||||||
shape.draw_bezier(
|
|
||||||
curve_start,
|
|
||||||
mapped_control_one,
|
|
||||||
mapped_control_two,
|
|
||||||
curve_end,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
shape.draw_bezier(*mapped)
|
|
||||||
shape.finish(
|
|
||||||
color=color,
|
|
||||||
width=max(0.1, width),
|
|
||||||
lineCap=1,
|
|
||||||
lineJoin=1,
|
|
||||||
closePath=False,
|
|
||||||
stroke_opacity=opacity,
|
|
||||||
)
|
|
||||||
shape.commit(overlay=True)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _style_at(styles: Any, line_number: int, character_number: int) -> Mapping[str, Any]:
|
def _style_at(styles: Any, line_number: int, character_number: int) -> Mapping[str, Any]:
|
||||||
if not isinstance(styles, Mapping):
|
if not isinstance(styles, Mapping):
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -546,49 +182,6 @@ def _style_at(styles: Any, line_number: int, character_number: int) -> Mapping[s
|
||||||
return character_style if isinstance(character_style, Mapping) else {}
|
return character_style if isinstance(character_style, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
def _insert_text_lines(
|
|
||||||
page: pymupdf.Page,
|
|
||||||
rect: pymupdf.Rect,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
fontsize: float,
|
|
||||||
font_name: str,
|
|
||||||
font_file: Path | None = None,
|
|
||||||
color: tuple[float, float, float],
|
|
||||||
align: int = 0,
|
|
||||||
line_height: float = 1.2,
|
|
||||||
synthetic_italic: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Insert text without requiring it to fit inside the saved rectangle.
|
|
||||||
|
|
||||||
Fabric keeps text visible even when a manually sized textbox is shorter
|
|
||||||
than the font metrics. PyMuPDF's textbox helper instead returns a negative
|
|
||||||
spare-height value and may insert nothing, so this fallback preserves the
|
|
||||||
preview's overflow behavior.
|
|
||||||
"""
|
|
||||||
font = pymupdf.Font(fontfile=str(font_file)) if font_file else pymupdf.Font(fontname=font_name)
|
|
||||||
for line_number, line in enumerate(text.split("\n")):
|
|
||||||
line_width = float(font.text_length(line, fontsize=fontsize))
|
|
||||||
x = rect.x0
|
|
||||||
if align == 1:
|
|
||||||
x += max(0.0, (rect.width - line_width) / 2)
|
|
||||||
elif align == 2:
|
|
||||||
x += max(0.0, rect.width - line_width)
|
|
||||||
|
|
||||||
options: dict[str, Any] = {
|
|
||||||
"fontsize": fontsize,
|
|
||||||
"fontname": font_name,
|
|
||||||
"color": color,
|
|
||||||
"overlay": True,
|
|
||||||
}
|
|
||||||
if font_file:
|
|
||||||
options["fontfile"] = str(font_file)
|
|
||||||
origin = pymupdf.Point(x, rect.y0 + fontsize + line_number * fontsize * line_height)
|
|
||||||
if synthetic_italic:
|
|
||||||
options["morph"] = _synthetic_italic_morph(origin)
|
|
||||||
page.insert_text(origin, line, **options)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_styled_text(
|
def _render_styled_text(
|
||||||
page: pymupdf.Page,
|
page: pymupdf.Page,
|
||||||
rect: pymupdf.Rect,
|
rect: pymupdf.Rect,
|
||||||
|
|
@ -596,62 +189,31 @@ def _render_styled_text(
|
||||||
props: Mapping[str, Any],
|
props: Mapping[str, Any],
|
||||||
styles: Any,
|
styles: Any,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not isinstance(styles, Mapping):
|
if not isinstance(styles, Mapping) or not styles:
|
||||||
styles = {}
|
return False
|
||||||
|
|
||||||
base_size = max(1.0, float(props.get("fontSize", 14)))
|
base_size = max(1.0, float(props.get("fontSize", 14)))
|
||||||
line_height = max(0.5, float(props.get("lineHeight", 1.2)))
|
line_height = max(0.5, float(props.get("lineHeight", 1.2)))
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
y = rect.y0
|
y = rect.y0
|
||||||
for line_number, line in enumerate(lines):
|
for line_number, line in enumerate(lines):
|
||||||
measured: list[
|
measured: list[tuple[str, float, str, tuple[float, float, float], float, str | None]] = []
|
||||||
tuple[
|
|
||||||
str,
|
|
||||||
float,
|
|
||||||
str,
|
|
||||||
Path | None,
|
|
||||||
tuple[float, float, float],
|
|
||||||
float,
|
|
||||||
str | None,
|
|
||||||
bool,
|
|
||||||
]
|
|
||||||
] = []
|
|
||||||
for character_number, character in enumerate(line):
|
for character_number, character in enumerate(line):
|
||||||
style = _style_at(styles, line_number, character_number)
|
style = _style_at(styles, line_number, character_number)
|
||||||
font_size = max(1.0, float(style.get("fontSize", base_size)))
|
font_size = max(1.0, float(style.get("fontSize", base_size)))
|
||||||
font_family = style.get("fontFamily", props.get("fontFamily"))
|
font_family = style.get("fontFamily", props.get("fontFamily"))
|
||||||
font_name, font_file = _font_choice(
|
font_name = _font_name(
|
||||||
font_family,
|
font_family,
|
||||||
_is_bold(style.get("fontWeight", "bold" if props.get("bold") else None)),
|
style.get("fontWeight") == "bold",
|
||||||
_is_italic(style.get("fontStyle", "italic" if props.get("italic") else None)),
|
style.get("fontStyle") == "italic",
|
||||||
)
|
|
||||||
synthetic_italic = _needs_synthetic_italic(
|
|
||||||
font_family,
|
|
||||||
style.get("fontWeight", "bold" if props.get("bold") else None),
|
|
||||||
style.get("fontStyle", "italic" if props.get("italic") else None),
|
|
||||||
)
|
)
|
||||||
color = _color(style.get("fill", props.get("color")))
|
color = _color(style.get("fill", props.get("color")))
|
||||||
font = (
|
font = pymupdf.Font(fontname=font_name)
|
||||||
pymupdf.Font(fontfile=str(font_file))
|
|
||||||
if font_file
|
|
||||||
else pymupdf.Font(fontname=font_name)
|
|
||||||
)
|
|
||||||
width = float(font.text_length(character, fontsize=font_size))
|
width = float(font.text_length(character, fontsize=font_size))
|
||||||
background = style.get("textBackgroundColor", props.get("highlightColor"))
|
background = style.get("textBackgroundColor", props.get("highlightColor"))
|
||||||
measured.append(
|
measured.append((character, font_size, font_name, color, width, background))
|
||||||
(
|
|
||||||
character,
|
|
||||||
font_size,
|
|
||||||
font_name,
|
|
||||||
font_file,
|
|
||||||
color,
|
|
||||||
width,
|
|
||||||
background,
|
|
||||||
synthetic_italic,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
total_width = sum(item[5] for item in measured)
|
total_width = sum(item[4] for item in measured)
|
||||||
align = str(props.get("align", "left"))
|
align = str(props.get("align", "left"))
|
||||||
x = rect.x0
|
x = rect.x0
|
||||||
if align == "center":
|
if align == "center":
|
||||||
|
|
@ -660,43 +222,27 @@ def _render_styled_text(
|
||||||
x += max(0.0, rect.width - total_width)
|
x += max(0.0, rect.width - total_width)
|
||||||
|
|
||||||
max_size = max((item[1] for item in measured), default=base_size)
|
max_size = max((item[1] for item in measured), default=base_size)
|
||||||
line_box_height = max_size * line_height
|
for character, font_size, font_name, color, width, background in measured:
|
||||||
background_top = y + max(0.0, (line_box_height - max_size) / 2)
|
|
||||||
background_height = max_size * 1.15
|
|
||||||
for (
|
|
||||||
character,
|
|
||||||
font_size,
|
|
||||||
font_name,
|
|
||||||
font_file,
|
|
||||||
color,
|
|
||||||
width,
|
|
||||||
background,
|
|
||||||
synthetic_italic,
|
|
||||||
) in measured:
|
|
||||||
if (
|
if (
|
||||||
isinstance(background, str)
|
isinstance(background, str)
|
||||||
and background.lower() not in {"", "transparent", "none"}
|
and background.lower() not in {"", "transparent", "none"}
|
||||||
):
|
):
|
||||||
page.draw_rect(
|
page.draw_rect(
|
||||||
pymupdf.Rect(x, background_top, x + width, background_top + background_height),
|
pymupdf.Rect(x, y, x + width, y + max_size * 1.15),
|
||||||
color=None,
|
color=None,
|
||||||
fill=_color(background),
|
fill=_color(background),
|
||||||
fill_opacity=0.3,
|
fill_opacity=0.3,
|
||||||
overlay=True,
|
overlay=True,
|
||||||
)
|
)
|
||||||
if character != " ":
|
if character != " ":
|
||||||
options: dict[str, Any] = {
|
page.insert_text(
|
||||||
"fontsize": font_size,
|
pymupdf.Point(x, y + font_size),
|
||||||
"fontname": font_name,
|
character,
|
||||||
"color": color,
|
fontsize=font_size,
|
||||||
"overlay": True,
|
fontname=font_name,
|
||||||
}
|
color=color,
|
||||||
if font_file:
|
overlay=True,
|
||||||
options["fontfile"] = str(font_file)
|
)
|
||||||
origin = pymupdf.Point(x, y + font_size)
|
|
||||||
if synthetic_italic:
|
|
||||||
options["morph"] = _synthetic_italic_morph(origin)
|
|
||||||
page.insert_text(origin, character, **options)
|
|
||||||
x += width
|
x += width
|
||||||
y += max_size * line_height
|
y += max_size * line_height
|
||||||
|
|
||||||
|
|
@ -711,50 +257,21 @@ def render_text(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -
|
||||||
text = str(props.get("text", ""))
|
text = str(props.get("text", ""))
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
styles = props.get("styles")
|
|
||||||
highlight = props.get("highlightColor")
|
highlight = props.get("highlightColor")
|
||||||
has_highlight = (
|
if isinstance(highlight, str) and highlight and highlight.lower() != "transparent":
|
||||||
isinstance(highlight, str)
|
page.draw_rect(rect, color=None, fill=_color(highlight), fill_opacity=0.3, overlay=True)
|
||||||
and highlight.strip().lower() not in {"", "transparent", "none"}
|
if _render_styled_text(page, rect, text, props, props.get("styles")):
|
||||||
)
|
|
||||||
if has_highlight or (isinstance(styles, Mapping) and bool(styles)):
|
|
||||||
_render_styled_text(page, rect, text, props, styles)
|
|
||||||
return
|
return
|
||||||
align = {"left": 0, "center": 1, "right": 2}.get(str(props.get("align", "left")), 0)
|
align = {"left": 0, "center": 1, "right": 2}.get(str(props.get("align", "left")), 0)
|
||||||
font_size = max(1.0, float(props.get("fontSize", 14)))
|
page.insert_textbox(
|
||||||
font_name, font_file = _font_choice(
|
rect,
|
||||||
props.get("fontFamily"), props.get("bold"), props.get("italic")
|
text,
|
||||||
|
fontsize=max(1.0, float(props.get("fontSize", 14))),
|
||||||
|
fontname=_font_name(props.get("fontFamily"), props.get("bold"), props.get("italic")),
|
||||||
|
color=_color(props.get("color")),
|
||||||
|
align=align,
|
||||||
|
overlay=True,
|
||||||
)
|
)
|
||||||
synthetic_italic = _needs_synthetic_italic(
|
|
||||||
props.get("fontFamily"), props.get("bold"), props.get("italic")
|
|
||||||
)
|
|
||||||
options: dict[str, Any] = {
|
|
||||||
"fontsize": font_size,
|
|
||||||
"fontname": font_name,
|
|
||||||
"color": _color(props.get("color")),
|
|
||||||
"align": align,
|
|
||||||
"overlay": True,
|
|
||||||
}
|
|
||||||
if font_file:
|
|
||||||
options["fontfile"] = str(font_file)
|
|
||||||
if synthetic_italic:
|
|
||||||
options["morph"] = _synthetic_italic_morph(
|
|
||||||
pymupdf.Point(rect.x0, rect.y0 + font_size)
|
|
||||||
)
|
|
||||||
result = page.insert_textbox(rect, text, **options)
|
|
||||||
if result < 0:
|
|
||||||
_insert_text_lines(
|
|
||||||
page,
|
|
||||||
rect,
|
|
||||||
text,
|
|
||||||
fontsize=font_size,
|
|
||||||
font_name=font_name,
|
|
||||||
font_file=font_file,
|
|
||||||
color=_color(props.get("color")),
|
|
||||||
align=align,
|
|
||||||
line_height=max(0.5, float(props.get("lineHeight", 1.2))),
|
|
||||||
synthetic_italic=synthetic_italic,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def render_draw(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
|
def render_draw(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
|
||||||
|
|
@ -762,16 +279,6 @@ def render_draw(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -
|
||||||
if rect is None:
|
if rect is None:
|
||||||
return
|
return
|
||||||
props = _props(annotation)
|
props = _props(annotation)
|
||||||
svg_path = props.get("svgPath")
|
|
||||||
if isinstance(svg_path, str) and _draw_svg_path(
|
|
||||||
page,
|
|
||||||
rect,
|
|
||||||
svg_path,
|
|
||||||
color=_color(props.get("strokeColor")),
|
|
||||||
width=float(props.get("strokeWidth", 2)),
|
|
||||||
opacity=_opacity(props.get("opacity")),
|
|
||||||
):
|
|
||||||
return
|
|
||||||
_draw_polyline(
|
_draw_polyline(
|
||||||
page,
|
page,
|
||||||
_points_from_props(annotation, rect),
|
_points_from_props(annotation, rect),
|
||||||
|
|
@ -814,41 +321,20 @@ def render_signature(page: pymupdf.Page, annotation: Annotation, assets_dir: Pat
|
||||||
if text:
|
if text:
|
||||||
font_file = _signature_font_file(props.get("fontFamily"))
|
font_file = _signature_font_file(props.get("fontFamily"))
|
||||||
font_name = _font_name(props.get("fontFamily"), False, False)
|
font_name = _font_name(props.get("fontFamily"), False, False)
|
||||||
font_size = _signature_font_size(
|
|
||||||
rect,
|
|
||||||
text,
|
|
||||||
font_file,
|
|
||||||
font_name,
|
|
||||||
props.get("fontSize"),
|
|
||||||
)
|
|
||||||
font = (
|
|
||||||
pymupdf.Font(fontfile=str(font_file))
|
|
||||||
if font_file
|
|
||||||
else pymupdf.Font(fontname=font_name)
|
|
||||||
)
|
|
||||||
options: dict[str, Any] = {
|
options: dict[str, Any] = {
|
||||||
"fontsize": font_size,
|
# Script fonts have a taller ascender than the built-in PDF fonts;
|
||||||
|
# leave room inside the editor's saved bounding rectangle so a
|
||||||
|
# valid signature is never silently dropped for not fitting.
|
||||||
|
"fontsize": _signature_font_size(rect, text, font_file, font_name),
|
||||||
"color": _color(props.get("color")),
|
"color": _color(props.get("color")),
|
||||||
"overlay": True,
|
"overlay": True,
|
||||||
}
|
}
|
||||||
# The font carries its own negative script-glyph bearings, so the PDF
|
|
||||||
# insertion origin is the Fabric text box edge. Fit the run to the
|
|
||||||
# canonical width to account for browser/PDF shaping differences and
|
|
||||||
# for intentional non-uniform resizing.
|
|
||||||
text_x = rect.x0
|
|
||||||
text_baseline = rect.y0 + font.ascender * font_size
|
|
||||||
text_width = float(font.text_length(text, fontsize=font_size))
|
|
||||||
if text_width > 0 and rect.width > 0:
|
|
||||||
options["morph"] = (
|
|
||||||
pymupdf.Point(text_x, text_baseline),
|
|
||||||
pymupdf.Matrix(rect.width / text_width, 0, 0, 1, 0, 0),
|
|
||||||
)
|
|
||||||
if font_file:
|
if font_file:
|
||||||
options["fontname"] = f"paperjet-{font_file.stem.lower()}"
|
options["fontname"] = f"paperjet-{font_file.stem.lower()}"
|
||||||
options["fontfile"] = str(font_file)
|
options["fontfile"] = str(font_file)
|
||||||
else:
|
else:
|
||||||
options["fontname"] = font_name
|
options["fontname"] = font_name
|
||||||
page.insert_text(pymupdf.Point(text_x, text_baseline), text, **options)
|
page.insert_textbox(rect, text, **options)
|
||||||
|
|
||||||
|
|
||||||
def render_highlight(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
|
def render_highlight(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import pymupdf
|
import pymupdf
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services.export.renderer import _needs_synthetic_italic, export_annotations
|
from app.services.export.renderer import export_annotations
|
||||||
|
|
||||||
|
|
||||||
def _source_pdf(path) -> None:
|
def _source_pdf(path) -> None:
|
||||||
|
|
@ -63,7 +62,6 @@ def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path)
|
||||||
"text": "Ava",
|
"text": "Ava",
|
||||||
"fontFamily": "Great Vibes",
|
"fontFamily": "Great Vibes",
|
||||||
"color": "#000000",
|
"color": "#000000",
|
||||||
"fontSize": 48,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -94,21 +92,6 @@ def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path)
|
||||||
assert "Exported text" in page.get_text("text")
|
assert "Exported text" in page.get_text("text")
|
||||||
assert "Ava" in page.get_text("text")
|
assert "Ava" in page.get_text("text")
|
||||||
|
|
||||||
signature_spans = [
|
|
||||||
span
|
|
||||||
for block in page.get_text("dict")["blocks"]
|
|
||||||
if "lines" in block
|
|
||||||
for line in block["lines"]
|
|
||||||
for span in line["spans"]
|
|
||||||
if span["text"] == "Ava"
|
|
||||||
]
|
|
||||||
assert signature_spans
|
|
||||||
signature_span = signature_spans[0]
|
|
||||||
assert signature_span["bbox"][2] - signature_span["bbox"][0] == pytest.approx(160)
|
|
||||||
assert signature_span["bbox"][3] - signature_span["bbox"][1] == pytest.approx(
|
|
||||||
(signature_span["ascender"] - signature_span["descender"]) * 48
|
|
||||||
)
|
|
||||||
|
|
||||||
# Normalize only for inspection: the exported PDF still retains the
|
# Normalize only for inspection: the exported PDF still retains the
|
||||||
# original page rotation above.
|
# original page rotation above.
|
||||||
page.set_rotation(0)
|
page.set_rotation(0)
|
||||||
|
|
@ -119,409 +102,6 @@ def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path)
|
||||||
assert len(page.get_drawings()) >= 3
|
assert len(page.get_drawings()) >= 3
|
||||||
|
|
||||||
|
|
||||||
def test_export_renders_text_when_saved_box_is_short(tmp_path) -> None:
|
|
||||||
source = tmp_path / "source.pdf"
|
|
||||||
document = pymupdf.open()
|
|
||||||
document.new_page(width=240, height=320)
|
|
||||||
document.save(source)
|
|
||||||
document.close()
|
|
||||||
|
|
||||||
annotations = [
|
|
||||||
{
|
|
||||||
"id": "short-text",
|
|
||||||
"page": 0,
|
|
||||||
"type": "text",
|
|
||||||
"rect": {"x": 20, "y": 20, "width": 120, "height": 12},
|
|
||||||
"props": {
|
|
||||||
"text": "Small text",
|
|
||||||
"fontFamily": "Liberation Sans",
|
|
||||||
"fontSize": 14,
|
|
||||||
"color": "#000000",
|
|
||||||
"align": "left",
|
|
||||||
"lineHeight": 1.2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "styled-multiline",
|
|
||||||
"page": 0,
|
|
||||||
"type": "text",
|
|
||||||
"rect": {"x": 20, "y": 60, "width": 120, "height": 18},
|
|
||||||
"props": {
|
|
||||||
"text": "Styled\ntext",
|
|
||||||
"fontFamily": "Liberation Sans",
|
|
||||||
"fontSize": 14,
|
|
||||||
"color": "#000000",
|
|
||||||
"align": "left",
|
|
||||||
"lineHeight": 1.2,
|
|
||||||
"styles": {
|
|
||||||
"0": {"0": {"fill": "#ff0000", "fontWeight": "bold"}},
|
|
||||||
"1": {"0": {"fill": "#0000ff", "fontStyle": "italic"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
|
||||||
|
|
||||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
|
||||||
text = document[0].get_text("text")
|
|
||||||
assert "Small text" in text
|
|
||||||
assert "Styled" in text
|
|
||||||
assert "text" in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_uses_the_selected_text_font(tmp_path) -> None:
|
|
||||||
source = tmp_path / "source.pdf"
|
|
||||||
document = pymupdf.open()
|
|
||||||
document.new_page(width=300, height=140)
|
|
||||||
document.save(source)
|
|
||||||
document.close()
|
|
||||||
|
|
||||||
annotations = [
|
|
||||||
{
|
|
||||||
"id": "outfit-text",
|
|
||||||
"page": 0,
|
|
||||||
"type": "text",
|
|
||||||
"rect": {"x": 20, "y": 20, "width": 220, "height": 40},
|
|
||||||
"props": {
|
|
||||||
"text": "Outfit text",
|
|
||||||
"fontFamily": "Outfit",
|
|
||||||
"fontSize": 24,
|
|
||||||
"color": "#000000",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
|
||||||
|
|
||||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
|
||||||
spans = [
|
|
||||||
span
|
|
||||||
for block in document[0].get_text("dict")["blocks"]
|
|
||||||
if "lines" in block
|
|
||||||
for line in block["lines"]
|
|
||||||
for span in line["spans"]
|
|
||||||
if span["text"] == "Outfit text"
|
|
||||||
]
|
|
||||||
assert spans
|
|
||||||
assert "outfit" in spans[0]["font"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_uses_an_embeddable_serif_font_for_times_new_roman(tmp_path) -> None:
|
|
||||||
source = tmp_path / "source.pdf"
|
|
||||||
document = pymupdf.open()
|
|
||||||
document.new_page(width=300, height=120)
|
|
||||||
document.save(source)
|
|
||||||
document.close()
|
|
||||||
|
|
||||||
annotations = [
|
|
||||||
{
|
|
||||||
"id": "times-text",
|
|
||||||
"page": 0,
|
|
||||||
"type": "text",
|
|
||||||
"rect": {"x": 20, "y": 20, "width": 240, "height": 40},
|
|
||||||
"props": {
|
|
||||||
"text": "Times New Roman",
|
|
||||||
"fontFamily": "Times New Roman",
|
|
||||||
"fontSize": 20,
|
|
||||||
"color": "#000000",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
|
||||||
|
|
||||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
|
||||||
spans = [
|
|
||||||
span
|
|
||||||
for block in document[0].get_text("dict")["blocks"]
|
|
||||||
if "lines" in block
|
|
||||||
for line in block["lines"]
|
|
||||||
for span in line["spans"]
|
|
||||||
if span["text"] == "Times New Roman"
|
|
||||||
]
|
|
||||||
assert spans
|
|
||||||
assert "liberationserif" in spans[0]["font"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_highlight_tracks_styled_text_instead_of_the_saved_box(tmp_path) -> None:
|
|
||||||
source = tmp_path / "source.pdf"
|
|
||||||
document = pymupdf.open()
|
|
||||||
document.new_page(width=320, height=140)
|
|
||||||
document.save(source)
|
|
||||||
document.close()
|
|
||||||
|
|
||||||
annotation_rect = pymupdf.Rect(20, 20, 260, 50)
|
|
||||||
annotations = [
|
|
||||||
{
|
|
||||||
"id": "styled-text",
|
|
||||||
"page": 0,
|
|
||||||
"type": "text",
|
|
||||||
"rect": {
|
|
||||||
"x": annotation_rect.x0,
|
|
||||||
"y": annotation_rect.y0,
|
|
||||||
"width": annotation_rect.width,
|
|
||||||
"height": annotation_rect.height,
|
|
||||||
},
|
|
||||||
"props": {
|
|
||||||
"text": "Highlighted text",
|
|
||||||
"fontFamily": "Liberation Sans",
|
|
||||||
"fontSize": 18,
|
|
||||||
"color": "#000000",
|
|
||||||
"highlightColor": "#ffff00",
|
|
||||||
"styles": {"0": {"0": {"fill": "#ff0000"}}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
|
||||||
|
|
||||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
|
||||||
fill_drawings = [drawing for drawing in document[0].get_drawings() if drawing["fill"]]
|
|
||||||
assert fill_drawings
|
|
||||||
assert all(drawing["rect"] != annotation_rect for drawing in fill_drawings)
|
|
||||||
assert all(drawing["rect"].width < annotation_rect.width for drawing in fill_drawings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_preserves_smooth_freehand_strokes(tmp_path) -> None:
|
|
||||||
source = tmp_path / "source.pdf"
|
|
||||||
document = pymupdf.open()
|
|
||||||
document.new_page(width=300, height=180)
|
|
||||||
document.save(source)
|
|
||||||
document.close()
|
|
||||||
|
|
||||||
annotations = [
|
|
||||||
{
|
|
||||||
"id": "smooth-draw",
|
|
||||||
"page": 0,
|
|
||||||
"type": "draw",
|
|
||||||
"rect": {"x": 20, "y": 20, "width": 180, "height": 100},
|
|
||||||
"props": {
|
|
||||||
"svgPath": "M 0 0 Q 45 100 90 0 Q 135 -100 180 0",
|
|
||||||
"strokeColor": "#000000",
|
|
||||||
"strokeWidth": 2,
|
|
||||||
"strokeWidthUnit": "pdf",
|
|
||||||
"opacity": 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
|
||||||
|
|
||||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
|
||||||
drawings = document[0].get_drawings()
|
|
||||||
items = [item for drawing in drawings for item in drawing["items"]]
|
|
||||||
assert any(item[0] == "c" for item in items)
|
|
||||||
assert all(all(cap == 1 for cap in drawing["lineCap"]) for drawing in drawings)
|
|
||||||
assert all(drawing["lineJoin"] == 1 for drawing in drawings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_does_not_shrink_saved_typed_signature_size(tmp_path) -> None:
|
|
||||||
source = tmp_path / "source.pdf"
|
|
||||||
document = pymupdf.open()
|
|
||||||
document.new_page(width=300, height=180)
|
|
||||||
document.save(source)
|
|
||||||
document.close()
|
|
||||||
|
|
||||||
annotations = [
|
|
||||||
{
|
|
||||||
"id": "typed-signature",
|
|
||||||
"page": 0,
|
|
||||||
"type": "signature",
|
|
||||||
"rect": {"x": 20, "y": 20, "width": 160, "height": 60},
|
|
||||||
"props": {
|
|
||||||
"mode": "type",
|
|
||||||
"text": "John Doe",
|
|
||||||
"fontFamily": "Great Vibes",
|
|
||||||
"fontSize": 48,
|
|
||||||
"color": "#000000",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
|
||||||
|
|
||||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
|
||||||
spans = [
|
|
||||||
span
|
|
||||||
for block in document[0].get_text("dict")["blocks"]
|
|
||||||
if "lines" in block
|
|
||||||
for line in block["lines"]
|
|
||||||
for span in line["spans"]
|
|
||||||
if span["text"] == "John Doe"
|
|
||||||
]
|
|
||||||
assert spans
|
|
||||||
span = spans[0]
|
|
||||||
assert span["bbox"][2] - span["bbox"][0] == pytest.approx(160, abs=0.01)
|
|
||||||
assert span["bbox"][3] - span["bbox"][1] == pytest.approx(
|
|
||||||
(span["ascender"] - span["descender"]) * 48
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_does_not_double_apply_typed_signature_glyph_bearing(tmp_path) -> None:
|
|
||||||
source = tmp_path / "source.pdf"
|
|
||||||
document = pymupdf.open()
|
|
||||||
document.new_page(width=320, height=160)
|
|
||||||
document.save(source)
|
|
||||||
document.close()
|
|
||||||
|
|
||||||
annotations = [
|
|
||||||
{
|
|
||||||
"id": "offset-signature",
|
|
||||||
"page": 0,
|
|
||||||
"type": "signature",
|
|
||||||
"rect": {"x": 120, "y": 40, "width": 190, "height": 70},
|
|
||||||
"props": {
|
|
||||||
"mode": "type",
|
|
||||||
"text": "John Doe",
|
|
||||||
"fontFamily": "Great Vibes",
|
|
||||||
"fontSize": 48,
|
|
||||||
# Stale records from the previous implementation may still
|
|
||||||
# contain this value. The font already carries the bearing.
|
|
||||||
"fontOffsetX": 4,
|
|
||||||
"color": "#000000",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
|
||||||
|
|
||||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
|
||||||
spans = [
|
|
||||||
span
|
|
||||||
for block in document[0].get_text("dict")["blocks"]
|
|
||||||
if "lines" in block
|
|
||||||
for line in block["lines"]
|
|
||||||
for span in line["spans"]
|
|
||||||
if span["text"] == "John Doe"
|
|
||||||
]
|
|
||||||
assert spans
|
|
||||||
assert spans[0]["bbox"][0] == pytest.approx(120)
|
|
||||||
assert spans[0]["bbox"][2] - spans[0]["bbox"][0] == pytest.approx(190)
|
|
||||||
|
|
||||||
pixmap = document[0].get_pixmap(alpha=False)
|
|
||||||
dark_x = [
|
|
||||||
index % pixmap.width
|
|
||||||
for index in range(pixmap.width * pixmap.height)
|
|
||||||
if min(pixmap.samples[index * 3 : index * 3 + 3]) < 128
|
|
||||||
]
|
|
||||||
assert min(dark_x) == pytest.approx(117, abs=1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_outfit_italic_uses_browser_equivalent_synthetic_oblique() -> None:
|
|
||||||
assert _needs_synthetic_italic("Outfit", False, "italic")
|
|
||||||
assert _needs_synthetic_italic("Outfit", True, "italic")
|
|
||||||
assert not _needs_synthetic_italic("Outfit", False, "normal")
|
|
||||||
assert not _needs_synthetic_italic("Plus Jakarta Sans", False, "italic")
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_slants_outfit_italic_to_the_right(tmp_path) -> None:
|
|
||||||
source = tmp_path / "source.pdf"
|
|
||||||
document = pymupdf.open()
|
|
||||||
document.new_page(width=360, height=140)
|
|
||||||
document.save(source)
|
|
||||||
document.close()
|
|
||||||
|
|
||||||
annotations = [
|
|
||||||
{
|
|
||||||
"page": 0,
|
|
||||||
"type": "text",
|
|
||||||
"rect": {"x": 20, "y": 20, "width": 120, "height": 80},
|
|
||||||
"props": {
|
|
||||||
"text": "test",
|
|
||||||
"fontFamily": "Outfit",
|
|
||||||
"fontSize": 48,
|
|
||||||
"color": "#0000ff",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"page": 0,
|
|
||||||
"type": "text",
|
|
||||||
"rect": {"x": 180, "y": 20, "width": 120, "height": 80},
|
|
||||||
"props": {
|
|
||||||
"text": "test",
|
|
||||||
"fontFamily": "Outfit",
|
|
||||||
"fontSize": 48,
|
|
||||||
"color": "#0000ff",
|
|
||||||
"italic": True,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
|
||||||
|
|
||||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
|
||||||
spans = [
|
|
||||||
span
|
|
||||||
for block in document[0].get_text("dict")["blocks"]
|
|
||||||
if "lines" in block
|
|
||||||
for line in block["lines"]
|
|
||||||
for span in line["spans"]
|
|
||||||
if span["text"] == "test"
|
|
||||||
]
|
|
||||||
assert len(spans) == 2
|
|
||||||
pixmap = document[0].get_pixmap(alpha=False)
|
|
||||||
|
|
||||||
def blue_pixels(row: int, start: int, end: int) -> list[int]:
|
|
||||||
return [
|
|
||||||
x
|
|
||||||
for x in range(start, end)
|
|
||||||
if pixmap.samples[(row * pixmap.width + x) * 3 + 2] > 120
|
|
||||||
and pixmap.samples[(row * pixmap.width + x) * 3] < 100
|
|
||||||
]
|
|
||||||
|
|
||||||
normal_top = min(blue_pixels(40, 0, 140)) - 20
|
|
||||||
italic_top = min(blue_pixels(40, 140, 340)) - 180
|
|
||||||
normal_bottom = min(blue_pixels(65, 0, 140)) - 20
|
|
||||||
italic_bottom = min(blue_pixels(65, 140, 340)) - 180
|
|
||||||
assert italic_top > normal_top + 5
|
|
||||||
assert italic_bottom == pytest.approx(normal_bottom, abs=1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_preserves_canonical_stroke_widths(tmp_path) -> None:
|
|
||||||
source = tmp_path / "source.pdf"
|
|
||||||
document = pymupdf.open()
|
|
||||||
document.new_page(width=240, height=320)
|
|
||||||
document.save(source)
|
|
||||||
document.close()
|
|
||||||
|
|
||||||
annotations = [
|
|
||||||
{
|
|
||||||
"id": "draw",
|
|
||||||
"page": 0,
|
|
||||||
"type": "draw",
|
|
||||||
"rect": {"x": 20, "y": 20, "width": 100, "height": 40},
|
|
||||||
"props": {
|
|
||||||
"paths": [[0, 0], [100, 40]],
|
|
||||||
"strokeColor": "#000000",
|
|
||||||
"strokeWidth": 1.25,
|
|
||||||
"strokeWidthUnit": "pdf",
|
|
||||||
"opacity": 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "shape",
|
|
||||||
"page": 0,
|
|
||||||
"type": "shape",
|
|
||||||
"rect": {"x": 20, "y": 100, "width": 100, "height": 40},
|
|
||||||
"props": {
|
|
||||||
"kind": "rect",
|
|
||||||
"strokeColor": "#000000",
|
|
||||||
"fillColor": "transparent",
|
|
||||||
"strokeWidth": 2.5,
|
|
||||||
"strokeWidthUnit": "pdf",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
|
||||||
|
|
||||||
with pymupdf.open(stream=exported, filetype="pdf") as document:
|
|
||||||
widths = [float(drawing["width"]) for drawing in document[0].get_drawings()]
|
|
||||||
assert any(width == pytest.approx(1.25) for width in widths)
|
|
||||||
assert any(width == pytest.approx(2.5) for width in widths)
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_does_not_mutate_annotation_input(tmp_path) -> None:
|
def test_export_does_not_mutate_annotation_input(tmp_path) -> None:
|
||||||
source = tmp_path / "source.pdf"
|
source = tmp_path / "source.pdf"
|
||||||
_source_pdf(source)
|
_source_pdf(source)
|
||||||
|
|
|
||||||
163
frontend/package-lock.json
generated
163
frontend/package-lock.json
generated
|
|
@ -30,7 +30,6 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@testing-library/dom": "^10.4.0",
|
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/fabric": "^5.3.11",
|
"@types/fabric": "^5.3.11",
|
||||||
|
|
@ -511,6 +510,16 @@
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||||
|
|
@ -1085,7 +1094,6 @@
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -1709,66 +1717,6 @@
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@tybys/wasm-util": "^0.10.1"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@emnapi/core": "^1.7.1",
|
|
||||||
"@emnapi/runtime": "^1.7.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
|
||||||
"version": "0.10.1",
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
||||||
|
|
@ -1815,26 +1763,6 @@
|
||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
|
||||||
"version": "10.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
|
||||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/code-frame": "^7.10.4",
|
|
||||||
"@babel/runtime": "^7.12.5",
|
|
||||||
"@types/aria-query": "^5.0.1",
|
|
||||||
"aria-query": "5.3.0",
|
|
||||||
"dom-accessibility-api": "^0.5.9",
|
|
||||||
"lz-string": "^1.5.0",
|
|
||||||
"picocolors": "1.1.1",
|
|
||||||
"pretty-format": "^27.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testing-library/jest-dom": {
|
"node_modules/@testing-library/jest-dom": {
|
||||||
"version": "6.9.1",
|
"version": "6.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||||
|
|
@ -1894,20 +1822,12 @@
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/aria-query": {
|
|
||||||
"version": "5.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
|
@ -2432,29 +2352,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ansi-regex": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ansi-styles": {
|
|
||||||
"version": "5.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
|
||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
|
@ -3001,13 +2898,6 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
|
||||||
"version": "0.5.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.371",
|
"version": "1.5.371",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
|
||||||
|
|
@ -4271,16 +4161,6 @@
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lz-string": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"lz-string": "bin/bin.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|
@ -4705,21 +4585,6 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pretty-format": {
|
|
||||||
"version": "27.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
|
||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-regex": "^5.0.1",
|
|
||||||
"ansi-styles": "^5.0.0",
|
|
||||||
"react-is": "^17.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pump": {
|
"node_modules/pump": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||||
|
|
@ -4778,13 +4643,6 @@
|
||||||
"react": "^19.2.7"
|
"react": "^19.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
|
||||||
"version": "17.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.17.0",
|
"version": "7.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
||||||
|
|
@ -5275,7 +5133,6 @@
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@testing-library/dom": "^10.4.0",
|
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/fabric": "^5.3.11",
|
"@types/fabric": "^5.3.11",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@ import { useEditorStore } from '../store';
|
||||||
import { getTool } from '../../../lib/annotations/registry';
|
import { getTool } from '../../../lib/annotations/registry';
|
||||||
import type { ViewportParams } from '../../../lib/coords';
|
import type { ViewportParams } from '../../../lib/coords';
|
||||||
import { screenRectToPdf } from '../../../lib/coords';
|
import { screenRectToPdf } from '../../../lib/coords';
|
||||||
import type { Annotation, SignatureAnnotation } from '../../../lib/annotations/types';
|
|
||||||
import { TextFormatToolbar } from '../toolbar/TextFormatToolbar';
|
import { TextFormatToolbar } from '../toolbar/TextFormatToolbar';
|
||||||
import { signatureTextBounds } from '../tools/signatureGeometry';
|
|
||||||
|
|
||||||
interface AnnotationLayerProps {
|
interface AnnotationLayerProps {
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
|
|
@ -52,28 +50,13 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
||||||
if (!object?.id) return;
|
if (!object?.id) return;
|
||||||
const annotation = useEditorStore.getState().annotations.find((item) => item.id === object.id);
|
const annotation = useEditorStore.getState().annotations.find((item) => item.id === object.id);
|
||||||
if (!annotation) return;
|
if (!annotation) return;
|
||||||
const bounds =
|
const bounds = object.getBoundingRect();
|
||||||
annotation.type === 'signature' && annotation.props.mode === 'type'
|
useEditorStore.getState().updateAnnotation(object.id, {
|
||||||
? signatureTextBounds(object)
|
|
||||||
: object.getBoundingRect();
|
|
||||||
const updates: Partial<Annotation> = {
|
|
||||||
rect: screenRectToPdf(
|
rect: screenRectToPdf(
|
||||||
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
|
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
|
||||||
latestViewportParams.current,
|
latestViewportParams.current,
|
||||||
),
|
),
|
||||||
};
|
});
|
||||||
if (annotation.type === 'signature' && annotation.props.mode === 'type') {
|
|
||||||
const textObject = object as fabric.Text;
|
|
||||||
const screenFontSize = textObject.fontSize * (textObject.scaleY || 1);
|
|
||||||
if (Number.isFinite(screenFontSize) && latestViewportParams.current.scale > 0) {
|
|
||||||
const nextFontSize = screenFontSize / latestViewportParams.current.scale;
|
|
||||||
updates.props = {
|
|
||||||
...(annotation as SignatureAnnotation).props,
|
|
||||||
fontSize: nextFontSize,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
useEditorStore.getState().updateAnnotation(object.id, updates);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateSelection = () => {
|
const updateSelection = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { TextFormatToolbar } from './TextFormatToolbar';
|
|
||||||
import { useEditorStore } from '../store';
|
|
||||||
import type { TextAnnotation } from '../../../lib/annotations/types';
|
|
||||||
import type { Canvas } from 'fabric';
|
|
||||||
|
|
||||||
const annotation: TextAnnotation = {
|
|
||||||
id: 'text-1',
|
|
||||||
page: 0,
|
|
||||||
type: 'text',
|
|
||||||
rect: { x: 20, y: 20, width: 120, height: 32 },
|
|
||||||
rotation: 0,
|
|
||||||
z: 0,
|
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
props: {
|
|
||||||
text: 'Test text',
|
|
||||||
fontFamily: 'Liberation Sans',
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#000000',
|
|
||||||
align: 'left',
|
|
||||||
bold: false,
|
|
||||||
italic: false,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
highlightColor: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const viewportParams = {
|
|
||||||
scale: 1,
|
|
||||||
rotation: 0,
|
|
||||||
canonicalWidth: 612,
|
|
||||||
canonicalHeight: 792,
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeCanvas(activeObject: Record<string, unknown> | null = null) {
|
|
||||||
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
|
||||||
return {
|
|
||||||
getActiveObject: vi.fn(() => activeObject),
|
|
||||||
requestRenderAll: vi.fn(),
|
|
||||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
||||||
const handlers = listeners.get(event) ?? new Set();
|
|
||||||
handlers.add(handler);
|
|
||||||
listeners.set(event, handlers);
|
|
||||||
}),
|
|
||||||
off: vi.fn((event: string, handler?: (...args: unknown[]) => void) => {
|
|
||||||
if (!handler) {
|
|
||||||
listeners.delete(event);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
listeners.get(event)?.delete(handler);
|
|
||||||
}),
|
|
||||||
emit: (event: string) => listeners.get(event)?.forEach((handler) => handler()),
|
|
||||||
} as unknown as Canvas & { emit: (event: string) => void };
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
useEditorStore.getState().setAnnotations([]);
|
|
||||||
useEditorStore.getState().setDraftAnnotation(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TextFormatToolbar', () => {
|
|
||||||
it('does not show stale bold or italic state without the matching active text object', () => {
|
|
||||||
useEditorStore.getState().setAnnotations([
|
|
||||||
{
|
|
||||||
...annotation,
|
|
||||||
props: { ...annotation.props, bold: true, italic: true },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const canvas = makeCanvas();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TextFormatToolbar
|
|
||||||
annotationId={annotation.id}
|
|
||||||
viewportParams={viewportParams}
|
|
||||||
canvas={canvas}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTitle('Bold')).not.toHaveClass('bg-blue-100');
|
|
||||||
expect(screen.getByTitle('Italic')).not.toHaveClass('bg-blue-100');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists selected-range styles without replacing the annotation base props', () => {
|
|
||||||
useEditorStore.getState().setAnnotations([annotation]);
|
|
||||||
const activeObject: Record<string, unknown> = {
|
|
||||||
id: annotation.id,
|
|
||||||
text: annotation.props.text,
|
|
||||||
isEditing: true,
|
|
||||||
selectionStart: 0,
|
|
||||||
selectionEnd: 4,
|
|
||||||
styles: {},
|
|
||||||
setSelectionStyles: vi.fn((styles: Record<string, unknown>) => {
|
|
||||||
activeObject.styles = { '0': { '0': styles } };
|
|
||||||
}),
|
|
||||||
set: vi.fn(),
|
|
||||||
initDimensions: vi.fn(),
|
|
||||||
setCoords: vi.fn(),
|
|
||||||
};
|
|
||||||
const canvas = makeCanvas(activeObject);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TextFormatToolbar
|
|
||||||
annotationId={annotation.id}
|
|
||||||
viewportParams={viewportParams}
|
|
||||||
canvas={canvas}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle('Bold'));
|
|
||||||
|
|
||||||
const updated = useEditorStore.getState().annotations[0] as TextAnnotation;
|
|
||||||
expect(activeObject.setSelectionStyles).toHaveBeenCalledWith({ fontWeight: 'bold' });
|
|
||||||
expect(updated.props.bold).toBe(false);
|
|
||||||
expect(updated.props.styles).toEqual({ '0': { '0': { fontWeight: 'bold' } } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates the active state when Fabric reports a text selection change', () => {
|
|
||||||
useEditorStore.getState().setAnnotations([annotation]);
|
|
||||||
const activeObject: Record<string, unknown> = {
|
|
||||||
id: annotation.id,
|
|
||||||
text: annotation.props.text,
|
|
||||||
isEditing: true,
|
|
||||||
selectionStart: 0,
|
|
||||||
selectionEnd: 4,
|
|
||||||
styles: {},
|
|
||||||
getSelectionStyles: vi.fn(() => [{ fontStyle: 'italic' }]),
|
|
||||||
};
|
|
||||||
const canvas = makeCanvas(activeObject);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TextFormatToolbar
|
|
||||||
annotationId={annotation.id}
|
|
||||||
viewportParams={viewportParams}
|
|
||||||
canvas={canvas}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTitle('Italic')).toHaveClass('bg-blue-100');
|
|
||||||
activeObject.getSelectionStyles = vi.fn(() => [{ fontStyle: 'normal' }]);
|
|
||||||
act(() => canvas.emit('text:selection:changed'));
|
|
||||||
expect(screen.getByTitle('Italic')).not.toHaveClass('bg-blue-100');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -22,55 +22,6 @@ interface TextFormatToolbarProps {
|
||||||
canvas: Canvas;
|
canvas: Canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolbarTextObject = fabric.Textbox & {
|
|
||||||
id?: string;
|
|
||||||
customHeight?: number;
|
|
||||||
styles?: Record<string, Record<string, Record<string, unknown>>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function cloneInlineStyles(styles: unknown): Record<string, unknown> | null {
|
|
||||||
if (!styles || typeof styles !== 'object' || Object.keys(styles).length === 0) return null;
|
|
||||||
return JSON.parse(JSON.stringify(styles)) as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBoldValue(value: unknown) {
|
|
||||||
return value === 'bold' || value === 700 || value === '700';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isItalicValue(value: unknown) {
|
|
||||||
return value === 'italic' || value === 'oblique';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSelectedRange(object: ToolbarTextObject) {
|
|
||||||
return Boolean(
|
|
||||||
object.isEditing &&
|
|
||||||
typeof object.selectionStart === 'number' &&
|
|
||||||
typeof object.selectionEnd === 'number' &&
|
|
||||||
object.selectionEnd > object.selectionStart,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function baseStyleValue(object: ToolbarTextObject, property: string): unknown {
|
|
||||||
const fabricObject = object as unknown as {
|
|
||||||
get?: (name: string) => unknown;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
return typeof fabricObject.get === 'function' ? fabricObject.get(property) : fabricObject[property];
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedStyleValue(object: ToolbarTextObject, property: string): unknown {
|
|
||||||
if (!object.isEditing || typeof object.getSelectionStyles !== 'function') {
|
|
||||||
return baseStyleValue(object, property);
|
|
||||||
}
|
|
||||||
const start = object.selectionStart ?? 0;
|
|
||||||
const end = object.selectionEnd ?? start;
|
|
||||||
const styles = object.getSelectionStyles(start, Math.max(start + 1, end), true) as Array<Record<string, unknown>>;
|
|
||||||
if (!styles.length) return baseStyleValue(object, property);
|
|
||||||
const values = styles.map((style) => style[property]);
|
|
||||||
if (values.every((value) => value === values[0]) && values[0] !== undefined) return values[0];
|
|
||||||
return baseStyleValue(object, property);
|
|
||||||
}
|
|
||||||
|
|
||||||
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New'];
|
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New'];
|
||||||
const SIZES = [6, 7, 8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
|
const SIZES = [6, 7, 8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
|
||||||
const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff'];
|
const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff'];
|
||||||
|
|
@ -82,21 +33,6 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
|
||||||
const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null);
|
const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null);
|
||||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [, setStyleRevision] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const refresh = () => setStyleRevision((revision) => revision + 1);
|
|
||||||
const events = [
|
|
||||||
'text:selection:changed',
|
|
||||||
'text:changed',
|
|
||||||
'selection:created',
|
|
||||||
'selection:updated',
|
|
||||||
'selection:cleared',
|
|
||||||
] as const;
|
|
||||||
events.forEach((event) => canvas.on(event, refresh));
|
|
||||||
return () => events.forEach((event) => canvas.off(event, refresh));
|
|
||||||
}, [canvas]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (toolbarRef.current && !toolbarRef.current.contains(event.target as Node)) {
|
if (toolbarRef.current && !toolbarRef.current.contains(event.target as Node)) {
|
||||||
|
|
@ -114,69 +50,92 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
|
||||||
const textAnn = annotation as TextAnnotation;
|
const textAnn = annotation as TextAnnotation;
|
||||||
const props = textAnn.props;
|
const props = textAnn.props;
|
||||||
|
|
||||||
const activeObject = canvas.getActiveObject() as ToolbarTextObject | undefined;
|
|
||||||
const activeTextObject = activeObject?.id === annotationId ? activeObject : undefined;
|
|
||||||
const boldActive = Boolean(activeTextObject && isBoldValue(selectedStyleValue(activeTextObject, 'fontWeight')));
|
|
||||||
const italicActive = Boolean(activeTextObject && isItalicValue(selectedStyleValue(activeTextObject, 'fontStyle')));
|
|
||||||
|
|
||||||
const pt = pdfRectToScreen(textAnn.rect, viewportParams);
|
const pt = pdfRectToScreen(textAnn.rect, viewportParams);
|
||||||
const top = pt.y - 48; // 48px above
|
const top = pt.y - 48; // 48px above
|
||||||
const left = pt.x;
|
const left = pt.x;
|
||||||
|
|
||||||
const applyStyle = (styleName: string, value: unknown, globalPropName: keyof TextProps, globalValue?: unknown) => {
|
const applyStyle = (styleName: string, value: unknown, globalPropName: keyof TextProps, globalValue?: unknown) => {
|
||||||
const activeObj = canvas.getActiveObject() as ToolbarTextObject | undefined;
|
const activeObj = canvas.getActiveObject() as (fabric.Textbox & { id?: string; customHeight?: number }) | undefined;
|
||||||
if (!activeObj || activeObj.id !== annotationId) return;
|
if (activeObj) {
|
||||||
|
if (activeObj && activeObj.id === annotationId) {
|
||||||
|
const isStructural = styleName === 'fontSize' || styleName === 'fontFamily';
|
||||||
|
|
||||||
const isStructural = styleName === 'fontSize' || styleName === 'fontFamily';
|
if (isStructural) {
|
||||||
const rangeSelected = hasSelectedRange(activeObj);
|
// Structural properties MUST be applied to the base object. Fabric 7's bounding box calculations
|
||||||
|
// frequently fail when inline styles are used for size/font.
|
||||||
|
activeObj.set(styleName, value);
|
||||||
|
|
||||||
if (rangeSelected && !isStructural) {
|
// Obliterate any inline styles for this property so the base property strictly applies to all text
|
||||||
activeObj.setSelectionStyles({ [styleName]: value });
|
if (activeObj.styles) {
|
||||||
} else {
|
for (const line in activeObj.styles) {
|
||||||
// Structural properties and non-editing changes apply to the base object.
|
for (const char in activeObj.styles[line]) {
|
||||||
// Inline values for the same property are removed so the base value is
|
if (activeObj.styles[line][char]) {
|
||||||
// not shadowed after the object is reloaded from the store.
|
delete activeObj.styles[line][char][styleName];
|
||||||
activeObj.set(styleName, value);
|
}
|
||||||
if (activeObj.styles) {
|
}
|
||||||
for (const line of Object.values(activeObj.styles)) {
|
}
|
||||||
for (const character of Object.values(line)) {
|
}
|
||||||
if (character) delete (character as unknown as Record<string, unknown>)[styleName];
|
|
||||||
|
// If the box is empty and we are currently editing it, Fabric's invisible cursor
|
||||||
|
// cache will still stubbornly hold the old size unless we violently wipe it.
|
||||||
|
if (activeObj.isEditing && !activeObj.text) {
|
||||||
|
activeObj.styles = {};
|
||||||
|
if (activeObj.hiddenTextarea) {
|
||||||
|
if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`;
|
||||||
|
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cosmetic properties (bold, italic, color) work fine with inline styles
|
||||||
|
if (activeObj.isEditing) {
|
||||||
|
activeObj.setSelectionStyles({ [styleName]: value });
|
||||||
|
if (!activeObj.text) {
|
||||||
|
activeObj.set(styleName, value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If NOT editing, they selected the whole box. Update the base property.
|
||||||
|
activeObj.set(styleName, value);
|
||||||
|
|
||||||
|
// Clear any inline styles for this property so the base property actually takes effect!
|
||||||
|
if (activeObj.styles) {
|
||||||
|
for (const line in activeObj.styles) {
|
||||||
|
for (const char in activeObj.styles[line]) {
|
||||||
|
if (activeObj.styles[line][char]) {
|
||||||
|
delete (activeObj.styles[line][char] as Record<string, unknown>)[styleName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (activeObj.isEditing && !activeObj.text) {
|
// Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw!
|
||||||
activeObj.styles = {};
|
activeObj.dirty = true;
|
||||||
if (activeObj.hiddenTextarea) {
|
if ('_forceClearCache' in activeObj) {
|
||||||
if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`;
|
(activeObj as typeof activeObj & { _forceClearCache?: boolean })._forceClearCache = true;
|
||||||
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove manual height constraint so the box can grow with the new font size
|
||||||
|
delete activeObj.customHeight;
|
||||||
|
if (activeObj.initDimensions) activeObj.initDimensions();
|
||||||
|
activeObj.setCoords();
|
||||||
|
canvas.requestRenderAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
activeObj.dirty = true;
|
const finalGlobalValue = globalValue !== undefined ? globalValue : value;
|
||||||
if ('_forceClearCache' in activeObj) {
|
const newProps = { [globalPropName]: finalGlobalValue } as Partial<TextProps>;
|
||||||
(activeObj as ToolbarTextObject & { _forceClearCache?: boolean })._forceClearCache = true;
|
setDefaultTextProps(newProps);
|
||||||
}
|
|
||||||
delete activeObj.customHeight;
|
|
||||||
activeObj.initDimensions();
|
|
||||||
activeObj.setCoords();
|
|
||||||
canvas.requestRenderAll();
|
|
||||||
|
|
||||||
const defaultProps: Partial<TextProps> = {
|
|
||||||
[globalPropName]: globalValue !== undefined ? globalValue : value,
|
|
||||||
} as Partial<TextProps>;
|
|
||||||
const newProps: Partial<TextProps> = {
|
|
||||||
...(!rangeSelected || isStructural ? defaultProps : {}),
|
|
||||||
...(rangeSelected && !isStructural ? { styles: cloneInlineStyles(activeObj.styles) } : {}),
|
|
||||||
} as Partial<TextProps>;
|
|
||||||
setDefaultTextProps(defaultProps);
|
|
||||||
|
|
||||||
|
// Always update store so the toolbar displays the new value
|
||||||
if (!isDraft) {
|
if (!isDraft) {
|
||||||
updateAnnotation(annotationId, { props: { ...props, ...newProps } });
|
updateAnnotation(annotationId, { props: { ...props, ...newProps } });
|
||||||
} else {
|
} else {
|
||||||
useEditorStore.getState().setDraftAnnotation({ ...textAnn, props: { ...props, ...newProps } });
|
useEditorStore.getState().setDraftAnnotation({
|
||||||
|
...textAnn,
|
||||||
|
props: { ...props, ...newProps }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setStyleRevision((revision) => revision + 1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicate = () => {
|
const handleDuplicate = () => {
|
||||||
|
|
@ -272,15 +231,15 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
|
||||||
|
|
||||||
{/* Bold / Italic */}
|
{/* Bold / Italic */}
|
||||||
<button
|
<button
|
||||||
onClick={() => applyStyle('fontWeight', boldActive ? 'normal' : 'bold', 'bold', !boldActive)}
|
onClick={() => applyStyle('fontWeight', props.bold ? 'normal' : 'bold', 'bold')}
|
||||||
className={`w-7 h-7 flex items-center justify-center rounded ${boldActive ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
|
className={`w-7 h-7 flex items-center justify-center rounded ${props.bold ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
|
||||||
title="Bold"
|
title="Bold"
|
||||||
>
|
>
|
||||||
<Bold className="w-4 h-4" />
|
<Bold className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => applyStyle('fontStyle', italicActive ? 'normal' : 'italic', 'italic', !italicActive)}
|
onClick={() => applyStyle('fontStyle', props.italic ? 'normal' : 'italic', 'italic')}
|
||||||
className={`w-7 h-7 flex items-center justify-center rounded ${italicActive ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
|
className={`w-7 h-7 flex items-center justify-center rounded ${props.italic ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
|
||||||
title="Italic"
|
title="Italic"
|
||||||
>
|
>
|
||||||
<Italic className="w-4 h-4" />
|
<Italic className="w-4 h-4" />
|
||||||
|
|
@ -347,7 +306,7 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
|
||||||
{HIGHLIGHTS.map(c => (
|
{HIGHLIGHTS.map(c => (
|
||||||
<button
|
<button
|
||||||
key={c}
|
key={c}
|
||||||
onClick={() => { applyStyle('textBackgroundColor', c === 'transparent' ? undefined : c, 'highlightColor', c === 'transparent' ? null : c); setActiveDropdown(null); }}
|
onClick={() => { applyStyle('textBackgroundColor', c === 'transparent' ? undefined : c, 'highlightColor'); setActiveDropdown(null); }}
|
||||||
className={`w-6 h-6 rounded-sm border ${props.highlightColor === c || (c === 'transparent' && !props.highlightColor) ? 'border-blue-500 scale-110' : 'border-neutral-300 hover:scale-110'}`}
|
className={`w-6 h-6 rounded-sm border ${props.highlightColor === c || (c === 'transparent' && !props.highlightColor) ? 'border-blue-500 scale-110' : 'border-neutral-300 hover:scale-110'}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: c === 'transparent' ? '#ffffff' : c,
|
backgroundColor: c === 'transparent' ? '#ffffff' : c,
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||||
import { useEditorStore } from '../store';
|
import { useEditorStore } from '../store';
|
||||||
import type { ToolHandler } from '../../../lib/annotations/registry';
|
import type { ToolHandler } from '../../../lib/annotations/registry';
|
||||||
import type { Annotation, DrawAnnotation, DrawProps } from '../../../lib/annotations/types';
|
import type { Annotation, DrawAnnotation, DrawProps } from '../../../lib/annotations/types';
|
||||||
import {
|
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
||||||
pdfRectToScreen,
|
|
||||||
screenRectToPdf,
|
|
||||||
screenStrokeWidthToPdf,
|
|
||||||
storedStrokeWidthToScreen,
|
|
||||||
} from '../../../lib/coords';
|
|
||||||
import type { ViewportParams } from '../../../lib/coords';
|
import type { ViewportParams } from '../../../lib/coords';
|
||||||
|
|
||||||
type FabricAnnotationObject = fabric.FabricObject & {
|
type FabricAnnotationObject = fabric.FabricObject & {
|
||||||
|
|
@ -17,8 +12,6 @@ type FabricAnnotationObject = fabric.FabricObject & {
|
||||||
annotationProps?: DrawProps;
|
annotationProps?: DrawProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DRAW_STROKE_WIDTH = 4;
|
|
||||||
|
|
||||||
export const DrawTool: ToolHandler = {
|
export const DrawTool: ToolHandler = {
|
||||||
name: 'draw',
|
name: 'draw',
|
||||||
|
|
||||||
|
|
@ -26,7 +19,7 @@ export const DrawTool: ToolHandler = {
|
||||||
canvas.isDrawingMode = true;
|
canvas.isDrawingMode = true;
|
||||||
const brush = new fabric.PencilBrush(canvas);
|
const brush = new fabric.PencilBrush(canvas);
|
||||||
brush.color = '#000000';
|
brush.color = '#000000';
|
||||||
brush.width = DRAW_STROKE_WIDTH;
|
brush.width = 4;
|
||||||
canvas.freeDrawingBrush = brush;
|
canvas.freeDrawingBrush = brush;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -44,7 +37,6 @@ export const DrawTool: ToolHandler = {
|
||||||
borderColor: '#3b82f6',
|
borderColor: '#3b82f6',
|
||||||
cornerSize: 8,
|
cornerSize: 8,
|
||||||
padding: 5,
|
padding: 5,
|
||||||
strokeUniform: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const annotatedPath = pathObj as FabricAnnotationObject;
|
const annotatedPath = pathObj as FabricAnnotationObject;
|
||||||
|
|
@ -87,8 +79,7 @@ export const DrawTool: ToolHandler = {
|
||||||
]),
|
]),
|
||||||
svgPath: pathStr as string,
|
svgPath: pathStr as string,
|
||||||
strokeColor: pathObj.stroke as string,
|
strokeColor: pathObj.stroke as string,
|
||||||
strokeWidth: screenStrokeWidthToPdf(pathObj.strokeWidth ?? DRAW_STROKE_WIDTH, vp),
|
strokeWidth: pathObj.strokeWidth,
|
||||||
strokeWidthUnit: 'pdf',
|
|
||||||
opacity: pathObj.opacity,
|
opacity: pathObj.opacity,
|
||||||
};
|
};
|
||||||
annotatedPath.annotationProps = props;
|
annotatedPath.annotationProps = props;
|
||||||
|
|
@ -123,11 +114,10 @@ export const DrawTool: ToolHandler = {
|
||||||
pathObj = new fabric.Path(props.svgPath, {
|
pathObj = new fabric.Path(props.svgPath, {
|
||||||
fill: '',
|
fill: '',
|
||||||
stroke: props.strokeColor || '#000000',
|
stroke: props.strokeColor || '#000000',
|
||||||
strokeWidth: storedStrokeWidthToScreen(props.strokeWidth ?? DRAW_STROKE_WIDTH, props.strokeWidthUnit, vp),
|
strokeWidth: props.strokeWidth || 4,
|
||||||
strokeLineCap: 'round',
|
strokeLineCap: 'round',
|
||||||
strokeLineJoin: 'round',
|
strokeLineJoin: 'round',
|
||||||
opacity: props.opacity || 1,
|
opacity: props.opacity || 1,
|
||||||
strokeUniform: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fabric Paths have internal boundaries. We need to scale it to fit the rect.
|
// Fabric Paths have internal boundaries. We need to scale it to fit the rect.
|
||||||
|
|
@ -150,8 +140,7 @@ export const DrawTool: ToolHandler = {
|
||||||
width: screenRect.width,
|
width: screenRect.width,
|
||||||
height: screenRect.height,
|
height: screenRect.height,
|
||||||
stroke: props.strokeColor || '#000000',
|
stroke: props.strokeColor || '#000000',
|
||||||
strokeWidth: storedStrokeWidthToScreen(props.strokeWidth ?? DRAW_STROKE_WIDTH, props.strokeWidthUnit, vp),
|
strokeWidth: props.strokeWidth || 4,
|
||||||
strokeUniform: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,7 +155,6 @@ export const DrawTool: ToolHandler = {
|
||||||
borderColor: '#3b82f6',
|
borderColor: '#3b82f6',
|
||||||
cornerSize: 8,
|
cornerSize: 8,
|
||||||
padding: 5,
|
padding: 5,
|
||||||
strokeUniform: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
canvas.add(pathObj);
|
canvas.add(pathObj);
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,12 @@ import { v4 as uuidv4 } from 'uuid';
|
||||||
import { useEditorStore } from '../store';
|
import { useEditorStore } from '../store';
|
||||||
import type { ToolHandler } from '../../../lib/annotations/registry';
|
import type { ToolHandler } from '../../../lib/annotations/registry';
|
||||||
import type { Annotation, ShapeAnnotation, ShapeProps } from '../../../lib/annotations/types';
|
import type { Annotation, ShapeAnnotation, ShapeProps } from '../../../lib/annotations/types';
|
||||||
import {
|
import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
|
||||||
pdfRectToScreen,
|
|
||||||
screenRectToPdf,
|
|
||||||
screenStrokeWidthToPdf,
|
|
||||||
storedStrokeWidthToScreen,
|
|
||||||
} from '../../../lib/coords';
|
|
||||||
import type { ViewportParams } from '../../../lib/coords';
|
import type { ViewportParams } from '../../../lib/coords';
|
||||||
|
|
||||||
type PointerEvent = fabric.TPointerEventInfo;
|
type PointerEvent = fabric.TPointerEventInfo;
|
||||||
type Draft = { object: fabric.FabricObject; start: { x: number; y: number }; kind: ShapeProps['kind'] };
|
type Draft = { object: fabric.FabricObject; start: { x: number; y: number }; kind: ShapeProps['kind'] };
|
||||||
const drafts = new WeakMap<fabric.Canvas, Draft>();
|
const drafts = new WeakMap<fabric.Canvas, Draft>();
|
||||||
const SHAPE_STROKE_WIDTH = 2;
|
|
||||||
|
|
||||||
function pointer(event: PointerEvent, canvas: fabric.Canvas) {
|
function pointer(event: PointerEvent, canvas: fabric.Canvas) {
|
||||||
return event.scenePoint ?? canvas.getScenePoint(event.e);
|
return event.scenePoint ?? canvas.getScenePoint(event.e);
|
||||||
|
|
@ -29,7 +23,6 @@ function style(object: fabric.FabricObject) {
|
||||||
cornerStrokeColor: '#3b82f6',
|
cornerStrokeColor: '#3b82f6',
|
||||||
borderColor: '#3b82f6',
|
borderColor: '#3b82f6',
|
||||||
cornerSize: 8,
|
cornerSize: 8,
|
||||||
strokeUniform: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,22 +54,14 @@ function createArrow(start: { x: number; y: number }, end: { x: number; y: numbe
|
||||||
top: geometry.top,
|
top: geometry.top,
|
||||||
fill: 'transparent',
|
fill: 'transparent',
|
||||||
stroke: '#111827',
|
stroke: '#111827',
|
||||||
strokeWidth: SHAPE_STROKE_WIDTH,
|
strokeWidth: 2,
|
||||||
strokeUniform: true,
|
|
||||||
originX: 'left',
|
originX: 'left',
|
||||||
originY: 'top',
|
originY: 'top',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createObject(kind: ShapeProps['kind'], start: { x: number; y: number }) {
|
function createObject(kind: ShapeProps['kind'], start: { x: number; y: number }) {
|
||||||
const common = {
|
const common = { left: start.x, top: start.y, fill: 'transparent', stroke: '#111827', strokeWidth: 2 };
|
||||||
left: start.x,
|
|
||||||
top: start.y,
|
|
||||||
fill: 'transparent',
|
|
||||||
stroke: '#111827',
|
|
||||||
strokeWidth: SHAPE_STROKE_WIDTH,
|
|
||||||
strokeUniform: true,
|
|
||||||
};
|
|
||||||
if (kind === 'ellipse') return new fabric.Ellipse({ ...common, rx: 0, ry: 0 });
|
if (kind === 'ellipse') return new fabric.Ellipse({ ...common, rx: 0, ry: 0 });
|
||||||
if (kind === 'arrow') return createArrow(start, start);
|
if (kind === 'arrow') return createArrow(start, start);
|
||||||
if (kind === 'line') return new fabric.Line([0, 0, 0, 0], common);
|
if (kind === 'line') return new fabric.Line([0, 0, 0, 0], common);
|
||||||
|
|
@ -152,8 +137,7 @@ export const ShapeTool: ToolHandler = {
|
||||||
kind: draft.kind,
|
kind: draft.kind,
|
||||||
strokeColor: '#111827',
|
strokeColor: '#111827',
|
||||||
fillColor: 'transparent',
|
fillColor: 'transparent',
|
||||||
strokeWidth: screenStrokeWidthToPdf(SHAPE_STROKE_WIDTH, vp),
|
strokeWidth: 2,
|
||||||
strokeWidthUnit: 'pdf',
|
|
||||||
};
|
};
|
||||||
if (draft.kind === 'line' || draft.kind === 'arrow') {
|
if (draft.kind === 'line' || draft.kind === 'arrow') {
|
||||||
const width = Math.max(bounds.width, 1);
|
const width = Math.max(bounds.width, 1);
|
||||||
|
|
@ -193,8 +177,7 @@ export const ShapeTool: ToolHandler = {
|
||||||
const common = {
|
const common = {
|
||||||
fill: props.fillColor === 'transparent' ? 'transparent' : props.fillColor,
|
fill: props.fillColor === 'transparent' ? 'transparent' : props.fillColor,
|
||||||
stroke: props.strokeColor,
|
stroke: props.strokeColor,
|
||||||
strokeWidth: storedStrokeWidthToScreen(props.strokeWidth, props.strokeWidthUnit, vp),
|
strokeWidth: props.strokeWidth,
|
||||||
strokeUniform: true,
|
|
||||||
};
|
};
|
||||||
let object: fabric.FabricObject;
|
let object: fabric.FabricObject;
|
||||||
if (props.kind === 'ellipse') {
|
if (props.kind === 'ellipse') {
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type * as fabric from 'fabric';
|
|
||||||
import { signatureTextBounds } from './signatureGeometry';
|
|
||||||
|
|
||||||
describe('signatureTextBounds', () => {
|
|
||||||
it('uses scaled text geometry without Fabric padding or phantom stroke width', () => {
|
|
||||||
const object = {
|
|
||||||
width: 182.88,
|
|
||||||
height: 54.24,
|
|
||||||
scaleX: 1.5,
|
|
||||||
scaleY: 2,
|
|
||||||
padding: 5,
|
|
||||||
strokeWidth: 1,
|
|
||||||
getCenterPoint: () => ({ x: 300, y: 150 }),
|
|
||||||
} as unknown as fabric.FabricObject;
|
|
||||||
|
|
||||||
const bounds = signatureTextBounds(object);
|
|
||||||
expect(bounds.left).toBeCloseTo(162.84);
|
|
||||||
expect(bounds.top).toBeCloseTo(95.76);
|
|
||||||
expect(bounds.width).toBeCloseTo(274.32);
|
|
||||||
expect(bounds.height).toBeCloseTo(108.48);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -5,7 +5,6 @@ import type { ToolHandler } from '../../../lib/annotations/registry';
|
||||||
import type { Annotation, SignatureAnnotation, SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types';
|
import type { Annotation, SignatureAnnotation, SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types';
|
||||||
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
||||||
import type { ViewportParams } from '../../../lib/coords';
|
import type { ViewportParams } from '../../../lib/coords';
|
||||||
import { signatureTextBounds } from './signatureGeometry';
|
|
||||||
|
|
||||||
type SignatureProps = SignatureDrawProps | SignatureTypeProps;
|
type SignatureProps = SignatureDrawProps | SignatureTypeProps;
|
||||||
type FabricSignatureObject = fabric.FabricObject & {
|
type FabricSignatureObject = fabric.FabricObject & {
|
||||||
|
|
@ -17,7 +16,6 @@ type FabricSignatureObject = fabric.FabricObject & {
|
||||||
let previewObj: fabric.FabricObject | null = null;
|
let previewObj: fabric.FabricObject | null = null;
|
||||||
let currentPreviewCanvas: fabric.Canvas | null = null;
|
let currentPreviewCanvas: fabric.Canvas | null = null;
|
||||||
let isCreatingPreview = false;
|
let isCreatingPreview = false;
|
||||||
const SIGNATURE_FONT_SIZE = 48;
|
|
||||||
|
|
||||||
const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: SignatureProps) => {
|
const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: SignatureProps) => {
|
||||||
isCreatingPreview = true;
|
isCreatingPreview = true;
|
||||||
|
|
@ -36,10 +34,9 @@ const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: S
|
||||||
previewObj = null;
|
previewObj = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const fontSize = props.fontSize ?? SIGNATURE_FONT_SIZE;
|
|
||||||
previewObj = new fabric.Text(props.text, {
|
previewObj = new fabric.Text(props.text, {
|
||||||
fontFamily: props.fontFamily,
|
fontFamily: props.fontFamily,
|
||||||
fontSize: fontSize * vp.scale,
|
fontSize: 48 * vp.scale,
|
||||||
fill: props.color,
|
fill: props.color,
|
||||||
originX: 'center',
|
originX: 'center',
|
||||||
originY: 'center',
|
originY: 'center',
|
||||||
|
|
@ -144,7 +141,7 @@ export const SignatureTool: ToolHandler = {
|
||||||
left: pointer.x,
|
left: pointer.x,
|
||||||
top: pointer.y,
|
top: pointer.y,
|
||||||
fontFamily: typeProps.fontFamily,
|
fontFamily: typeProps.fontFamily,
|
||||||
fontSize: (typeProps.fontSize ?? SIGNATURE_FONT_SIZE) * vp.scale,
|
fontSize: 48 * vp.scale,
|
||||||
fill: typeProps.color,
|
fill: typeProps.color,
|
||||||
originX: 'center',
|
originX: 'center',
|
||||||
originY: 'center',
|
originY: 'center',
|
||||||
|
|
@ -162,19 +159,16 @@ export const SignatureTool: ToolHandler = {
|
||||||
padding: 5,
|
padding: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
const annotationProps: SignatureProps = props.mode === 'type'
|
|
||||||
? { ...props, fontSize: props.fontSize ?? SIGNATURE_FONT_SIZE }
|
|
||||||
: props;
|
|
||||||
const annotatedObject = fabricObj as FabricSignatureObject;
|
const annotatedObject = fabricObj as FabricSignatureObject;
|
||||||
annotatedObject.annotationType = 'signature';
|
annotatedObject.annotationType = 'signature';
|
||||||
annotatedObject.annotationProps = annotationProps;
|
annotatedObject.annotationProps = props;
|
||||||
|
|
||||||
canvas.add(fabricObj);
|
canvas.add(fabricObj);
|
||||||
canvas.setActiveObject(fabricObj);
|
canvas.setActiveObject(fabricObj);
|
||||||
canvas.requestRenderAll();
|
canvas.requestRenderAll();
|
||||||
|
|
||||||
// Convert to canonical space
|
// Convert to canonical space
|
||||||
const bounds = props.mode === 'type' ? signatureTextBounds(fabricObj) : fabricObj.getBoundingRect();
|
const bounds = fabricObj.getBoundingRect();
|
||||||
const pdfRect = screenRectToPdf({
|
const pdfRect = screenRectToPdf({
|
||||||
x: bounds.left,
|
x: bounds.left,
|
||||||
y: bounds.top,
|
y: bounds.top,
|
||||||
|
|
@ -189,7 +183,7 @@ export const SignatureTool: ToolHandler = {
|
||||||
rect: pdfRect,
|
rect: pdfRect,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
z: 0,
|
z: 0,
|
||||||
props: annotationProps,
|
props: props,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
@ -233,7 +227,6 @@ export const SignatureTool: ToolHandler = {
|
||||||
originX: 'left',
|
originX: 'left',
|
||||||
originY: 'top',
|
originY: 'top',
|
||||||
fontFamily: typeProps.fontFamily,
|
fontFamily: typeProps.fontFamily,
|
||||||
fontSize: (typeProps.fontSize ?? SIGNATURE_FONT_SIZE) * vp.scale,
|
|
||||||
fill: typeProps.color,
|
fill: typeProps.color,
|
||||||
});
|
});
|
||||||
// Set scale manually to match the saved rect
|
// Set scale manually to match the saved rect
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import type * as fabric from 'fabric';
|
|
||||||
|
|
||||||
export function signatureTextBounds(object: fabric.FabricObject) {
|
|
||||||
const center = object.getCenterPoint();
|
|
||||||
const width = (object.width || 0) * Math.abs(object.scaleX || 1);
|
|
||||||
const height = (object.height || 0) * Math.abs(object.scaleY || 1);
|
|
||||||
return {
|
|
||||||
left: center.x - width / 2,
|
|
||||||
top: center.y - height / 2,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -47,10 +47,7 @@ export interface DrawProps {
|
||||||
paths: [number, number][];
|
paths: [number, number][];
|
||||||
svgPath?: string;
|
svgPath?: string;
|
||||||
strokeColor: string;
|
strokeColor: string;
|
||||||
/** Canonical PDF-point width for new annotations. */
|
|
||||||
strokeWidth: number;
|
strokeWidth: number;
|
||||||
/** Omitted on legacy records whose width was stored in screen pixels. */
|
|
||||||
strokeWidthUnit?: 'pdf' | 'screen';
|
|
||||||
opacity: number;
|
opacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,8 +67,6 @@ export interface SignatureTypeProps {
|
||||||
text: string;
|
text: string;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
color: string;
|
color: string;
|
||||||
/** Canonical PDF-point font size for new typed signatures. */
|
|
||||||
fontSize?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignatureAnnotation extends AnnotationBase {
|
export interface SignatureAnnotation extends AnnotationBase {
|
||||||
|
|
@ -104,10 +99,7 @@ export interface ShapeProps {
|
||||||
kind: 'rect' | 'ellipse' | 'line' | 'arrow';
|
kind: 'rect' | 'ellipse' | 'line' | 'arrow';
|
||||||
strokeColor: string;
|
strokeColor: string;
|
||||||
fillColor: string;
|
fillColor: string;
|
||||||
/** Canonical PDF-point width for new annotations. */
|
|
||||||
strokeWidth: number;
|
strokeWidth: number;
|
||||||
/** Omitted on legacy records whose width was stored in screen pixels. */
|
|
||||||
strokeWidthUnit?: 'pdf' | 'screen';
|
|
||||||
/** Normalized endpoints, retained for line/arrow direction. */
|
/** Normalized endpoints, retained for line/arrow direction. */
|
||||||
start?: [number, number];
|
start?: [number, number];
|
||||||
end?: [number, number];
|
end?: [number, number];
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,6 @@ import {
|
||||||
screenToPdf,
|
screenToPdf,
|
||||||
pdfRectToScreen,
|
pdfRectToScreen,
|
||||||
screenRectToPdf,
|
screenRectToPdf,
|
||||||
pdfStrokeWidthToScreen,
|
|
||||||
screenStrokeWidthToPdf,
|
|
||||||
storedStrokeWidthToScreen,
|
|
||||||
} from './coords';
|
} from './coords';
|
||||||
import type {
|
import type {
|
||||||
ViewportParams,
|
ViewportParams,
|
||||||
|
|
@ -107,16 +104,4 @@ describe('Coordinate Transforms', () => {
|
||||||
);
|
);
|
||||||
expect(retina).toEqual(standard);
|
expect(retina).toEqual(standard);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips canonical stroke widths at every supported zoom', () => {
|
|
||||||
for (const scale of [0.5, 1, 2]) {
|
|
||||||
const vp: ViewportParams = { scale, rotation: 0, canonicalWidth: W, canonicalHeight: H };
|
|
||||||
const pdfWidth = screenStrokeWidthToPdf(4, vp);
|
|
||||||
|
|
||||||
expect(pdfWidth).toBeCloseTo(4 / scale, 6);
|
|
||||||
expect(pdfStrokeWidthToScreen(pdfWidth, vp)).toBeCloseTo(4, 6);
|
|
||||||
expect(storedStrokeWidthToScreen(pdfWidth, 'pdf', vp)).toBeCloseTo(4, 6);
|
|
||||||
expect(storedStrokeWidthToScreen(4, undefined, vp)).toBe(4);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,27 +12,6 @@ export interface ViewportParams {
|
||||||
dpr?: number; // Backing-store density; never part of CSS geometry
|
dpr?: number; // Backing-store density; never part of CSS geometry
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StrokeWidthUnit = 'pdf' | 'screen';
|
|
||||||
|
|
||||||
/** Convert a Fabric/CSS stroke width into canonical PDF-point space. */
|
|
||||||
export function screenStrokeWidthToPdf(width: number, vp: ViewportParams): number {
|
|
||||||
return width / vp.scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert a canonical PDF-point stroke width into Fabric/CSS pixels. */
|
|
||||||
export function pdfStrokeWidthToScreen(width: number, vp: ViewportParams): number {
|
|
||||||
return width * vp.scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Preserve legacy records whose stroke width was stored in screen pixels. */
|
|
||||||
export function storedStrokeWidthToScreen(
|
|
||||||
width: number,
|
|
||||||
unit: StrokeWidthUnit | undefined,
|
|
||||||
vp: ViewportParams,
|
|
||||||
): number {
|
|
||||||
return unit === 'pdf' ? pdfStrokeWidthToScreen(width, vp) : width;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
|
export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
|
||||||
// Screen points are CSS pixels. Device-pixel-ratio only controls the PDF.js
|
// Screen points are CSS pixels. Device-pixel-ratio only controls the PDF.js
|
||||||
// backing canvas; including it here would make Fabric objects overflow the
|
// backing canvas; including it here would make Fabric objects overflow the
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
"""Convert the browser's bundled WOFF2 fonts to PDF-embeddable TTF files."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from fontTools.ttLib import TTFont
|
|
||||||
|
|
||||||
|
|
||||||
FONT_SPECS = (
|
|
||||||
("outfit", "outfit-latin-400-normal.woff2", "outfit-400.ttf"),
|
|
||||||
("outfit", "outfit-latin-700-normal.woff2", "outfit-700.ttf"),
|
|
||||||
("plus-jakarta-sans", "plus-jakarta-sans-latin-400-normal.woff2", "plus-jakarta-sans-400.ttf"),
|
|
||||||
("plus-jakarta-sans", "plus-jakarta-sans-latin-700-normal.woff2", "plus-jakarta-sans-700.ttf"),
|
|
||||||
(
|
|
||||||
"plus-jakarta-sans",
|
|
||||||
"plus-jakarta-sans-latin-400-italic.woff2",
|
|
||||||
"plus-jakarta-sans-400-italic.ttf",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"plus-jakarta-sans",
|
|
||||||
"plus-jakarta-sans-latin-700-italic.woff2",
|
|
||||||
"plus-jakarta-sans-700-italic.ttf",
|
|
||||||
),
|
|
||||||
("allura", "allura-latin-400-normal.woff2", "allura-400.ttf"),
|
|
||||||
("caveat", "caveat-latin-400-normal.woff2", "caveat-400.ttf"),
|
|
||||||
(
|
|
||||||
"dancing-script",
|
|
||||||
"dancing-script-latin-400-normal.woff2",
|
|
||||||
"dancing-script-400.ttf",
|
|
||||||
),
|
|
||||||
("great-vibes", "great-vibes-latin-400-normal.woff2", "great-vibes-400.ttf"),
|
|
||||||
("sacramento", "sacramento-latin-400-normal.woff2", "sacramento-400.ttf"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main(source_root: Path, output_root: Path) -> None:
|
|
||||||
output_root.mkdir(parents=True, exist_ok=True)
|
|
||||||
for package, source_name, output_name in FONT_SPECS:
|
|
||||||
source = source_root / package / "files" / source_name
|
|
||||||
if not source.is_file():
|
|
||||||
raise FileNotFoundError(source)
|
|
||||||
font = TTFont(source)
|
|
||||||
font.flavor = None
|
|
||||||
font.save(output_root / output_name)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
raise SystemExit("usage: convert_frontend_fonts.py SOURCE_ROOT OUTPUT_ROOT")
|
|
||||||
main(Path(sys.argv[1]), Path(sys.argv[2]))
|
|
||||||
|
|
@ -58,8 +58,7 @@
|
||||||
"description": "Array of [x, y] points in rect-local space"
|
"description": "Array of [x, y] points in rect-local space"
|
||||||
},
|
},
|
||||||
"strokeColor": { "type": "string", "default": "#000000" },
|
"strokeColor": { "type": "string", "default": "#000000" },
|
||||||
"strokeWidth": { "type": "number", "default": 2, "description": "Stroke width in canonical PDF points for records with strokeWidthUnit=pdf" },
|
"strokeWidth": { "type": "number", "default": 2 },
|
||||||
"strokeWidthUnit": { "type": "string", "enum": ["pdf", "screen"], "description": "Optional unit marker; omitted legacy records use screen pixels" },
|
|
||||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 1 }
|
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 1 }
|
||||||
},
|
},
|
||||||
"required": ["paths"]
|
"required": ["paths"]
|
||||||
|
|
@ -79,7 +78,6 @@
|
||||||
"mode": { "type": "string", "const": "type" },
|
"mode": { "type": "string", "const": "type" },
|
||||||
"text": { "type": "string" },
|
"text": { "type": "string" },
|
||||||
"fontFamily": { "type": "string" },
|
"fontFamily": { "type": "string" },
|
||||||
"fontSize": { "type": "number", "description": "Typed signature font size in canonical PDF points" },
|
|
||||||
"color": { "type": "string", "default": "#000000" }
|
"color": { "type": "string", "default": "#000000" }
|
||||||
},
|
},
|
||||||
"required": ["mode", "text", "fontFamily"]
|
"required": ["mode", "text", "fontFamily"]
|
||||||
|
|
@ -106,8 +104,7 @@
|
||||||
"kind": { "type": "string", "enum": ["rect", "ellipse", "line", "arrow"] },
|
"kind": { "type": "string", "enum": ["rect", "ellipse", "line", "arrow"] },
|
||||||
"strokeColor": { "type": "string", "default": "#000000" },
|
"strokeColor": { "type": "string", "default": "#000000" },
|
||||||
"fillColor": { "type": "string", "default": "transparent" },
|
"fillColor": { "type": "string", "default": "transparent" },
|
||||||
"strokeWidth": { "type": "number", "default": 2, "description": "Stroke width in canonical PDF points for records with strokeWidthUnit=pdf" },
|
"strokeWidth": { "type": "number", "default": 2 },
|
||||||
"strokeWidthUnit": { "type": "string", "enum": ["pdf", "screen"], "description": "Optional unit marker; omitted legacy records use screen pixels" },
|
|
||||||
"start": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
|
"start": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
|
||||||
"end": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
|
"end": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue