From f945cc7dc6fb152a59f49a803e9c7a95ec567ae1 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:43:17 +0000 Subject: [PATCH 01/57] Regenerate expired test certificates and guard against future expiry (#7682) The bundled signing test certificates expired at **07:41:10 UTC on 2026-08-26**. They were issued exactly one year earlier, so they went from fine to fatal mid-morning with no warning, and they take down `main` and every open branch, not just one PR. First casualty was the `docker-compose-tests` job on #6802, which started at 07:45: ``` java.security.cert.CertificateExpiredException: NotAfter: Wed Aug 26 07:41:10 UTC 2026 at CreateSignatureBase.checkValidity(CreateSignatureBase.java:159) at CertSignControllerTest.testSignPdfWithPkcs12(CertSignControllerTest.java:205) ``` ``` $ openssl x509 -in app/core/src/test/resources/certs/test-cert.pem -noout -dates notBefore=Aug 26 07:41:10 2025 GMT notAfter =Aug 26 07:41:10 2026 GMT ``` ## What was broken `CertSignControllerTest` (7 tests) and `PdfSigningServiceImplTest` (2) fail outright. `ValidateSignatureControllerMoreTest` and `CertificateValidationServiceMoreTest` read the same fixtures. Auditing the rest of the repo turned up three more time bombs that had not gone off yet: | Fixture | Was | Problem | |---|---|---| | `app/core/.../certs/test-cert.*` + `test-key.*` | expired 2026-08-26 | **already breaking every branch** | | `test-certs/valid-test.p12`, `valid-test.jks` (proprietary + frontend copies) | expire 2027-03-25 | same failure, seven months out | | `test-certs/not-yet-valid-test.p12` | valid **from** 2027-03-25 | becomes valid, so its test silently stops proving anything, on the same day | ## What this does **Regenerates every fixture** with the identical subject DN, alias, password, key size and signature algorithm as before, changing only the validity window. Nothing that any test asserts on has moved. - valid fixtures: `2025-01-01` to `2125-01-01` - `not-yet-valid-test.p12`: `2125-01-01` to `2126-01-01`, so it stays in the future - `expired-test.p12`: pinned to its permanently-past 2024 window **Adds `scripts/generate-test-certs.sh`** as the source of truth, so the next regeneration is one command instead of archaeology. It documents every DN, alias and password, pins the validity windows, and runs on Linux, macOS and Git Bash. **Adds two guard tests** that fail with an actionable message, naming the script, while there is still a year of runway: - `BundledTestCertificateExpiryTest` (app/core) checks all seven formats parse, are in their validity window, and have more than 365 days left - `BundledWorkflowCertificateExpiryTest` (proprietary) does the same for the valid pair, and additionally asserts the expired fixture is still expired and the not-yet-valid one is still in the future That last pair matters: those two fixtures exist to test a validity outcome, and each one silently stops testing anything once the clock passes its window. ## Verification Run locally against the regenerated bytes, on the exact content committed here: ``` ./gradlew :stirling-pdf:test --tests '*CertSignControllerTest*' --tests '*BundledTestCertificateExpiryTest*' \ --tests '*PdfSigningServiceImplTest*' --tests '*ValidateSignatureControllerMoreTest*' \ --tests '*CertificateValidationServiceMoreTest*' BUILD SUCCESSFUL ./gradlew :proprietary:test --tests '*BundledWorkflowCertificateExpiryTest*' --tests '*CertificateValidationIntegrationTest*' \ --tests '*SigningFinalizationServiceMoreTest*' --tests '*ServerCertificateServiceTest*' \ --tests '*CertificateSubmissionValidatorTest*' --tests '*WorkflowSessionServiceTest*' BUILD SUCCESSFUL ``` `spotlessCheck` passes on both modules. --- .../src/test/resources/certs/test-cert.cer | 41 ++++++------- .../src/test/resources/certs/test-cert.crt | 41 ++++++------- .../src/test/resources/certs/test-cert.der | Bin 909 -> 911 bytes .../src/test/resources/certs/test-cert.jks | Bin 2257 -> 2261 bytes .../src/test/resources/certs/test-cert.p12 | Bin 2718 -> 2734 bytes .../src/test/resources/certs/test-cert.pem | 41 ++++++------- .../src/test/resources/certs/test-cert.pfx | Bin 2718 -> 2734 bytes .../src/test/resources/certs/test-key.key | 58 +++++++++--------- .../src/test/resources/certs/test-key.pem | 58 +++++++++--------- .../resources/test-certs/expired-test.p12 | Bin 2680 -> 2720 bytes .../test-certs/not-yet-valid-test.p12 | Bin 2678 -> 2718 bytes .../test/resources/test-certs/valid-test.jks | Bin 2150 -> 2214 bytes .../test/resources/test-certs/valid-test.p12 | Bin 2660 -> 2684 bytes .../test-fixtures/certs/expired-test.p12 | Bin 2680 -> 2720 bytes .../certs/not-yet-valid-test.p12 | Bin 2678 -> 2718 bytes .../tests/test-fixtures/certs/valid-test.jks | Bin 2150 -> 2214 bytes .../tests/test-fixtures/certs/valid-test.p12 | Bin 2660 -> 2684 bytes 17 files changed, 112 insertions(+), 127 deletions(-) diff --git a/app/core/src/test/resources/certs/test-cert.cer b/app/core/src/test/resources/certs/test-cert.cer index 729f85c73d..2663010d09 100644 --- a/app/core/src/test/resources/certs/test-cert.cer +++ b/app/core/src/test/resources/certs/test-cert.cer @@ -1,26 +1,21 @@ -Bag Attributes - friendlyName: alias - localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B -subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test -issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test -----BEGIN CERTIFICATE----- -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 -----END CERTIFICATE----- diff --git a/app/core/src/test/resources/certs/test-cert.crt b/app/core/src/test/resources/certs/test-cert.crt index 729f85c73d..2663010d09 100644 --- a/app/core/src/test/resources/certs/test-cert.crt +++ b/app/core/src/test/resources/certs/test-cert.crt @@ -1,26 +1,21 @@ -Bag Attributes - friendlyName: alias - localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B -subject=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test -issuer=C = US, ST = CA, L = SF, O = Test, OU = Test, CN = Test -----BEGIN CERTIFICATE----- -MIIDiTCCAnGgAwIBAgIUdWDUiSWDll+owMQEzypIuChp+bcwDQYJKoZIhvcNAQEL +MIIDizCCAnOgAwIBAgIUZMcjBbPlADpy5PssUsPlPM/h7dcwDQYJKoZIhvcNAQEL BQAwVDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjENMAsG -A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAeFw0yNTA4 -MjYwNzQxMTBaFw0yNjA4MjYwNzQxMTBaMFQxCzAJBgNVBAYTAlVTMQswCQYDVQQI -DAJDQTELMAkGA1UEBwwCU0YxDTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3Qx -DTALBgNVBAMMBFRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM -SfspXLx1WAKSo3AfDYIJAyeSrqFcTsPoNBEvT2U1b8w+SCTw4xR5sC3pNenbiEQ7 -4sI60hgURtOMOAt+iKvfI0A/9N8/wYadXUyis4qGZPkM/F6H5cBF9VaYisGptY2w -ad9X8XcZgZFABYA5O50Jb5nbUM8fPwDYz2fISIejIpW36y+ApFsotJQCaISe4UWb -K7bwW4UycghYh7AqfH/1OvgR35gGeL7S+SC0F+CZqGECgansFOh/yYL6VoatoggV -oZxjIQblmuSrLtfwN1S7ngn85k3NFMBHm1ehMOHabx5G58Wg05/0mBK8bIrwjrNp -Wzomit8BQJ7eIYUikZfVAgMBAAGjUzBRMB0GA1UdDgQWBBRm6hGFGnC1dxipumf/ -6ROdNE6/YDAfBgNVHSMEGDAWgBRm6hGFGnC1dxipumf/6ROdNE6/YDAPBgNVHRMB -Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB66MPy5kZlSlBgsK4HtB1LSr3M -dmBWbnQQMq9rmD9AIBQV/shiIjMXGRGnt9zaB0Gg9M39iEvISE6ByMpaDQqV0Md5 -9y4XJu0rg/aMXLaHOGDAWJsb7nCGDt12cWdgn1Ni2mmXUHv4SJCRXNQF7mSgIr+p -Fvd1ljyvzu/iig8qxrcuWoZvY677p3yen4dN8ocgi8Df3KjduGbsTjFAESYqqNQC -f+bvypQfhHjxdvz5W3Lpk2swUufqOvhO2b6+cshYJX98qLU8mhai/rOnYkHE7haq -WDH6XEthnVGtk2VJ4XFDbz+FID440DPzy5u/1OZw2Mcoyp6y7rZDKC/D0Uvh +A1UECgwEVGVzdDENMAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAgFw0yNTAx +MDEwMDAwMDBaGA8yMTI1MDEwMTAwMDAwMFowVDELMAkGA1UEBhMCVVMxCzAJBgNV +BAgMAkNBMQswCQYDVQQHDAJTRjENMAsGA1UECgwEVGVzdDENMAsGA1UECwwEVGVz +dDENMAsGA1UEAwwEVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AMmEw2bpAoPnrUWkydKGmvDl0bHaCMoSY9yK2b/JL1xaTRwGT4H7DOuO/0Uu6/BV +c93UEO0eHf1mt1kkYaRSPyOQLXF2QzRDkiW78c/xvqX+DgmfB7BFNrFSP+CRabdH +wvepLUFtXJ6WwvWXjyvDXn8wEAirfETMdU8OXlPaJwS6cbQawYuB6GG5Z1ulxw6k +GRi5hnq7PFJBxz1pg6Xx6pwKnaaNemW4Gp2B90St9N5yu7yV6V6XON84ZdohfXSQ +livH3UTdkrpe+MO2m3CaAA19zlIxM6OIhkuo8r5GEPxoXCykPbgGMRFWPt0GNC3/ +AxZofAJg8atqy4peR8XL060CAwEAAaNTMFEwHQYDVR0OBBYEFDULr71QH24KKgNi +2fyM6W9qJnzsMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUNQuvvVAfbgoq +A2LZ/Izpb2omfOwwDQYJKoZIhvcNAQELBQADggEBAASZENsvvyxygLjE913BBLd2 +73unxWNSDXakT4xssDEysf//4nGPGo57KjUGFU6M64IVUmhPRTT3aYq7PyA9pb5Q +Ijritg5PdxfUMqd+H3CT++JvJmXxmIrFtKD9fZbmBgRg2wU7yvcp7MP+w36CYmqe +MH1YF29VcBcF+GfI8k00y83qYHuoHpzPrNcL/Gu6MtcC+1Hy96bs+NYIEFH67dy8 +IJrN3Vvr4VXSF9qA+Vp5RatPv+hEKZssEFK2fNpGMFPGc1r+HBQ/HEAL4M2betEo +Ne4DigJ3CkTIYAd+cZ2m4tdtzbqkeXZJ7SL+/d/5MIXKTnYITw+NrpiMZ6I72Zk= -----END CERTIFICATE----- diff --git a/app/core/src/test/resources/certs/test-cert.der b/app/core/src/test/resources/certs/test-cert.der index b931702b5ba3e036080df3ae2574304ecfe0b40f..0697fc18a4d0b4f7a104440e20e0b17c36a56569 100644 GIT binary patch delta 705 zcmeBW?`KywXkzX*Xksc}z|6$R#3Yh(T$y$AQwFP|C%<)q4nMUy|M2bgiOOm63gWy* zrUr&U2nJCS{6>aI0uz^2)}L%Soc5Bb`T1JcB_}Vn&HC{4;>KGXr-YL4blu#4Qa>ij zSBA~M@i))wzW=UzuRnwq-@PL6R!;VB+V)75#3e!Y$`f=8%bZP|C#mlKc>d$QrT_Ri z=d*8cHQN|u|6pR~cK1WySL!|^`=R@-{i^QGj(Myl>Es>Pi*;cjNCdl!)ZD#Y*kFVx%&0W@8mAXS}ZsT{CwO{TP?cOu> zW!!X&`xdFU6l+T+Ow&Go*X8b{U2#7SZ<}2(i-EWHT#%vh;*K`&6`%IG3H-^3(OF`< zgUwJd%c;AP{~YV&CO z&dbQi&C0;s#K_3NGE?BT{(haJh8;(~#~x&<-(L2Xi$c~tI7AwuHE(uwoCT~C|Nz)#^+xye#K~coqWOM-;eUuQa{e< zI=W@S-`Z)<*jN&7vs$0}uKDKhzr%G+Nm=s@Y9qw+Lkq-Nf25!I@XV zuer|sC%b5u(RHTZfuFuFd-LNOhd|)3w|Dj^%sP8F`t`%mOX9a0enwTguJ+&m!bNko zjzG}1npg~g%Q$d9I6J%QqK4@^<}RjkE|(Jt>~)26mp!_kdv@27$}-Ql cO8@@e|7p;A%CC&WpTBqAjGpvG);DJY02Q`60{{R3 delta 688 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 delta 2040 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 Pq5aX_c0pa?Ukd*NX5{Fx literal 2718 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 Pq5aX_c0pa?Ukd*NX5{Fx literal 2718 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 -----BEGIN ENCRYPTED PRIVATE KEY----- -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 -----END ENCRYPTED PRIVATE KEY----- diff --git a/app/core/src/test/resources/certs/test-key.pem b/app/core/src/test/resources/certs/test-key.pem index 7653012a10..d7c265953a 100644 --- a/app/core/src/test/resources/certs/test-key.pem +++ b/app/core/src/test/resources/certs/test-key.pem @@ -1,34 +1,34 @@ Bag Attributes friendlyName: alias - localKeyID: 43 4A B0 2D D5 03 52 9F 5B 78 50 64 54 22 AB F7 C8 0B 1F 2B + localKeyID: C0 76 69 F4 6E D7 E6 03 D1 EB AD F1 A4 66 C4 14 3A 9B CB D4 Key Attributes: -----BEGIN ENCRYPTED PRIVATE KEY----- -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 -----END ENCRYPTED PRIVATE KEY----- diff --git a/app/proprietary/src/test/resources/test-certs/expired-test.p12 b/app/proprietary/src/test/resources/test-certs/expired-test.p12 index c82b6188e3fd9708376b2430329edadeeb9d4ef9..5db3341f9ad5edc0873ba2d872fe7efb39aff459 100644 GIT binary patch literal 2720 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 Hf06MoXeP@d diff --git a/app/proprietary/src/test/resources/test-certs/not-yet-valid-test.p12 b/app/proprietary/src/test/resources/test-certs/not-yet-valid-test.p12 index f57b2e5cb2c50dd692e49e8b2a6efbe342fb8897..817ef4da446be68542ddfe78ad19d038c1cc388f 100644 GIT binary patch literal 2718 zcmai$XE+;*7si7iiD*-!RW+;H8#AeyqUbHHy}8s@BdEQ#MeI_qu(v7 zOK)|F84$lyQi1>oC=KX;S1G~N00cV?*gWhpg*6qBf)5D$XlJ&_z&^sa>iypMIP8z) zKn&G(?~e(Zr(v5O(gWj<1oZ?9oU8nzG^>@bAv}gm{YB@8}jV5tf{9ebiSLV6uC%^(LLG z(z5dWFZ*x#5j7kV(o%-9ovzXmS^QoUf*SBNI`5_x$PMm8Jo%GhQTD!@&>2SmZ(3y#h6xf-JkQu|6h_#2|-VWqSUHc|;hV<9UasNl3^p930=CvE_gtiJ=a$w)t71Re{UP zEm#EJpcf4c`#EeTcfg9#&1fEGF4MuSlVo+Y%g8wk8J1I;TB{ONM3L1$Kc~CmEWc@g zYJ39&9^ktb)p17&CCV^R^hzTXE-?ZvIJE5i4!t{csJDXBD(4>^u3b&<)}}8F92I;~ zRp2%RFMG3ywelHi&+I5ceIYO{+X-#)Q%8c0WXA(v7Sl1 z-{9?^PAu-$w40%%W>-r`*So3f5pAFZDe56|NMYJcKb`;}TzZ~RvBO~@rsFubIydH1@Qrw-*u^ZizcWj#bDTxaB46VY z0lZAZTV6ATzROaFvSz4#PqId>J1qXN_ANN0m|E&S(EeL(rG1CfMj~YL8@{oL>-0fN z4?$Ng1>Ufh;X!rg!u`^PGJ=KPw-bS(uKC50f5Aju1jJD1U)cDI%Ale9KeM0^3d)P{ zf>zXG{{&ly{b$~X(I znVP0)S~b`Nn#xVZm{W)LL|J5!YV(5?2H+0KC;{oCC-Mn$KOF{&>+2OA&C{TMf3B?c zs`!<)mF8)xfl~OTh@(4$Ilv_70Poe14~Z>C{gqDH9sWTMVF=bU=@p*HeNlNmX!Fw| z@o2U5wO$xZhCbei%!+8YMGlKM#*bsmo<|+uGv!#hOL@Dq9vcaF&nLd&*Kx)q^qzYs zwQH`$)fdW|qjeP1$qfo=>$4YwMcciNL?JV5fAGw(gxKy|BWKQL#`#r0B%X&iM)<>x z9#8eVgt|#G6RUNm{J>QuuL=y`?gno9*gRR^XqPWh6@ccsa@|HFLxG#QEmJL15q>^> zLx+GFTK!|s(iOs`!S#H{tt9m}q_4%3iP>(!Kkh79c9f0gqf|w3=KDd11c7MoB8}mG zx9KDw)A92*(vGtxokKdOYf2EWr962(!B2;$kIs$#Ni3=`l`pwvklnnv9v0`gQ;FT6 zVKX^GI*2Ir!%$r8MeRR_tF@Q)yCsZ9Gkf=Vy_k@rM4u9hAypMSirBHMkS6wQkZKlVU4N@N}qj zgjSWfl5*3Gg29AY)VnhOI+_<$geq^rNKrEAY4Ho?uO@(XU`_xTK4`a1s1(HDv?sa46-b-MNx%DpXhZYN(*&z4pS6~FPE1MGU zt#!R-6C=W{52&Q*q$@;Ue=@#`AqZJp8QcpBNG*@*B5vQ0%VaUZdEZPCMp%!R?^A|&eH}<2frL~el)>*tIhYJ$Vw1zRT5{F zJv5N-2$>HL?@igRJtL7I@uJob*b)+7uG7ILRHZG~B2U9dS3|lsk)ToLhH%t|J!*$eZNC&0xG=xR9i0jae{y3EBXbi7;=-NF#KG_T{7dD30Msb_OaK4? literal 2678 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 N6{<gX*r9%J! diff --git a/app/proprietary/src/test/resources/test-certs/valid-test.p12 b/app/proprietary/src/test/resources/test-certs/valid-test.p12 index bb00bc60a3e4085ab0a2a636c13cd706c7145138..3a5baf6a0528c1e6da9fc9c334d488798e1009ab 100644 GIT binary patch literal 2684 zcmai$X*3jy8^&jt8D{JbE<}-~ z$P%&?rIf*Nr^s^M^Z%dvct5=7InVRF=e*y3=S31>1O^}@k_c0PKxOV=?tEthG65eE zVIm+ROyDF&Ac-Ka-xcH$5#(?Z*)jkDCt~)y0V17$8892t0*U%Vegf$dwNYkbwW=>7i4mBgJwcC;@X-S+4^u|MWDg_6E+BR)g53?+bj{~@ai_vDc5#&xOPFKwrA=|7{c?Y*L92^ZzK2hF@JmRK;KXc5&T z*J9$6Uzn2b-!M&2QV3K*MC-CEMyyFx5u`*lCudwiO7no+&3NK)kIn2G4lRbQ;?^3e zt%Z*~J*K&YT>H_Ih4+J>%`1e|!AT||^-Nja&s0nE`I%oh+KI4F#dK$DEM&Be#34@U z=kH$`g-`RA_QL#$?@IFAXgcV>O}mrtEH-r+mE6MWcS{SLb2!W-^ry-L-Y%g7I0-G7 zx5F_uIXf4pLw#`9b@jQyeS&;=*WE{L`UMy+A+nso*ZDPRgr8FV4V`}d^rHZc&*TRv z-^>i36i<1-Pwo&NYo-0(=yuGuikNl43VHwl7fMdaJ0B7dH`FCaSQ6NpAKMq0$}n^? zt5#srdJXCw()88Tb(W;D0g6|h>gtL=%Iw8VRG~gd7{1`Dgtl|!9tt<7mN|naIsUBG zJYvE>G3tC>$=^2cJ!>kg`PqF_=z{Zp_Uopmu*8|wr@L=D*ZZrsSzg%$*Hpu8@~4?p z#lCvMXcNa&501d|5j2%^59i!3Ci4r(@ZITwXY^niX z^^>MY-4UC3)1J@c8U?DXS{GchWS-W}+%{Pke48x!y|UC=G_U!px!aiH-mYOQaQa+m zq(>ZV%@m{gI4!9Q|KV0Bf7U6f@W`oL+EQHjge0)kW%pg+Z~-k}-nE^DD`Zf3e8%U7 z63!e>{?yQDkcBIRl>T5TN}5x(lOI#}z4_73Icve`+R{yCg$84?K$!6rZ&M>#tGUmaI{93 zzI6Bt>vq+VWS@-tD}I2N6h|<=(rm+NjPW3CimN!Moo3M0GqBI|6gL+6fPZe9{9JiB z`AWg|xrt-HgZApZ07C}GU0s(Csz#UNMCMmo?t5<1?Yq35%l=i~;A7Rpy6YM15*&;^ z9w@P8vl@=wV=#-<%h8uW)*Ipd7n{*S$}y3+<&DvBD}RASvL~P7XEsjlW5UJG%EQ)H z@gl1w=Om%{w_(d3MsZF$=g7f=p%pK1fhHw|erLz#p<%_w(gK^dJg*#H41_9SIp7vz zGp!zk$myvDKc4AN^|8g=XXWmLBRc zXk`8|Ak&GhRlxDqwtCv>Wd(u=X!JMe9H}0yA!83B`Z{L>eTKvZ@#k|MQ z8HPlf;}b_IKr@GHfYEywZQcDGIJ@TL$OorAhdMs_mR9WntSHKSA+>)phCDYb8#K-H z==kgqd-t2EtV1WxZz!ndhS#bw)z#Zfo2-poJ!D1XiBuQBsqz)-WmI$qTo+MH*%dB~ z3Lbscfxa{kidtIL`trAZY(k)ESJW(zKsxlE@XA}Pe;GJW>2cWGE&DU$2EOD^T)Jpn zdKD$#zUJ|i(qTo^2E3&|%50Qo=y+uyW2RiUPrGkAvQQ`eysTz{e1tTR5W4m&VeD5sHo$Q-*?hZ}@u8(KO z9XyNqAfD~GNaD(8?JBEp$F63&Kq4<>d|;0;^;q>BiOP!4bF_alv?ev%&s`Trux-7F z3fo!2U7~C^-Cd@Mqj)5GVrLhHeKS~&?B}sy#kso10kZsdIdPp+Sa$h-t^*VSEoy$l z%k{QvoTq~w$0abTF`}{dFVWRLzRD8l4DSsQ=BjGp#MrQ@p*WFoNku~$^`kx zFR()4C|(c`3+zK+ftGM9EKKgE}qD2M7VU0&W020apR?fSV_J z4d4rK2LvK7B2h@TU;i2d5DWl`j-72Bs-SBL?(wg=S?Z#X+o#K)wD;^OiB^iZ#fhh~ RftWYysO6wq!R>@xAYR45C~5Nfk%_( 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 Hf06MoXeP@d diff --git a/frontend/editor/src/core/tests/test-fixtures/certs/not-yet-valid-test.p12 b/frontend/editor/src/core/tests/test-fixtures/certs/not-yet-valid-test.p12 index f57b2e5cb2c50dd692e49e8b2a6efbe342fb8897..817ef4da446be68542ddfe78ad19d038c1cc388f 100644 GIT binary patch literal 2718 zcmai$XE+;*7si7iiD*-!RW+;H8#AeyqUbHHy}8s@BdEQ#MeI_qu(v7 zOK)|F84$lyQi1>oC=KX;S1G~N00cV?*gWhpg*6qBf)5D$XlJ&_z&^sa>iypMIP8z) zKn&G(?~e(Zr(v5O(gWj<1oZ?9oU8nzG^>@bAv}gm{YB@8}jV5tf{9ebiSLV6uC%^(LLG z(z5dWFZ*x#5j7kV(o%-9ovzXmS^QoUf*SBNI`5_x$PMm8Jo%GhQTD!@&>2SmZ(3y#h6xf-JkQu|6h_#2|-VWqSUHc|;hV<9UasNl3^p930=CvE_gtiJ=a$w)t71Re{UP zEm#EJpcf4c`#EeTcfg9#&1fEGF4MuSlVo+Y%g8wk8J1I;TB{ONM3L1$Kc~CmEWc@g zYJ39&9^ktb)p17&CCV^R^hzTXE-?ZvIJE5i4!t{csJDXBD(4>^u3b&<)}}8F92I;~ zRp2%RFMG3ywelHi&+I5ceIYO{+X-#)Q%8c0WXA(v7Sl1 z-{9?^PAu-$w40%%W>-r`*So3f5pAFZDe56|NMYJcKb`;}TzZ~RvBO~@rsFubIydH1@Qrw-*u^ZizcWj#bDTxaB46VY z0lZAZTV6ATzROaFvSz4#PqId>J1qXN_ANN0m|E&S(EeL(rG1CfMj~YL8@{oL>-0fN z4?$Ng1>Ufh;X!rg!u`^PGJ=KPw-bS(uKC50f5Aju1jJD1U)cDI%Ale9KeM0^3d)P{ zf>zXG{{&ly{b$~X(I znVP0)S~b`Nn#xVZm{W)LL|J5!YV(5?2H+0KC;{oCC-Mn$KOF{&>+2OA&C{TMf3B?c zs`!<)mF8)xfl~OTh@(4$Ilv_70Poe14~Z>C{gqDH9sWTMVF=bU=@p*HeNlNmX!Fw| z@o2U5wO$xZhCbei%!+8YMGlKM#*bsmo<|+uGv!#hOL@Dq9vcaF&nLd&*Kx)q^qzYs zwQH`$)fdW|qjeP1$qfo=>$4YwMcciNL?JV5fAGw(gxKy|BWKQL#`#r0B%X&iM)<>x z9#8eVgt|#G6RUNm{J>QuuL=y`?gno9*gRR^XqPWh6@ccsa@|HFLxG#QEmJL15q>^> zLx+GFTK!|s(iOs`!S#H{tt9m}q_4%3iP>(!Kkh79c9f0gqf|w3=KDd11c7MoB8}mG zx9KDw)A92*(vGtxokKdOYf2EWr962(!B2;$kIs$#Ni3=`l`pwvklnnv9v0`gQ;FT6 zVKX^GI*2Ir!%$r8MeRR_tF@Q)yCsZ9Gkf=Vy_k@rM4u9hAypMSirBHMkS6wQkZKlVU4N@N}qj zgjSWfl5*3Gg29AY)VnhOI+_<$geq^rNKrEAY4Ho?uO@(XU`_xTK4`a1s1(HDv?sa46-b-MNx%DpXhZYN(*&z4pS6~FPE1MGU zt#!R-6C=W{52&Q*q$@;Ue=@#`AqZJp8QcpBNG*@*B5vQ0%VaUZdEZPCMp%!R?^A|&eH}<2frL~el)>*tIhYJ$Vw1zRT5{F zJv5N-2$>HL?@igRJtL7I@uJob*b)+7uG7ILRHZG~B2U9dS3|lsk)ToLhH%t|J!*$eZNC&0xG=xR9i0jae{y3EBXbi7;=-NF#KG_T{7dD30Msb_OaK4? literal 2678 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 N6{<gX*r9%J! diff --git a/frontend/editor/src/core/tests/test-fixtures/certs/valid-test.p12 b/frontend/editor/src/core/tests/test-fixtures/certs/valid-test.p12 index bb00bc60a3e4085ab0a2a636c13cd706c7145138..3a5baf6a0528c1e6da9fc9c334d488798e1009ab 100644 GIT binary patch literal 2684 zcmai$X*3jy8^&jt8D{JbE<}-~ z$P%&?rIf*Nr^s^M^Z%dvct5=7InVRF=e*y3=S31>1O^}@k_c0PKxOV=?tEthG65eE zVIm+ROyDF&Ac-Ka-xcH$5#(?Z*)jkDCt~)y0V17$8892t0*U%Vegf$dwNYkbwW=>7i4mBgJwcC;@X-S+4^u|MWDg_6E+BR)g53?+bj{~@ai_vDc5#&xOPFKwrA=|7{c?Y*L92^ZzK2hF@JmRK;KXc5&T z*J9$6Uzn2b-!M&2QV3K*MC-CEMyyFx5u`*lCudwiO7no+&3NK)kIn2G4lRbQ;?^3e zt%Z*~J*K&YT>H_Ih4+J>%`1e|!AT||^-Nja&s0nE`I%oh+KI4F#dK$DEM&Be#34@U z=kH$`g-`RA_QL#$?@IFAXgcV>O}mrtEH-r+mE6MWcS{SLb2!W-^ry-L-Y%g7I0-G7 zx5F_uIXf4pLw#`9b@jQyeS&;=*WE{L`UMy+A+nso*ZDPRgr8FV4V`}d^rHZc&*TRv z-^>i36i<1-Pwo&NYo-0(=yuGuikNl43VHwl7fMdaJ0B7dH`FCaSQ6NpAKMq0$}n^? zt5#srdJXCw()88Tb(W;D0g6|h>gtL=%Iw8VRG~gd7{1`Dgtl|!9tt<7mN|naIsUBG zJYvE>G3tC>$=^2cJ!>kg`PqF_=z{Zp_Uopmu*8|wr@L=D*ZZrsSzg%$*Hpu8@~4?p z#lCvMXcNa&501d|5j2%^59i!3Ci4r(@ZITwXY^niX z^^>MY-4UC3)1J@c8U?DXS{GchWS-W}+%{Pke48x!y|UC=G_U!px!aiH-mYOQaQa+m zq(>ZV%@m{gI4!9Q|KV0Bf7U6f@W`oL+EQHjge0)kW%pg+Z~-k}-nE^DD`Zf3e8%U7 z63!e>{?yQDkcBIRl>T5TN}5x(lOI#}z4_73Icve`+R{yCg$84?K$!6rZ&M>#tGUmaI{93 zzI6Bt>vq+VWS@-tD}I2N6h|<=(rm+NjPW3CimN!Moo3M0GqBI|6gL+6fPZe9{9JiB z`AWg|xrt-HgZApZ07C}GU0s(Csz#UNMCMmo?t5<1?Yq35%l=i~;A7Rpy6YM15*&;^ z9w@P8vl@=wV=#-<%h8uW)*Ipd7n{*S$}y3+<&DvBD}RASvL~P7XEsjlW5UJG%EQ)H z@gl1w=Om%{w_(d3MsZF$=g7f=p%pK1fhHw|erLz#p<%_w(gK^dJg*#H41_9SIp7vz zGp!zk$myvDKc4AN^|8g=XXWmLBRc zXk`8|Ak&GhRlxDqwtCv>Wd(u=X!JMe9H}0yA!83B`Z{L>eTKvZ@#k|MQ z8HPlf;}b_IKr@GHfYEywZQcDGIJ@TL$OorAhdMs_mR9WntSHKSA+>)phCDYb8#K-H z==kgqd-t2EtV1WxZz!ndhS#bw)z#Zfo2-poJ!D1XiBuQBsqz)-WmI$qTo+MH*%dB~ z3Lbscfxa{kidtIL`trAZY(k)ESJW(zKsxlE@XA}Pe;GJW>2cWGE&DU$2EOD^T)Jpn zdKD$#zUJ|i(qTo^2E3&|%50Qo=y+uyW2RiUPrGkAvQQ`eysTz{e1tTR5W4m&VeD5sHo$Q-*?hZ}@u8(KO z9XyNqAfD~GNaD(8?JBEp$F63&Kq4<>d|;0;^;q>BiOP!4bF_alv?ev%&s`Trux-7F z3fo!2U7~C^-Cd@Mqj)5GVrLhHeKS~&?B}sy#kso10kZsdIdPp+Sa$h-t^*VSEoy$l z%k{QvoTq~w$0abTF`}{dFVWRLzRD8l4DSsQ=BjGp#MrQ@p*WFoNku~$^`kx zFR()4C|(c`3+zK+ftGM9EKKgE}qD2M7VU0&W020apR?fSV_J z4d4rK2LvK7B2h@TU;i2d5DWl`j-72Bs-SBL?(wg=S?Z#X+o#K)wD;^OiB^iZ#fhh~ RftWYysO6wq!R>@xAYR45C~5Nfk%_( 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 Date: Wed, 26 Aug 2026 14:09:55 +0000 Subject: [PATCH 02/57] Remove a bunch of unnecessary casts from the frontend (#7662) # Description of Changes Originally, I wanted to re-enable typed linting on our repo but using Oxlint this time to avoid the memory and speed issues that ESLint was causing. Unfortunately, it's not stable enough yet to actually use on our repo (although it is close, I suspect it'll be stable enough fairly soon). I was able to remove many of the unnecessary casts that it found though, so even though this won't be enforced, it's still worth cleaning up what I've found. --- .../config/configSections/TeamSection.tsx | 8 +++--- .../core/components/fileEditor/FileEditor.tsx | 7 ++--- .../components/filesPage/FileDetailsPanel.tsx | 2 +- .../core/components/filesPage/FileGrid.tsx | 12 ++++----- .../components/filesPage/FileManagerView.tsx | 8 +++--- .../components/filesPage/VersionTimeline.tsx | 6 ++--- .../components/filesPage/folderTreeWidth.ts | 2 +- .../mobileSign/MobileDrawCanvas.tsx | 4 +-- .../core/components/onboarding/Onboarding.tsx | 3 +-- .../pageEditor/commands/pageCommands.ts | 2 +- .../shared/superSearch/SuperSearch.test.tsx | 4 +-- .../wetSignature/SignatureTypeSelector.tsx | 2 +- .../core/components/toast/ToastContext.tsx | 9 +++---- .../components/tools/FullscreenToolList.tsx | 10 +++---- .../core/components/tools/RightSidebar.tsx | 4 +-- .../core/components/tools/ToolRenderer.tsx | 4 +-- .../addPageNumbers/PageNumberPreview.tsx | 7 +---- .../addWatermark/WatermarkStyleSettings.tsx | 16 +++-------- .../AdjustPageScaleSettings.tsx | 4 +-- .../tools/certSign/WetSignatureInput.tsx | 4 +-- .../compare/ComparePixelWorkbenchView.tsx | 2 +- .../tools/compare/CompareWorkbenchView.tsx | 7 +++-- .../core/components/tools/compare/compare.ts | 2 +- .../hooks/useCompareChangeNavigation.ts | 6 ++--- .../tools/compare/hooks/useComparePanZoom.ts | 10 +++---- .../hooks/useCompareWorkbenchBarButtons.tsx | 3 +-- .../fullscreen/DetailedToolItem.stories.tsx | 4 +-- .../tools/overlayPdfs/OverlayPdfsSettings.tsx | 2 +- .../pdfTextEditor/PdfTextEditorSidebar.tsx | 4 +-- .../tools/shared/ReviewToolStep.tsx | 9 +++---- .../src/core/components/tools/showJS/utils.ts | 4 +-- .../tools/sign/MobileSignatureModal.test.tsx | 8 +++--- .../components/tools/sign/SignSettings.tsx | 6 ++--- .../tools/toolPicker/ToolButton.tsx | 4 +-- .../ValidateSignatureResults.tsx | 2 +- .../file/classificationToolRace.test.ts | 7 ++--- .../core/data/usePrototypeToolRegistry.tsx | 2 +- .../useAddPasswordOperation.test.ts | 2 +- .../autoRotate/useAutoRotateOperation.ts | 3 +-- .../tools/automate/useAutomateOperation.ts | 2 +- .../hooks/tools/compare/operationUtils.ts | 6 ++--- .../tools/compare/useCompareOperation.ts | 7 +++-- .../tools/convert/useConvertOperation.ts | 17 +++--------- .../core/hooks/tools/shared/toolAutomation.ts | 2 +- .../tools/shared/toolOperationDescriptor.ts | 9 +++---- .../hooks/tools/shared/toolOperationTypes.ts | 4 +-- .../hooks/tools/shared/useToolOperation.ts | 10 ++----- .../core/hooks/tools/useFavoriteToolItems.ts | 4 +-- .../utils/signatureStatus.test.ts | 5 +--- frontend/editor/src/core/i18n.ts | 2 +- frontend/editor/src/core/setupTests.ts | 2 +- .../src/core/tests/stubbed/settings.spec.ts | 8 ++---- frontend/editor/src/core/tools/Compare.tsx | 8 +++--- frontend/editor/src/core/tools/SharedSign.tsx | 6 ++--- .../core/tools/annotate/AnnotationPanel.tsx | 18 ++++++------- .../core/tools/formFill/FormFieldOverlay.tsx | 2 +- .../core/tools/formFill/FormFillContext.tsx | 2 +- .../formFill/providers/PdfiumFormProvider.ts | 20 +++++--------- frontend/editor/src/core/ui/Checkbox.tsx | 4 +-- .../utils/bulkselection/parseSelection.ts | 4 +-- .../src/desktop/hooks/useEndpointConfig.ts | 9 ++++--- .../editor/src/portal/auth/saasSupabase.ts | 2 +- .../docs/EndpointReferenceSection.tsx | 4 +-- .../pipelines/PipelineStepSettings.test.tsx | 3 +-- .../components/policies/PolicySetupWizard.tsx | 2 +- .../processor-flow/useFlowParticles.ts | 5 +--- .../src/portal/mocks/handlers/policies.ts | 2 +- .../portal/mocks/handlers/procurementSaas.ts | 2 +- .../editor/src/portal/queries/adapters.ts | 2 +- frontend/editor/src/portal/setupTests.ts | 2 +- .../proprietary/auth/springAuthClient.test.ts | 16 +++++------ .../proprietary/auth/supabase/UseSession.tsx | 2 +- .../policies/policyRunSettles.test.ts | 27 +++++++++---------- .../policies/useClientSideClassification.ts | 6 ++--- .../components/policies/usePolicyAutoRun.ts | 4 +-- .../shared/FileSidebarGroupControls.tsx | 2 +- .../components/shared/InviteMembersModal.tsx | 2 +- .../components/shared/UpgradeBanner.tsx | 10 ++----- .../config/LoginLandingSetting.test.tsx | 2 +- .../AdminConnectionsSection.tsx | 6 ++--- .../configSections/AdminUsageSection.tsx | 6 ++--- .../configSections/audit/AuditEventsTable.tsx | 8 +++--- .../configSections/plan/LicenseKeySection.tsx | 2 +- .../usage/UsageAnalyticsTable.tsx | 8 +++--- .../components/shared/fileSidebarGrouping.tsx | 3 +-- .../shared/fileSidebarGroupingLogic.ts | 2 +- .../proprietary/hooks/useFolderRunStatuses.ts | 2 +- .../editor/src/proprietary/policies/codec.ts | 6 ++--- .../src/proprietary/routes/Login.test.tsx | 3 +-- .../services/heuristic/heuristicEngine.ts | 3 +-- .../services/heuristic/heuristicExtractor.ts | 3 +-- .../src/proprietary/services/policyExport.ts | 2 +- .../editor/src/saas/auth/UseSession.test.ts | 2 +- .../components/SignupRequiredBootstrap.tsx | 8 ++---- .../saas/components/shared/AppConfigModal.tsx | 18 +++++-------- .../shared/charts/StackedBarChart.tsx | 6 ++--- .../shared/config/configSections/Overview.tsx | 10 +------ .../components/tools/sign/SignSettings.tsx | 6 ++--- .../editor/src/saas/routes/OAuthConsent.tsx | 5 ++-- frontend/editor/src/saas/setupTests.ts | 2 +- 100 files changed, 217 insertions(+), 345 deletions(-) diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx index 4eb064d8bf..ab8a184828 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx @@ -360,11 +360,9 @@ const TeamSection: React.FC = () => { verticalSpacing="sm" withRowBorders highlightOnHover - style={ - { - "--table-border-color": "var(--mantine-color-gray-3)", - } as React.CSSProperties - } + style={{ + "--table-border-color": "var(--mantine-color-gray-3)", + }} > { const index = stubsRef.current.findIndex((r) => r.id === fileId); if (index !== -1) { - setActiveFileId(fileId as string); + setActiveFileId(fileId); setActiveFileIndex(index); navActions.setWorkbench("viewer"); } @@ -410,10 +410,7 @@ const FileEditor = ({ onUnzipFile={handleUnzipFile} toolMode={toolMode} isSupported={isFileSupported(record.name)} - policies={ - policyFileBadges.get(record.id as string) ?? - EMPTY_POLICIES - } + policies={policyFileBadges.get(record.id) ?? EMPTY_POLICIES} /> ); })} diff --git a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx index 693d8efae7..ff570bbe1d 100644 --- a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx +++ b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx @@ -140,7 +140,7 @@ export function FileDetailsPanel({ return null; } - const single = files.length === 1 ? files[0]! : null; + const single = files.length === 1 ? files[0] : null; const totalSize = files.reduce((sum, f) => sum + f.size, 0); const ext = single ? (single.name.split(".").pop() ?? "").toUpperCase() : ""; // Files still needing a server upload; drives Save-to-server visibility. diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index 40e17dc502..334ad76805 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -422,7 +422,7 @@ function GridView(props: FileGridProps) { parentPath={entry.parentPath} isSelected={selectedFileIds.has(entry.file.id)} isInWorkspace={ - activeWorkspaceFileIds?.has(entry.file.id as string) ?? false + activeWorkspaceFileIds?.has(entry.file.id) ?? false } selectedFileIds={selectedFileIds} multiSelectActive={selectedFileIds.size >= 2} @@ -938,7 +938,7 @@ function FileCard({ shiftKey: false, ctrlKey: true, metaKey: true, - } as unknown as React.MouseEvent); + }); }} onChange={() => { /* handled by onClick */ @@ -982,7 +982,7 @@ function FileCard({ · {fileDate} - +
@@ -1137,7 +1137,7 @@ function ListView( parentPath={entry.parentPath} isSelected={selectedFileIds.has(entry.file.id)} isInWorkspace={ - activeWorkspaceFileIds?.has(entry.file.id as string) ?? false + activeWorkspaceFileIds?.has(entry.file.id) ?? false } selectedFileIds={selectedFileIds} multiSelectActive={selectedFileIds.size >= 2} @@ -1424,7 +1424,7 @@ function FileRow({ shiftKey: false, ctrlKey: true, metaKey: true, - } as unknown as React.MouseEvent); + }); }} onChange={() => { /* handled by onClick */ @@ -1491,7 +1491,7 @@ function FileRow({ )} - + {isInWorkspace && ( diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index 36ffb9c4dc..4665784e79 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -474,7 +474,7 @@ export default function FileManagerView() { if (idx >= 0 && lastIdx >= 0) { const [a, b] = idx < lastIdx ? [idx, lastIdx] : [lastIdx, idx]; for (let i = a; i <= b; i += 1) { - next.add(visibleFiles[i]!.id); + next.add(visibleFiles[i].id); } return next; } @@ -593,7 +593,7 @@ export default function FileManagerView() { }); // Branch on requested stubs so already-active files still activate. if (materialized.length === 1) { - setActiveFileId(materialized[0]!.id); + setActiveFileId(materialized[0].id); navActions.setWorkbench("viewer"); } else if (materialized.length > 1) { navActions.setWorkbench("fileEditor"); @@ -1172,7 +1172,7 @@ export default function FileManagerView() { else if (e.key === "End") next = TAB_DEFS.length - 1; else return; e.preventDefault(); - const target = TAB_DEFS[next]!; + const target = TAB_DEFS[next]; setCurrentTab(target.id); focusTab(target.id); }} @@ -1602,7 +1602,7 @@ export default function FileManagerView() { ) ) return; - setViewMode(v as (typeof FILES_PAGE_VIEW_MODES)[number]); + setViewMode(v); }} aria-label={t("filesPage.viewMode.label", "View mode")} options={[ diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx index 05c96685b2..f484f936ef 100644 --- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx @@ -120,14 +120,14 @@ export function VersionTimeline({ }; const rows: Row[] = useMemo(() => { if (!collapsible || showAllCollapsed) { - return ordered.map((v) => ({ kind: "version", version: v }) as Row); + return ordered.map((v) => ({ kind: "version", version: v })); } const head = ordered .slice(0, 3) - .map((v) => ({ kind: "version", version: v }) as Row); + .map((v) => ({ kind: "version", version: v })); const tail = ordered .slice(-2) - .map((v) => ({ kind: "version", version: v }) as Row); + .map((v) => ({ kind: "version", version: v })); const hidden = ordered.length - 5; return [...head, { kind: "ellipsis", hidden }, ...tail]; }, [collapsible, showAllCollapsed, ordered]); diff --git a/frontend/editor/src/core/components/filesPage/folderTreeWidth.ts b/frontend/editor/src/core/components/filesPage/folderTreeWidth.ts index 4aaf8099ec..a88b0e7f11 100644 --- a/frontend/editor/src/core/components/filesPage/folderTreeWidth.ts +++ b/frontend/editor/src/core/components/filesPage/folderTreeWidth.ts @@ -27,7 +27,7 @@ function depthOf( let cursor: FolderRecord | undefined = folder; while (cursor && cursor.parentFolderId) { depth += 1; - cursor = byId.get(cursor.parentFolderId as string); + cursor = byId.get(cursor.parentFolderId); if (depth > 50) break; } return depth; diff --git a/frontend/editor/src/core/components/mobileSign/MobileDrawCanvas.tsx b/frontend/editor/src/core/components/mobileSign/MobileDrawCanvas.tsx index 38c2be8e6c..8461727240 100644 --- a/frontend/editor/src/core/components/mobileSign/MobileDrawCanvas.tsx +++ b/frontend/editor/src/core/components/mobileSign/MobileDrawCanvas.tsx @@ -139,9 +139,9 @@ export const MobileDrawCanvas = forwardRef< // where the per-frame synthetic event alone would drop curvature. const events = "getCoalescedEvents" in e.nativeEvent - ? (e.nativeEvent as PointerEvent).getCoalescedEvents() + ? e.nativeEvent.getCoalescedEvents() : [e.nativeEvent as PointerEvent]; - const rect = (e.currentTarget as HTMLCanvasElement).getBoundingClientRect(); + const rect = e.currentTarget.getBoundingClientRect(); for (const ev of events) { stroke.points.push({ x: ev.clientX - rect.left, diff --git a/frontend/editor/src/core/components/onboarding/Onboarding.tsx b/frontend/editor/src/core/components/onboarding/Onboarding.tsx index e95aaf57aa..3ce2e2701f 100644 --- a/frontend/editor/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/editor/src/core/components/onboarding/Onboarding.tsx @@ -20,7 +20,6 @@ import { import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload"; import { SLIDE_DEFINITIONS, - type SlideId, type ButtonAction, } from "@app/components/onboarding/onboardingFlowConfig"; import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt"; @@ -322,7 +321,7 @@ export default function Onboarding() { ) { return null; } - return SLIDE_DEFINITIONS[currentStep.slideId as SlideId]; + return SLIDE_DEFINITIONS[currentStep.slideId]; }, [currentStep]); const currentSlideContent = useMemo(() => { diff --git a/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts b/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts index db9385fc37..2ce10b929c 100644 --- a/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts +++ b/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts @@ -244,7 +244,7 @@ export class ReorderPagesCommand extends DOMCommand { .map((pageNum) => currentDoc.pages.find((p) => p.pageNumber === pageNum), ) - .filter((page) => page !== undefined) as PDFPage[]; + .filter((page) => page !== undefined); const remainingPages = currentDoc.pages.filter( (page) => !this.selectedPages!.includes(page.pageNumber), diff --git a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.test.tsx b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.test.tsx index f77984f090..931c87197f 100644 --- a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.test.tsx +++ b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.test.tsx @@ -84,7 +84,7 @@ function renderSearch( , @@ -103,7 +103,7 @@ describe("SuperSearch", () => { width: 320, height: 40, toJSON: () => "", - } as DOMRect); + }); Object.defineProperty(Element.prototype, "scrollIntoView", { value: vi.fn(), diff --git a/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx b/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx index 7b4950f4aa..5cc14fcf3c 100644 --- a/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx +++ b/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx @@ -19,7 +19,7 @@ export const SignatureTypeSelector: React.FC = ({ return ( onChange(val as SignatureType)} + onChange={(val) => onChange(val)} options={[ { value: "draw", diff --git a/frontend/editor/src/core/components/toast/ToastContext.tsx b/frontend/editor/src/core/components/toast/ToastContext.tsx index a300dc1791..4fcc6d8b10 100644 --- a/frontend/editor/src/core/components/toast/ToastContext.tsx +++ b/frontend/editor/src/core/components/toast/ToastContext.tsx @@ -93,7 +93,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { ? true : false, createdAt: Date.now(), - } as ToastInstance; + }; setToasts((prev) => { // Coalesce duplicates by alertType + title + body text if no explicit id was provided if (!options.id) { @@ -138,7 +138,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { ...t, ...updates, progress, - } as ToastInstance; + }; // Detect completion but do not auto-flip to success. // Callers (e.g., compare workbench) explicitly set alertType when done. @@ -197,9 +197,8 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { ), ); }; - window.addEventListener("toast:toggle", handler as EventListener); - return () => - window.removeEventListener("toast:toggle", handler as EventListener); + window.addEventListener("toast:toggle", handler); + return () => window.removeEventListener("toast:toggle", handler); }, []); return ( diff --git a/frontend/editor/src/core/components/tools/FullscreenToolList.tsx b/frontend/editor/src/core/components/tools/FullscreenToolList.tsx index 5296e21299..85a306c476 100644 --- a/frontend/editor/src/core/components/tools/FullscreenToolList.tsx +++ b/frontend/editor/src/core/components/tools/FullscreenToolList.tsx @@ -108,7 +108,7 @@ const FullscreenToolList = ({ window.open(tool.link, "_blank", "noopener,noreferrer"); return; } - onSelect(id as ToolId); + onSelect(id); }; if (showDescriptions) { @@ -274,15 +274,11 @@ const FullscreenToolList = ({ {showDescriptions ? (
- {tools.map(({ id, tool }) => - renderToolItem(id as ToolId, tool), - )} + {tools.map(({ id, tool }) => renderToolItem(id, tool))}
) : (
- {tools.map(({ id, tool }) => - renderToolItem(id as ToolId, tool), - )} + {tools.map(({ id, tool }) => renderToolItem(id, tool))}
)} diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index a4bdbced7e..d66d30c442 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -108,7 +108,7 @@ export default function RightSidebar() { const activeTool: ToolRegistryEntry | null = inToolView && selectedToolKey - ? (toolRegistry[selectedToolKey as ToolId] ?? null) + ? (toolRegistry[selectedToolKey] ?? null) : null; const expandedWidth = "18.5rem"; @@ -131,7 +131,7 @@ export default function RightSidebar() { const items: Array<{ id: ToolId; tool: ToolRegistryEntry }> = []; collapsedQuickSection.subcategories.forEach((sc: SubcategoryGroup) => sc.tools.forEach((entry) => - items.push({ id: entry.id as ToolId, tool: entry.tool }), + items.push({ id: entry.id, tool: entry.tool }), ), ); return items; diff --git a/frontend/editor/src/core/components/tools/ToolRenderer.tsx b/frontend/editor/src/core/components/tools/ToolRenderer.tsx index 1429231ab7..4c99afbca2 100644 --- a/frontend/editor/src/core/components/tools/ToolRenderer.tsx +++ b/frontend/editor/src/core/components/tools/ToolRenderer.tsx @@ -19,9 +19,7 @@ const ToolRenderer = ({ // Get the tool from context (instead of direct hook call) const { toolRegistry } = useToolWorkflow(); const selectedTool = - selectedToolKey in toolRegistry - ? toolRegistry[selectedToolKey as ToolId] - : undefined; + selectedToolKey in toolRegistry ? toolRegistry[selectedToolKey] : undefined; // Handle tools that only work in workbenches (read, multiTool) if (selectedTool && !selectedTool.component && selectedTool.workbench) { diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx index 6e35b286a9..9991c4b849 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx +++ b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx @@ -261,12 +261,7 @@ export default function PageNumberPreview({ variant="tertiary" key={idx} className={`${styles.gridTile} ${selected || hoverTile === idx ? styles.gridTileSelected : ""} ${hoverTile === idx ? styles.gridTileHovered : ""}`} - onClick={() => - onParameterChange( - "position", - idx as AddPageNumbersParameters["position"], - ) - } + onClick={() => onParameterChange("position", idx)} onMouseEnter={() => setHoverTile(idx)} onMouseLeave={() => setHoverTile(null)} style={{ diff --git a/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.tsx b/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.tsx index 1d9dad2995..c9ce1ca1bd 100644 --- a/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.tsx +++ b/frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.tsx @@ -36,9 +36,7 @@ const WatermarkStyleSettings = ({ onChange={(value) => onParameterChange( "rotation", - typeof value === "number" - ? value - : parseInt(value as string, 10) || 0, + typeof value === "number" ? value : parseInt(value, 10) || 0, ) } min={-360} @@ -55,9 +53,7 @@ const WatermarkStyleSettings = ({ onChange={(value) => onParameterChange( "opacity", - typeof value === "number" - ? value - : parseInt(value as string, 10) || 50, + typeof value === "number" ? value : parseInt(value, 10) || 50, ) } min={0} @@ -77,9 +73,7 @@ const WatermarkStyleSettings = ({ onChange={(value) => onParameterChange( "widthSpacer", - typeof value === "number" - ? value - : parseInt(value as string, 10) || 50, + typeof value === "number" ? value : parseInt(value, 10) || 50, ) } min={0} @@ -96,9 +90,7 @@ const WatermarkStyleSettings = ({ onChange={(value) => onParameterChange( "heightSpacer", - typeof value === "number" - ? value - : parseInt(value as string, 10) || 50, + typeof value === "number" ? value : parseInt(value, 10) || 50, ) } min={0} diff --git a/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx b/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx index aeb874b58e..e2bf0a8466 100644 --- a/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx +++ b/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx @@ -103,9 +103,7 @@ const AdjustPageScaleSettings = ({ - onParameterChange("orientation", value as Orientation) - } + onChange={(value) => onParameterChange("orientation", value)} options={orientationOptions} fullWidth /> diff --git a/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.tsx b/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.tsx index f87fda19fe..af1913baa7 100644 --- a/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.tsx +++ b/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.tsx @@ -238,9 +238,7 @@ const WetSignatureInput = ({ - handleSignatureTypeChange(value as SignatureType) - } + onChange={(value) => handleSignatureTypeChange(value)} options={[ { label: t("sign.type.canvas", "Draw"), value: "canvas", disabled }, { label: t("sign.type.image", "Upload"), value: "image", disabled }, diff --git a/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx index d45ccd75a0..35b3a7e0f4 100644 --- a/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx @@ -170,7 +170,7 @@ const ComparePixelWorkbenchView = ({ setViewMode(value as PixelViewMode)} + onChange={(value) => setViewMode(value)} options={[ { value: "side-by-side", diff --git a/frontend/editor/src/core/components/tools/compare/CompareWorkbenchView.tsx b/frontend/editor/src/core/components/tools/compare/CompareWorkbenchView.tsx index 1df0f1c14a..5470e3ac8c 100644 --- a/frontend/editor/src/core/components/tools/compare/CompareWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/compare/CompareWorkbenchView.tsx @@ -28,7 +28,6 @@ import { updateToastProgress, dismissToast, } from "@app/components/toast"; -import type { ToastLocation } from "@app/components/toast/types"; interface CompareWorkbenchViewProps { data: CompareWorkbenchData | null; @@ -323,7 +322,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => { "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete", ), body: `${countsText} ${t("compare.rendering.pagesRendered", "pages rendered")}`, - location: "bottom-right" as ToastLocation, + location: "bottom-right", isPersistentPopup: true, durationMs: 0, expandable: false, @@ -337,7 +336,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => { "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete", ), body: `${countsText} ${t("compare.rendering.pagesRendered", "pages rendered")}`, - location: "bottom-right" as ToastLocation, + location: "bottom-right", isPersistentPopup: true, alertType: "neutral", // ensure it stays neutral until completion }); @@ -452,7 +451,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => { "compare.rendering.pageNotReadyBody", "Some pages are still rendering. Navigation will snap once they are ready.", ), - location: "bottom-right" as ToastLocation, + location: "bottom-right", isPersistentPopup: false, durationMs: 2500, }); diff --git a/frontend/editor/src/core/components/tools/compare/compare.ts b/frontend/editor/src/core/components/tools/compare/compare.ts index 30d0c42304..b157745307 100644 --- a/frontend/editor/src/core/components/tools/compare/compare.ts +++ b/frontend/editor/src/core/components/tools/compare/compare.ts @@ -186,7 +186,7 @@ export const getFileFromSelection = ( ): StirlingFile | null => { if (explicit) return explicit; if (!fileId) return null; - return (selectors.getFile(fileId) as StirlingFile | undefined | null) ?? null; + return selectors.getFile(fileId) ?? null; }; export const getStubFromSelection = ( diff --git a/frontend/editor/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts b/frontend/editor/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts index d7d8e95635..e56187f5f2 100644 --- a/frontend/editor/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts +++ b/frontend/editor/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts @@ -79,7 +79,7 @@ export const useCompareChangeNavigation = ( const inner = anchor.closest( ".compare-diff-page__inner", ) as HTMLElement | null; - const topPercent = parseFloat((anchor as HTMLElement).style.top || "0"); + const topPercent = parseFloat(anchor.style.top || "0"); if (pageEl && inner && !Number.isNaN(topPercent)) { const innerRect = inner.getBoundingClientRect(); const innerHeight = Math.max(1, innerRect.height); @@ -156,9 +156,7 @@ export const useCompareChangeNavigation = ( ".compare-diff-page", ) as HTMLElement | null; const pageNumAttr = pageEl?.getAttribute("data-page-number"); - const topPercent = parseFloat( - (anchor as HTMLElement).style.top || "0", - ); + const topPercent = parseFloat(anchor.style.top || "0"); if (pageNumAttr) { const peerPageEl = peer.querySelector( `.compare-diff-page[data-page-number="${pageNumAttr}"]`, diff --git a/frontend/editor/src/core/components/tools/compare/hooks/useComparePanZoom.ts b/frontend/editor/src/core/components/tools/compare/hooks/useComparePanZoom.ts index bac47de952..d44c55e1ec 100644 --- a/frontend/editor/src/core/components/tools/compare/hooks/useComparePanZoom.ts +++ b/frontend/editor/src/core/components/tools/compare/hooks/useComparePanZoom.ts @@ -323,7 +323,7 @@ export const useComparePanZoom = ({ const pages = getPagesForPane(pane); const rotation = pages[0]?.rotation ?? 0; const normalized = ((rotation % 360) + 360) % 360; - return normalized as 0 | 90 | 180 | 270 | number; + return normalized; }, [getPagesForPane], ); @@ -656,7 +656,7 @@ export const useComparePanZoom = ({ }; edgeOverscrollRef.current[pane] = 0; lastActivePaneRef.current = pane; - (container as HTMLDivElement).style.cursor = "grabbing"; + container.style.cursor = "grabbing"; }, [isPanMode, baseZoom, comparisonZoom, basePan, comparisonPan], ); @@ -700,11 +700,7 @@ export const useComparePanZoom = ({ : comparisonScrollRef.current; if (sourceEl) { const zoom = drag.source === "base" ? baseZoom : comparisonZoom; - (sourceEl as HTMLDivElement).style.cursor = isPanMode - ? zoom > 1 - ? "grab" - : "auto" - : ""; + sourceEl.style.cursor = isPanMode ? (zoom > 1 ? "grab" : "auto") : ""; } panDragRef.current.active = false; panDragRef.current.source = null; diff --git a/frontend/editor/src/core/components/tools/compare/hooks/useCompareWorkbenchBarButtons.tsx b/frontend/editor/src/core/components/tools/compare/hooks/useCompareWorkbenchBarButtons.tsx index 3ddbbbf3da..076ac877d5 100644 --- a/frontend/editor/src/core/components/tools/compare/hooks/useCompareWorkbenchBarButtons.tsx +++ b/frontend/editor/src/core/components/tools/compare/hooks/useCompareWorkbenchBarButtons.tsx @@ -3,7 +3,6 @@ import type React from "react"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import { alert } from "@app/components/toast"; -import type { ToastLocation } from "@app/components/toast/types"; import type { WorkbenchBarButtonWithAction } from "@app/hooks/useWorkbenchBarButtons"; import { useIsMobile } from "@app/hooks/useIsMobile"; @@ -179,7 +178,7 @@ export const useCompareWorkbenchBarButtons = ({ "Tip: Arrow Up/Down scroll both panes when unlinked is off.", ), durationMs: 5000, - location: "bottom-center" as ToastLocation, + location: "bottom-center", expandable: false, }); } diff --git a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx index cd443e248a..901824aaf6 100644 --- a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx +++ b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx @@ -94,10 +94,10 @@ type Story = StoryObj; /** An available tool rendered in its default, unselected state. */ export const Default: Story = { - render: () => , + render: () => , }; /** The active tool in the panel — highlighted selected state. */ export const Selected: Story = { - render: () => , + render: () => , }; diff --git a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx index e7246bb4e3..8df455182e 100644 --- a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx +++ b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx @@ -137,7 +137,7 @@ export default function OverlayPdfsSettings({ - onParameterChange("overlayPosition", (v === "1" ? 1 : 0) as 0 | 1) + onParameterChange("overlayPosition", v === "1" ? 1 : 0) } options={[ { diff --git a/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorSidebar.tsx b/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorSidebar.tsx index 1892aaa7f3..a0ce9fc362 100644 --- a/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorSidebar.tsx +++ b/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorSidebar.tsx @@ -279,9 +279,7 @@ const PdfTextEditorSidebar = ({ data }: PdfTextEditorSidebarProps) => { - handleModeChangeRequest(value as GroupingMode) - } + onChange={(value) => handleModeChangeRequest(value)} options={[ { label: t("pdfTextEditor.groupingMode.auto", "Auto"), diff --git a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx index 7918bac7ca..ef596c2a0d 100644 --- a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx @@ -12,7 +12,6 @@ import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; import { saveOperationResults } from "@app/services/operationResultsSaveService"; import { useFileActions, useFileSelectors } from "@app/contexts/FileContext"; -import { FileId } from "@app/types/fileContext"; import i18n from "@app/i18n"; export interface ReviewToolStepProps { @@ -65,11 +64,11 @@ function ReviewStepContent({ downloadFilename: operation.downloadFilename || "download", downloadLocalPath: operation.downloadLocalPath, outputFileIds: operation.outputFileIds, - getFile: (fileId) => selectors.getFile(fileId as FileId), - getStub: (fileId) => selectors.getStirlingFileStub(fileId as FileId), + getFile: (fileId) => selectors.getFile(fileId), + getStub: (fileId) => selectors.getStirlingFileStub(fileId), markSaved: (fileId, savedPath) => { - const stub = selectors.getStirlingFileStub(fileId as FileId); - fileActions.updateStirlingFileStub(fileId as FileId, { + const stub = selectors.getStirlingFileStub(fileId); + fileActions.updateStirlingFileStub(fileId, { localFilePath: stub?.localFilePath ?? savedPath, isDirty: false, }); diff --git a/frontend/editor/src/core/components/tools/showJS/utils.ts b/frontend/editor/src/core/components/tools/showJS/utils.ts index e946ec271e..7c6530c21b 100644 --- a/frontend/editor/src/core/components/tools/showJS/utils.ts +++ b/frontend/editor/src/core/components/tools/showJS/utils.ts @@ -186,7 +186,7 @@ export function tokenizeToLines( } if (isStringDelimiter) { - startString(ch as '"' | "'" | "`"); + startString(ch); continue; } @@ -312,7 +312,7 @@ export function computeBlocks( continue; } if (isStringDelimiter) { - startString(ch as '"' | "'" | "`"); + startString(ch); continue; } if (isOpenBrace) { diff --git a/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx index c8607d55c4..4f68e7c68c 100644 --- a/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx +++ b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx @@ -52,9 +52,9 @@ function primeSession( mockedApi.post.mockResolvedValue({ status: 200, data: SESSION_INFO, - } as never); - mockedApi.delete.mockResolvedValue({ status: 200 } as never); - mockedApi.get.mockImplementation(((url: string, config?: unknown) => { + }); + mockedApi.delete.mockResolvedValue({ status: 200 }); + mockedApi.get.mockImplementation((url: string, config?: unknown) => { if (url.includes("/files/")) { return Promise.resolve({ status: 200, data: { files } } as never); } @@ -70,7 +70,7 @@ function primeSession( } as never); } return Promise.reject(new Error(`unexpected GET ${url}`)); - }) as never); + }); } function renderModal( diff --git a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx index 4aa9f8ffd3..1d78343c79 100644 --- a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx @@ -519,7 +519,7 @@ const SignSettings = ({ return; } const nextSource = allowedSignatureSources.includes( - parameters.signatureType as SignatureSource, + parameters.signatureType, ) ? (parameters.signatureType as SignatureSource) : effectiveDefaultSource; @@ -1282,9 +1282,7 @@ const SignSettings = ({ - handleSignatureSourceChange(value as SignatureSource) - } + onChange={(value) => handleSignatureSourceChange(value)} options={sourceOptions} /> )} diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx index 4792fd721a..8daf2cb019 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx @@ -74,7 +74,7 @@ const ToolButton: React.FC = ({ const { hotkeys } = useHotkeys(); const binding = hotkeys[id]; const { getToolNavigation } = useToolNavigation(); - const fav = isFavorite(id as ToolId); + const fav = isFavorite(id); // Check if this tool will route to SaaS backend (desktop only) const rawEndpoint = tool.operationConfig?.endpoint; @@ -308,7 +308,7 @@ const ToolButton: React.FC = ({ hasStars && !visuallyUnavailable ? ( toggleFavorite(id as ToolId)} + onToggle={() => toggleFavorite(id)} className="tool-button-star" size="xs" /> diff --git a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx index 22020ac6dc..23a9d9e949 100644 --- a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx +++ b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx @@ -294,7 +294,7 @@ const ValidateSignatureResults = ({ setSelectedType(v as "pdf" | "csv" | "json")} + onChange={(v) => setSelectedType(v)} options={downloadTypeOptions} />
{v.endpoints.map((e) => (
- + {e.endpoint} {e.name} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx index 0b15b7233a..0850499a99 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx @@ -275,8 +275,7 @@ describe("PipelineStepSettings: every tool's settings render in the portal", () const Settings = entry.automationSettings as ComponentType< ToolAutomationSettingsProps >; - const params = (entry.operationConfig?.defaultParameters ?? - {}) as ErasedToolParams; + const params = entry.operationConfig?.defaultParameters ?? {}; const caught: { error: Error | null } = { error: null }; // The sentinel sibling commits only once the lazy Settings actually renders, so we wait for a diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index b50e2efe12..cccdb36e40 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -457,7 +457,7 @@ function PolicySetupWizardBody({ variant="underline" ariaLabel={t("portal.policies.wizard.tabs.ariaLabel")} activeKey={step} - onChange={(k) => setStep(k as Step)} + onChange={(k) => setStep(k)} items={[ { key: "workflow", label: t("portal.policies.wizard.tabs.workflow") }, { key: "settings", label: t("portal.policies.wizard.tabs.settings") }, diff --git a/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts b/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts index 65fc6cc9ad..a32ea76a4d 100644 --- a/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts +++ b/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts @@ -134,10 +134,7 @@ export function useFlowParticles({ for (let i = 0; i < meanInterval.length; i++) { if (!Number.isFinite(meanInterval[i]) || !g.srcs[i]) continue; if (now >= nextEmit[i] && particles.length < MAX_PARTICLES) { - const c = document.createElementNS( - NS, - "circle", - ) as SVGCircleElement; + const c = document.createElementNS(NS, "circle"); c.setAttribute("r", "2.5"); c.setAttribute("opacity", "0.75"); c.style.fill = "var(--c-primary)"; diff --git a/frontend/editor/src/portal/mocks/handlers/policies.ts b/frontend/editor/src/portal/mocks/handlers/policies.ts index 1bfc435d4c..ce6c041ae9 100644 --- a/frontend/editor/src/portal/mocks/handlers/policies.ts +++ b/frontend/editor/src/portal/mocks/handlers/policies.ts @@ -35,7 +35,7 @@ function nextId(categoryId: string): string { } function categoryId(wire: WirePolicy): string { - return (wire.output?.options?.categoryId as string | undefined) ?? ""; + return wire.output?.options?.categoryId ?? ""; } export const policiesHandlers = [ diff --git a/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts index fe75827c2d..2f5246ce8d 100644 --- a/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts +++ b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts @@ -256,7 +256,7 @@ export const procurementSaasHandlers = [ stage: "quote", licensed: true, latestQuote: quote, - } as never; + }; return HttpResponse.json(quote); }), http.post(`${SAAS}/api/v1/procurement/trial/extend`, () => { diff --git a/frontend/editor/src/portal/queries/adapters.ts b/frontend/editor/src/portal/queries/adapters.ts index 083e396bad..3cb091d8cd 100644 --- a/frontend/editor/src/portal/queries/adapters.ts +++ b/frontend/editor/src/portal/queries/adapters.ts @@ -11,6 +11,6 @@ export function toAsyncState(query: UseQueryResult): AsyncState { return { data: query.data ?? null, loading: query.isPending, - error: (query.error as Error | null) ?? null, + error: query.error ?? null, }; } diff --git a/frontend/editor/src/portal/setupTests.ts b/frontend/editor/src/portal/setupTests.ts index 7de4f9f309..22d95aa1ab 100644 --- a/frontend/editor/src/portal/setupTests.ts +++ b/frontend/editor/src/portal/setupTests.ts @@ -50,7 +50,7 @@ global.IntersectionObserver = vi.fn().mockImplementation(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(), -})) as unknown as typeof IntersectionObserver; +})); Object.defineProperty(window, "matchMedia", { writable: true, diff --git a/frontend/editor/src/proprietary/auth/springAuthClient.test.ts b/frontend/editor/src/proprietary/auth/springAuthClient.test.ts index 54fed2028d..13f5c6d651 100644 --- a/frontend/editor/src/proprietary/auth/springAuthClient.test.ts +++ b/frontend/editor/src/proprietary/auth/springAuthClient.test.ts @@ -12,11 +12,7 @@ import apiClient from "@app/services/apiClient"; // + oauthNavigation seam, so springAuth routes through the mocks below. import "@app/auth/configureSpringAuth"; import { allowConsole, expectConsole } from "@app/tests/failOnConsole"; -import { - AxiosError, - type AxiosResponse, - type InternalAxiosRequestConfig, -} from "axios"; +import { AxiosError, type InternalAxiosRequestConfig } from "axios"; // Mock apiClient vi.mock("@app/services/apiClient"); @@ -59,7 +55,7 @@ describe("SpringAuthClient", () => { vi.mocked(apiClient.get).mockResolvedValueOnce({ status: 200, data: { user: mockUser }, - } as unknown as AxiosResponse); + }); const result = await springAuth.getSession(); @@ -176,7 +172,7 @@ describe("SpringAuthClient", () => { expires_in: 3600, }, }, - } as unknown as AxiosResponse); + }); // Spy on window.dispatchEvent const dispatchEventSpy = vi.spyOn(window, "dispatchEvent"); @@ -235,7 +231,7 @@ describe("SpringAuthClient", () => { vi.mocked(apiClient.post).mockResolvedValueOnce({ status: 200, data: {}, - } as unknown as AxiosResponse); + }); const result = await springAuth.signOut(); @@ -288,7 +284,7 @@ describe("SpringAuthClient", () => { expires_in: 3600, }, }, - } as unknown as AxiosResponse); + }); const result = await springAuth.refreshSession(); @@ -375,7 +371,7 @@ describe("SpringAuthClient", () => { expect(isSafePostLoginRedirect("")).toBe(false); expect(isSafePostLoginRedirect(null)).toBe(false); expect(isSafePostLoginRedirect(undefined)).toBe(false); - expect(isSafePostLoginRedirect(42 as unknown)).toBe(false); + expect(isSafePostLoginRedirect(42)).toBe(false); }); it("rejects protocol-relative and absolute URLs (open-redirect guard)", () => { diff --git a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx index 6bcc6b501e..385f3f4e35 100644 --- a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx @@ -46,7 +46,7 @@ function mapUser(user: SbUser): AuthUser { "", role: readRole(user), is_anonymous: user.is_anonymous, - app_metadata: user.app_metadata as Record, + app_metadata: user.app_metadata, }; } diff --git a/frontend/editor/src/proprietary/components/policies/policyRunSettles.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunSettles.test.ts index fdce331821..c3c6be7137 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunSettles.test.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunSettles.test.ts @@ -7,20 +7,19 @@ import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; * effect used to skip those runs entirely, so `imported` never flipped and the * file's badge + blocking overlay spun forever - on every engine. */ -const run = (overrides: Partial = {}): PolicyRunRecord => - ({ - runId: "r", - categoryId: "security", - fileId: "f", - fileName: "f.pdf", - fileSize: 1, - target: "saas", - status: "COMPLETED", - outputs: [], - error: null, - startedAt: 0, - ...overrides, - }) as PolicyRunRecord; +const run = (overrides: Partial = {}): PolicyRunRecord => ({ + runId: "r", + categoryId: "security", + fileId: "f", + fileName: "f.pdf", + fileSize: 1, + target: "saas", + status: "COMPLETED", + outputs: [], + error: null, + startedAt: 0, + ...overrides, +}); describe("finishedWithNothingToDeliver", () => { it("settles a completed run that produced no output", () => { diff --git a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts index 64fd849d87..ede1768b2a 100644 --- a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts +++ b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts @@ -99,7 +99,7 @@ export function useClientSideClassification(): void { if (claimed.current.has(key)) continue; claimed.current.add(key); const verdict = await classifyStub( - stub.id as FileId, + stub.id, stub.name, stub.size ?? 0, ); @@ -108,11 +108,11 @@ export function useClientSideClassification(): void { if (verdict == null) continue; // Deliver unconditionally - a re-render must never discard a computed // (and already metered) result. Writes are idempotent. - updateStirlingFileStub(stub.id as FileId, { + updateStirlingFileStub(stub.id, { classificationLabels: verdict.labels, classificationConfidence: verdict.confidence, }); - const ok = await fileStorage.updateFileMetadata(stub.id as FileId, { + const ok = await fileStorage.updateFileMetadata(stub.id, { classificationLabels: verdict.labels, classificationConfidence: verdict.confidence, }); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 936fef8119..0638b1432c 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -768,7 +768,7 @@ async function importOutputs( // Mark the outputs handled BEFORE adding them (belt-and-suspenders session // guard on top of derivedFromTool) so the auto-run never enforces the policy // on its own output — that would version endlessly in a loop. - for (const s of categorized) markHandled(s.id as string); + for (const s of categorized) markHandled(s.id); deliveredIds = categorized.map((s) => s.id as string); if (ctx.parentStub) { // Input is in the active workspace: version it in place, silently — the @@ -799,7 +799,7 @@ async function importOutputs( derivedFromTool: true, }); // Belt-and-suspenders session guard on top of derivedFromTool. - for (const f of added) markHandled(f.fileId as string); + for (const f of added) markHandled(f.fileId); deliveredIds = added.map((f) => f.fileId as string); // Mark each new-file output as tool-derived (the versioned path gets this from the // CONSUME_FILES reducer; the addFiles path doesn't). This is the real loop guard: the dispatch diff --git a/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.tsx b/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.tsx index 41d1358594..1b37a13bb7 100644 --- a/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.tsx +++ b/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.tsx @@ -46,7 +46,7 @@ export function FileSidebarGroupControls({ const ids = new Set(); for (const key of category.labelKeys) { for (const stub of byLabel.get(key)?.stubs ?? []) { - ids.add(stub.id as string); + ids.add(stub.id); } } counts.set(category.id, ids.size); diff --git a/frontend/editor/src/proprietary/components/shared/InviteMembersModal.tsx b/frontend/editor/src/proprietary/components/shared/InviteMembersModal.tsx index e57f60395f..063fb1c4ef 100644 --- a/frontend/editor/src/proprietary/components/shared/InviteMembersModal.tsx +++ b/frontend/editor/src/proprietary/components/shared/InviteMembersModal.tsx @@ -460,7 +460,7 @@ export default function InviteMembersModal({ { - setInviteMode(value as "email" | "direct" | "link"); + setInviteMode(value); setGeneratedInviteLink(null); }} options={[ diff --git a/frontend/editor/src/proprietary/components/shared/UpgradeBanner.tsx b/frontend/editor/src/proprietary/components/shared/UpgradeBanner.tsx index 365c64a030..fdb92dc271 100644 --- a/frontend/editor/src/proprietary/components/shared/UpgradeBanner.tsx +++ b/frontend/editor/src/proprietary/components/shared/UpgradeBanner.tsx @@ -67,15 +67,9 @@ const UpgradeBanner: React.FC = () => { } }; - window.addEventListener( - UPGRADE_BANNER_TEST_EVENT, - handleTestEvent as EventListener, - ); + window.addEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent); return () => { - window.removeEventListener( - UPGRADE_BANNER_TEST_EVENT, - handleTestEvent as EventListener, - ); + window.removeEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent); }; }, [isDev]); diff --git a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx index 31d45c0d45..b9caf6ba8d 100644 --- a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; const h = vi.hoisted(() => ({ - prefs: { loginLandingView: "processor" as "processor" | "editor" }, + prefs: { loginLandingView: "processor" }, update: vi.fn(), get: vi.fn(), })); diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx index f788551551..b993889e52 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -518,11 +518,11 @@ export default function AdminConnectionsSection() { updatedSettings: Record, ) => { if (provider.id === "smtp") { - setSettings({ ...settings, mail: updatedSettings as MailSettings }); + setSettings({ ...settings, mail: updatedSettings }); } else if (provider.id === "telegram") { setSettings({ ...settings, - telegram: updatedSettings as TelegramSettingsData, + telegram: updatedSettings, }); } else if (provider.id === "googledrive") { const gd = updatedSettings as GoogleDriveSettings; @@ -534,7 +534,7 @@ export default function AdminConnectionsSection() { googleDriveAppId: gd.appId, }); } else if (provider.id === "saml2") { - setSettings({ ...settings, saml2: updatedSettings as Saml2Settings }); + setSettings({ ...settings, saml2: updatedSettings }); } else if (provider.id === "oauth2-generic") { const generic = updatedSettings as OAuth2GenericSettings; setSettings({ ...settings, oauth2: { ...settings.oauth2, ...generic } }); diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminUsageSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminUsageSection.tsx index 6b46789bdd..d0a0a1b2e9 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminUsageSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminUsageSection.tsx @@ -332,9 +332,7 @@ const AdminUsageSection: React.FC = () => { - setDisplayMode(value as "top10" | "top20" | "all") - } + onChange={(value) => setDisplayMode(value)} options={[ { value: "top10", @@ -373,7 +371,7 @@ const AdminUsageSection: React.FC = () => { setDataType(value as "all" | "api" | "ui")} + onChange={(value) => setDataType(value)} options={[ { value: "all", diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx index feca3696d4..22263596c0 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx @@ -257,11 +257,9 @@ const AuditEventsTable: React.FC = ({ verticalSpacing="sm" withRowBorders highlightOnHover - style={ - { - "--table-border-color": "var(--mantine-color-gray-3)", - } as React.CSSProperties - } + style={{ + "--table-border-color": "var(--mantine-color-gray-3)", + }} > = ({ { - setInputMethod(value as "text" | "file"); + setInputMethod(value); // Clear opposite input when switching if (value === "text") setLicenseFile(null); if (value === "file") setLicenseKeyInput(""); diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx index f27d5d09a3..a89cb477e3 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx @@ -32,11 +32,9 @@ const UsageAnalyticsTable: React.FC = ({ data }) => { verticalSpacing="sm" withRowBorders highlightOnHover - style={ - { - "--table-border-color": "var(--mantine-color-gray-3)", - } as React.CSSProperties - } + style={{ + "--table-border-color": "var(--mantine-color-gray-3)", + }} > diff --git a/frontend/editor/src/proprietary/components/shared/fileSidebarGrouping.tsx b/frontend/editor/src/proprietary/components/shared/fileSidebarGrouping.tsx index baf3d812cb..447bd19a68 100644 --- a/frontend/editor/src/proprietary/components/shared/fileSidebarGrouping.tsx +++ b/frontend/editor/src/proprietary/components/shared/fileSidebarGrouping.tsx @@ -20,7 +20,6 @@ import { } from "@app/services/fileSidebarCategories"; import { buildLabelGroups } from "@app/components/shared/fileSidebarGroupingLogic"; import { scheduleIdle } from "@app/utils/scheduleIdle"; -import type { FileId } from "@app/types/file"; import type { StirlingFileStub } from "@app/types/fileContext"; import type { FileSidebarGroup } from "@core/components/shared/fileSidebarGrouping"; @@ -81,7 +80,7 @@ export function useFileSidebarGroups( if (cancelled) return; attempted.current.add(attemptKey(stub)); if (labels) { - const ok = await fileStorage.updateFileMetadata(stub.id as FileId, { + const ok = await fileStorage.updateFileMetadata(stub.id, { classificationLabels: labels, }); if (ok) wrote = true; diff --git a/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts b/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts index 82569a9bd5..5b4b30ce33 100644 --- a/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts +++ b/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts @@ -70,7 +70,7 @@ export function buildLabelGroups( // Other = files in no visible group: unlabelled, or labelled only under hidden categories. const covered = new Set(); for (const group of visible) { - for (const stub of group.stubs) covered.add(stub.id as string); + for (const stub of group.stubs) covered.add(stub.id); } const other = stubs.filter((stub) => !covered.has(stub.id as string)); diff --git a/frontend/editor/src/proprietary/hooks/useFolderRunStatuses.ts b/frontend/editor/src/proprietary/hooks/useFolderRunStatuses.ts index 031d514f5f..78f00b1fab 100644 --- a/frontend/editor/src/proprietary/hooks/useFolderRunStatuses.ts +++ b/frontend/editor/src/proprietary/hooks/useFolderRunStatuses.ts @@ -51,7 +51,7 @@ export function useFolderRunStatuses( ); return [folder.id, deriveStatus(runs)] as const; } catch { - return [folder.id, "idle" as FolderRunStatus] as const; + return [folder.id, "idle"] as const; } }), ); diff --git a/frontend/editor/src/proprietary/policies/codec.ts b/frontend/editor/src/proprietary/policies/codec.ts index 2ef68514e4..e6fbdf739d 100644 --- a/frontend/editor/src/proprietary/policies/codec.ts +++ b/frontend/editor/src/proprietary/policies/codec.ts @@ -62,10 +62,8 @@ export function fromWirePolicy(policy: WirePolicy): PolicyDecodedState { name: policy.name, enabled: policy.enabled, categoryId, - sources: Array.isArray(raw.sources) ? (raw.sources as string[]) : [], - scopeTypes: Array.isArray(raw.scopeTypes) - ? (raw.scopeTypes as string[]) - : [], + sources: Array.isArray(raw.sources) ? raw.sources : [], + scopeTypes: Array.isArray(raw.scopeTypes) ? raw.scopeTypes : [], reviewerEmail: str(raw.reviewerEmail), fieldValues: raw.fieldValues ?? {}, runOn: resolveRunOn(raw.runOn, categoryId), diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index d2cb80360f..29ae9d9d79 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -10,7 +10,6 @@ import { PreferencesProvider } from "@app/contexts/PreferencesContext"; import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider"; import apiClient from "@app/services/apiClient"; import { configureSpringAuth } from "@app/auth/config"; -import type { AxiosInstance } from "axios"; // Mock i18n to return fallback text vi.mock("react-i18next", () => ({ @@ -137,7 +136,7 @@ describe("Login", () => { // The shared login hook reads getSpringAuthConfig().http; in the real app, // startup points that at apiClient. Mirror that here so the mocked apiClient // serves the login-ui-data fetch. - configureSpringAuth({ http: apiClient as unknown as AxiosInstance }); + configureSpringAuth({ http: apiClient }); }); it("should render login form", async () => { diff --git a/frontend/editor/src/proprietary/services/heuristic/heuristicEngine.ts b/frontend/editor/src/proprietary/services/heuristic/heuristicEngine.ts index ed01b9542e..8d2f795636 100644 --- a/frontend/editor/src/proprietary/services/heuristic/heuristicEngine.ts +++ b/frontend/editor/src/proprietary/services/heuristic/heuristicEngine.ts @@ -387,8 +387,7 @@ export async function ensureRulesLoaded(): Promise { if (!loadPromise) { loadPromise = import("@app/services/heuristic/heuristicRules.json").then( (mod) => { - const root = ((mod as { default?: RulesFile }).default ?? - (mod as RulesFile)) as RulesFile; + const root = (mod as { default?: RulesFile }).default ?? mod; PREPARED = prepare(root.labels ?? []); PRIORS = loadPriors(root.priors ?? {}); }, diff --git a/frontend/editor/src/proprietary/services/heuristic/heuristicExtractor.ts b/frontend/editor/src/proprietary/services/heuristic/heuristicExtractor.ts index 426c1133e1..fb51b407b6 100644 --- a/frontend/editor/src/proprietary/services/heuristic/heuristicExtractor.ts +++ b/frontend/editor/src/proprietary/services/heuristic/heuristicExtractor.ts @@ -186,8 +186,7 @@ async function metadata( } catch { return {}; } - const get = (k: string) => - typeof info[k] === "string" ? (info[k] as string) : ""; + const get = (k: string) => (typeof info[k] === "string" ? info[k] : ""); return { title: get("Title"), author: get("Author"), diff --git a/frontend/editor/src/proprietary/services/policyExport.ts b/frontend/editor/src/proprietary/services/policyExport.ts index 78b747182b..91b68f8edd 100644 --- a/frontend/editor/src/proprietary/services/policyExport.ts +++ b/frontend/editor/src/proprietary/services/policyExport.ts @@ -222,7 +222,7 @@ export async function enforceExportPolicies( fileId, fileName: file.name, fileSize: file.size, - target: versionRun!.target, + target: versionRun.target, status: "COMPLETED", outputs: versionRun.outputs, error: null, diff --git a/frontend/editor/src/saas/auth/UseSession.test.ts b/frontend/editor/src/saas/auth/UseSession.test.ts index d5f8124948..94e1ffcb6a 100644 --- a/frontend/editor/src/saas/auth/UseSession.test.ts +++ b/frontend/editor/src/saas/auth/UseSession.test.ts @@ -17,7 +17,7 @@ function makeUser(overrides: Partial = {}): User { user_metadata: {}, created_at: "2026-01-01T00:00:00Z", ...overrides, - } as User; + }; } describe("saas deriveDisplayName", () => { diff --git a/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx b/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx index b9028ce22b..7ba4230ef5 100644 --- a/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx +++ b/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx @@ -46,12 +46,8 @@ export default function SignupRequiredBootstrap() { return true; }); }; - window.addEventListener("payg:signupRequired", handler as EventListener); - return () => - window.removeEventListener( - "payg:signupRequired", - handler as EventListener, - ); + window.addEventListener("payg:signupRequired", handler); + return () => window.removeEventListener("payg:signupRequired", handler); }, []); // Map the server's gate categories to user-facing nouns. The server diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index ab93f734fb..11b8957deb 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -80,12 +80,8 @@ const AppConfigModal: React.FC = ({ setMobilePane("content"); } }; - window.addEventListener("appConfig:navigate", handler as EventListener); - return () => - window.removeEventListener( - "appConfig:navigate", - handler as EventListener, - ); + window.addEventListener("appConfig:navigate", handler); + return () => window.removeEventListener("appConfig:navigate", handler); }, []); // When the modal opens via a /settings/
deep link (navigateToSettings — e.g. the @@ -122,9 +118,8 @@ const AppConfigModal: React.FC = ({ setNotice(detail.notice); } }; - window.addEventListener("appConfig:notice", handler as EventListener); - return () => - window.removeEventListener("appConfig:notice", handler as EventListener); + window.addEventListener("appConfig:notice", handler); + return () => window.removeEventListener("appConfig:notice", handler); }, []); // Full-screen overlays that live inside our React tree (e.g. the PAYG @@ -140,9 +135,8 @@ const AppConfigModal: React.FC = ({ | undefined; setOverlayActive(Boolean(detail?.open)); }; - window.addEventListener("appConfig:overlay", handler as EventListener); - return () => - window.removeEventListener("appConfig:overlay", handler as EventListener); + window.addEventListener("appConfig:overlay", handler); + return () => window.removeEventListener("appConfig:overlay", handler); }, []); const colors = useMemo( diff --git a/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx b/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx index 5f56843dae..0224b0837b 100644 --- a/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx +++ b/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx @@ -211,11 +211,9 @@ export default function StackedBarChart({ setTooltipContent(html); const tooltip = tooltipRef.current; if (tooltip) tooltip.style.opacity = "1"; - positionTooltip(event as unknown as MouseEvent); + positionTooltip(event); }) - .on("mousemove", (event: MouseEvent) => - positionTooltip(event as unknown as MouseEvent), - ) + .on("mousemove", (event: MouseEvent) => positionTooltip(event)) .on("mouseleave", hideTooltip); // Animate reveal of used segments (only on first load, not on re-renders) diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx index 3a28152425..62f84977c1 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx @@ -562,15 +562,7 @@ const Overview: React.FC = ({ onLogoutClick }) => { style={{ width: 16, height: 16 }} /> } - onClick={() => - handleOAuthUpgrade( - provider.id as - | "github" - | "google" - | "apple" - | "azure", - ) - } + onClick={() => handleOAuthUpgrade(provider.id)} disabled={isLoading} > {provider.label} diff --git a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx index 503087cbe4..59920ab6fb 100644 --- a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx @@ -547,7 +547,7 @@ const SignSettings = ({ return; } const nextSource = allowedSignatureSources.includes( - parameters.signatureType as SignatureSource, + parameters.signatureType, ) ? (parameters.signatureType as SignatureSource) : effectiveDefaultSource; @@ -1314,9 +1314,7 @@ const SignSettings = ({ - handleSignatureSourceChange(value as SignatureSource) - } + onChange={(value) => handleSignatureSourceChange(value)} options={sourceOptions} /> )} diff --git a/frontend/editor/src/saas/routes/OAuthConsent.tsx b/frontend/editor/src/saas/routes/OAuthConsent.tsx index f22f13fa19..f2acc03899 100644 --- a/frontend/editor/src/saas/routes/OAuthConsent.tsx +++ b/frontend/editor/src/saas/routes/OAuthConsent.tsx @@ -35,9 +35,8 @@ interface AuthorizationDetails { }; } -const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string; -const SUPABASE_KEY = import.meta.env - .VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY as string; +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL; +const SUPABASE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY; async function gotrue( path: string, diff --git a/frontend/editor/src/saas/setupTests.ts b/frontend/editor/src/saas/setupTests.ts index 3e8864e49e..6b04e18efd 100644 --- a/frontend/editor/src/saas/setupTests.ts +++ b/frontend/editor/src/saas/setupTests.ts @@ -130,7 +130,7 @@ Object.defineProperty(globalThis, "crypto", { } return array; }), - } as unknown as Crypto, + }, writable: true, configurable: true, }); From caeca0b88ad2f1eaa1fc0aa722b7d796becc4d9d Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:34:13 +0000 Subject: [PATCH 03/57] Fix classification escalation: the local pass was claiming the server dispatch key (#7667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #7580: the escalation it added could never fire. ## What's broken The auto-run skips a policy that has already run on a file, keyed on `(categoryId, fileId)`. `recordRunStart` claims that key — and #7580 has the **browser-side first pass** record its own run under `categoryId: "classification"` for the uploaded file. So the local heuristic ticks the very key the server escalation checks, and the AI is never asked, at any confidence. Trigger is the default seeded setup: **Classification as the only on-upload policy**, and a local verdict below `high`. Any other on-upload policy masks it, because classification then targets that policy's output — a new file id whose key was never claimed. That's why this went unnoticed. Two smaller faults in the same path: - A chained output carried no `classificationConfidence`, so `shouldDispatchToAi` waited for a verdict that could never arrive (a tool-derived file gets no local pass). - Browser-local runs were polled against the server: 3 × 404 per file, after which `MAX_NOT_FOUND` marked a local run that had actually **succeeded** as `FAILED`. ## The fix - `PolicyRunRecord.browserLocal`; `recordRunStart` skips the dispatch claim for such a run. It is the first pass, not the policy's run. - The local pass meters under `classification:local-meter` instead of the category id, so metering dedupe survives without suppressing dispatch. - The poll effect skips browser-local runs. - `CONSUME_FILES` inherits `classificationConfidence` alongside the labels, so the verdict survives a version bump. ## How to test Download [`low-confidence-classification.pdf`](https://github.com/Stirling-Tools/Stirling-PDF/raw/fix/chained-classification-confidence/frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf) (checked in as a fixture, verdict pinned by a test). With **Classification as the only on-upload policy**, upload it and watch the Network tab: - **Before:** no `POST /api/v1/policies/{id}/run` for classification, ever. Console shows `local-classification-*` 404s. - **After:** exactly one, and the engine receives `POST /api/v1/documents/classify`. Judge it on that request, not on the resulting label — the model's answer varies, so a label comparison can pass or fail for the wrong reason. Headless equivalent: ``` npx vitest run --project proprietary src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx ``` Passes here, fails on `main` on "asks the AI about an unsure verdict even though the local pass already ran". Its other two cases pass on both, so the guards still hold: a confident verdict still costs nothing, and a file with no verdict yet still waits rather than racing the free pass. New tests drive the **real** run store — mocking it is what let this through. `task frontend:check`: 255 files / 2202 tests. --- .../core/contexts/file/FileReducer.test.ts | 48 +++++ .../src/core/contexts/file/FileReducer.ts | 26 ++- .../policies/policyRunStore.test.ts | 23 +++ .../components/policies/policyRunStore.ts | 18 +- .../useClientSideClassification.test.tsx | 10 +- .../policies/useClientSideClassification.ts | 15 +- .../policies/usePolicyAutoRun.chain.test.tsx | 188 +++++++++++++++++- .../usePolicyAutoRun.escalation.test.tsx | 155 +++++++++++++++ .../components/policies/usePolicyAutoRun.ts | 9 +- .../data/classificationPolicy.test.ts | 23 +++ .../proprietary/data/classificationPolicy.ts | 10 +- .../low-confidence-classification.pdf | 95 +++++++++ .../fixtures/lowConfidenceFixture.test.ts | 60 ++++++ 13 files changed, 651 insertions(+), 29 deletions(-) create mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx create mode 100644 frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf create mode 100644 frontend/editor/src/proprietary/services/heuristic/fixtures/lowConfidenceFixture.test.ts diff --git a/frontend/editor/src/core/contexts/file/FileReducer.test.ts b/frontend/editor/src/core/contexts/file/FileReducer.test.ts index bde6a17a02..cdba68b013 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.test.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.test.ts @@ -209,6 +209,54 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)", ]); }); + it("carries classificationConfidence forward with the labels", () => { + // The confidence is part of the verdict: without it the escalation decision + // (shouldDispatchToAi) dies at the version boundary and a chained + // classification never runs. + const start = stateWith([ + stub("a", { + classificationLabels: ["Invoice"], + classificationConfidence: "low", + }), + ]); + const next = fileContextReducer(start, { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId], + outputStirlingFileStubs: [stub("a-v2")], + }, + }); + expect(next.files.byId["a-v2" as FileId].classificationConfidence).toBe( + "low", + ); + }); + + it("an output with its own verdict keeps it — no confidence bleed from the input", () => { + // A fresh classify result carries its own labels; stamping the input's + // heuristic confidence onto them would mislabel an AI verdict as unsure. + const start = stateWith([ + stub("a", { + classificationLabels: ["Invoice"], + classificationConfidence: "low", + }), + ]); + const next = fileContextReducer(start, { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId], + outputStirlingFileStubs: [ + stub("b", { classificationLabels: ["Contract"] }), + ], + }, + }); + expect(next.files.byId["b" as FileId].classificationLabels).toEqual([ + "Contract", + ]); + expect( + next.files.byId["b" as FileId].classificationConfidence, + ).toBeUndefined(); + }); + it("non-silent CONSUME_FILES still moves the output to the front (unchanged)", () => { const start = stateWith([stub("a"), stub("b")]); const next = fileContextReducer(start, { diff --git a/frontend/editor/src/core/contexts/file/FileReducer.ts b/frontend/editor/src/core/contexts/file/FileReducer.ts index a9dcae7b38..1ed23b11d6 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.ts @@ -386,14 +386,19 @@ export function fileContextReducer( ), ); - // Carry the document's classification labels forward across the edit: any + // Carry the document's classification verdict forward across the edit: any // tool that versions/derives a classified file keeps it in its label // groups instead of dropping to "Other" and waiting on a PDF re-read. - // Inherited from the first input that has any; an output that already - // carries its own (e.g. a fresh classify result) keeps them. - const inheritedLabels = inputFileIds - .map((id) => state.files.byId[id]?.classificationLabels) - .find((labels) => labels && labels.length > 0); + // Inherited from the first input that has labels, together with that + // verdict's confidence - the escalation decision (shouldDispatchToAi) is + // about the document, not about which step produced the current bytes, so + // it must survive the version boundary. An output that already carries its + // own verdict (e.g. a fresh classify result) keeps it. + const verdictDonor = inputFileIds + .map((id) => state.files.byId[id]) + .find( + (s) => s?.classificationLabels && s.classificationLabels.length > 0, + ); // Mark every consume output as tool-produced (the single chokepoint for // both versioned edits and independent artifacts like convert/split/merge) @@ -404,7 +409,14 @@ export function fileContextReducer( ...stub, derivedFromTool: true, sourceFileIds, - classificationLabels: stub.classificationLabels ?? inheritedLabels, + ...(stub.classificationLabels == null && verdictDonor + ? { + classificationLabels: verdictDonor.classificationLabels, + classificationConfidence: + stub.classificationConfidence ?? + verdictDonor.classificationConfidence, + } + : {}), })); // Silent (background enforcement): replace inputs in their existing grid diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts index 498a688ac2..3ee9057b3e 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts @@ -83,6 +83,29 @@ describe("policyRunStore", () => { expect(isDispatched("security", "f1")).toBe(true); }); + it("a browser-local run does not claim the (policy, file) dispatch key", () => { + // The local classification heuristic records a run for the same (classification, file) pair + // the server escalation is keyed on. If that claimed the key, the auto-run would read + // "already dispatched" and never ask the AI - which killed escalation entirely. + recordRunStart( + rec({ + runId: "local-classification-f1-1", + categoryId: "classification", + fileId: "f1", + target: "local", + browserLocal: true, + status: "RUNNING", + }), + ); + expect(getRun("local-classification-f1-1")).toBeDefined(); + expect(isDispatched("classification", "f1")).toBe(false); + }); + + it("a real backend run still claims the dispatch key", () => { + recordRunStart(rec({ runId: "srv-1", categoryId: "classification" })); + expect(isDispatched("classification", "f1")).toBe(true); + }); + it("never evicts in-flight runs, even past the soft cap", () => { // A large upload batch can exceed the cap while still processing. Dropping a // live run would orphan its polling/import and undercount progress, so every diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts index 2fd91d215a..a598b8b9b1 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts @@ -47,6 +47,14 @@ export interface PolicyRunRecord { retrying?: boolean; /** Epoch ms when the run was dispatched. */ startedAt: number; + /** + * Ran in the browser (the local classification heuristic), not on a backend. Such a run has no + * server-side status to poll, and - crucially - must NOT claim the (policy, file) dispatch key: + * it is the first pass, not the policy's run, so claiming it would suppress the server run the + * verdict may still need to escalate to. Distinct from {@link target}, which says which BACKEND + * holds a real run's outputs. + */ + browserLocal?: boolean; } /** Statuses of a run that is still executing (not yet settled). */ @@ -216,11 +224,15 @@ export function recordRunStart(record: PolicyRunRecord) { const waveStartedAt = state.runs.some(isRunInFlight) ? state.waveStartedAt : record.startedAt; + // A browser-local run is the first pass, not the policy's run: claiming the dispatch key here + // would permanently suppress the server run its verdict may still need to escalate to. + const claimsDispatch = !record.browserLocal; state = { runs: capRuns([record, ...state.runs]), - dispatched: state.dispatched.includes(key) - ? state.dispatched - : [...state.dispatched, key], + dispatched: + !claimsDispatch || state.dispatched.includes(key) + ? state.dispatched + : [...state.dispatched, key], waveStartedAt, }; emit(); diff --git a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.test.tsx b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.test.tsx index d399b09553..2149161f59 100644 --- a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.test.tsx @@ -77,7 +77,10 @@ vi.mock("@app/services/classificationMeter", () => ({ meterClassificationRun: (payload: unknown) => mocks.meter(payload), })); -import { useClientSideClassification } from "@app/components/policies/useClientSideClassification"; +import { + useClientSideClassification, + LOCAL_METER_CATEGORY, +} from "@app/components/policies/useClientSideClassification"; // Run idle callbacks immediately so batches start without timer waits. vi.stubGlobal("requestIdleCallback", (cb: () => void) => { @@ -181,8 +184,9 @@ describe("useClientSideClassification delivery", () => { }); it("heals a previously-dispatched file whose result was lost, without re-metering", async () => { - // A past session classified + metered this file but the delivery was lost. - markDispatched("classification", "lost"); + // A past session classified + metered this file but the delivery was lost. The marker is the + // local-meter key, NOT the classification dispatch key - that one belongs to the server run. + markDispatched(LOCAL_METER_CATEGORY, "lost"); mocks.workspace = [stub("lost")]; mocks.classify.mockResolvedValue({ labels: ["bank-statement"] }); diff --git a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts index ede1768b2a..0ae9238b12 100644 --- a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts +++ b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts @@ -22,6 +22,12 @@ import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; import type { HeuristicConfidence } from "@app/services/heuristic/types"; import { CLASSIFICATION_CATEGORY_ID } from "@app/data/classificationPolicy"; +/** + * Dispatch-store key namespace for "this file's local pass has been metered". Deliberately NOT the + * Classification category id: that key is the server escalation's own guard, so metering under it + * would tell the auto-run the policy had already run and kill the escalation entirely. + */ +export const LOCAL_METER_CATEGORY = `${CLASSIFICATION_CATEGORY_ID}:local-meter`; /** Files classified per idle pass, so a large library drains over several ticks. */ const CLASSIFY_BATCH = 3; /** How long to wait for an upload's bytes to land in IndexedDB (20 × 250ms ≈ 5s). @@ -161,9 +167,7 @@ async function classifyStub( // A local run is still a billable policy run, so it belongs in the activity feed; recorded only // once the bytes are in hand, so a file whose bytes never land leaves no phantom row. - // Read before recordRunStart, which takes the dispatch key itself and would otherwise always - // answer "already dispatched", silently stopping metering. - const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY_ID, fileId); + const alreadyMetered = isDispatched(LOCAL_METER_CATEGORY, fileId); const runId = `local-${CLASSIFICATION_CATEGORY_ID}-${fileId}-${Date.now()}`; recordRunStart({ runId, @@ -172,6 +176,9 @@ async function classifyStub( fileName, fileSize, target: "local", + // Ran here, not on a backend: nothing to poll, and it must not claim the classification + // dispatch key - that key is what the server escalation checks before running. + browserLocal: true, status: "RUNNING", outputs: [], error: null, @@ -201,7 +208,7 @@ async function classifyStub( labels, }); } - markDispatched(CLASSIFICATION_CATEGORY_ID, fileId); + markDispatched(LOCAL_METER_CATEGORY, fileId); // Labels, no output file - the same settle shape the server-run classification uses. updateRun(runId, { status: "COMPLETED", diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx index a6f78392be..a03d55f757 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx @@ -10,7 +10,13 @@ const aiEnabled = vi.hoisted(() => ({ value: true })); vi.mock("@app/hooks/useAiEngineEnabled", () => ({ useAiEngineEnabled: () => aiEnabled.value, })); -const fileStubs: { id: string; name: string; derivedFromTool?: boolean }[] = []; +const fileStubs: { + id: string; + name: string; + derivedFromTool?: boolean; + classificationLabels?: string[]; + classificationConfidence?: "none" | "low" | "medium" | "high"; +}[] = []; vi.mock("@app/contexts/FileContext", () => ({ useAllFiles: () => ({ fileStubs }), useFileManagement: () => ({ addFiles: vi.fn() }), @@ -55,10 +61,11 @@ import { updateRun, resetPolicyRuns, } from "@app/components/policies/policyRunStore"; -import { runStoredPolicy } from "@app/services/policyApi"; +import { runStoredPolicy, getPolicyRun } from "@app/services/policyApi"; import { fileStorage } from "@app/services/fileStorage"; const runStored = vi.mocked(runStoredPolicy); +const getPolicyRunMock = vi.mocked(getPolicyRun); const getFile = vi.mocked(fileStorage.getStirlingFile); /** Reset the shared file list between tests without swapping the array identity. */ @@ -67,6 +74,27 @@ function setFileStubs(next: typeof fileStubs) { fileStubs.push(...next); } +/** A completed security run whose imported output is file-1-v2, ready to chain from. */ +function seedCompletedSecurityRun() { + recordRunStart({ + runId: "run-sec", + categoryId: "security", + fileId: "file-1", + fileName: "doc.pdf", + fileSize: 100, + target: "saas", + status: "PENDING", + outputs: [], + error: null, + startedAt: 0, + }); + updateRun("run-sec", { + status: "COMPLETED", + imported: true, + outputFileIds: ["file-1-v2"], + }); +} + beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); @@ -133,6 +161,154 @@ describe("auto-run ordered chaining", () => { ); }); + it("escalates a chained output that carries no verdict", async () => { + // The output stub is in the workspace shaped as a new_file-mode delivery (or a + // version made before the upload's verdict landed) produces it: tool-derived, + // labels inherited, NO classificationConfidence. No local pass ever runs on a + // derived file, so waiting for a verdict would skip classification forever — + // it must dispatch to the engine instead. + seedCompletedSecurityRun(); + setFileStubs([ + { + id: "file-1-v2", + name: "doc.pdf", + derivedFromTool: true, + classificationLabels: ["invoice"], + }, + ]); + runStored.mockResolvedValue("run-cls"); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(runStored).toHaveBeenCalledWith( + "backend-cls", + [{ size: 100 }], + "file-1-v2", + ); + }); + + it("chains classification onto an output that inherited an unsure verdict", async () => { + // The default (new_version) delivery: createChildStub copies the parent's + // verdict onto the output, so a low confidence rides through and escalates. + seedCompletedSecurityRun(); + setFileStubs([ + { + id: "file-1-v2", + name: "doc.pdf", + derivedFromTool: true, + classificationLabels: ["invoice"], + classificationConfidence: "low", + }, + ]); + runStored.mockResolvedValue("run-cls"); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(runStored).toHaveBeenCalledWith( + "backend-cls", + [{ size: 100 }], + "file-1-v2", + ); + }); + + it("lets an inherited confident verdict stand — no engine call for the chained output", async () => { + seedCompletedSecurityRun(); + setFileStubs([ + { + id: "file-1-v2", + name: "doc.pdf", + derivedFromTool: true, + classificationLabels: ["invoice"], + classificationConfidence: "high", + }, + ]); + runStored.mockResolvedValue("run-cls"); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect( + runStored.mock.calls.some(([backendId]) => backendId === "backend-cls"), + ).toBe(false); + }); + + it("still escalates after the local pass has recorded its own run for the file", async () => { + // The regression that made the whole escalation dead in practice: the local heuristic records + // a run for the SAME (classification, file) pair, and recordRunStart claims the dispatch key. + // The auto-run then reads "already dispatched" and skips the server run forever. A + // browser-local run must not claim that key - it is the first pass, not the policy's run. + seedCompletedSecurityRun(); + // The local pass ran on the chained output and recorded its own run for it. + recordRunStart({ + runId: "local-classification-file-1-v2-123", + categoryId: "classification", + fileId: "file-1-v2", + fileName: "doc.pdf", + fileSize: 100, + target: "local", + browserLocal: true, + status: "COMPLETED", + outputs: [], + error: null, + startedAt: 0, + }); + // Its verdict was unsure, so the AI must still be asked. + setFileStubs([ + { + id: "file-1-v2", + name: "doc.pdf", + derivedFromTool: true, + classificationConfidence: "low", + }, + ]); + runStored.mockResolvedValue("run-cls"); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(runStored).toHaveBeenCalledWith( + "backend-cls", + [{ size: 100 }], + "file-1-v2", + ); + }); + + it("does not poll a browser-local run against the server", async () => { + // There is no server-side run to ask about: polling 404s, and MAX_NOT_FOUND consecutive + // misses would mark a local run that actually succeeded as FAILED. + recordRunStart({ + runId: "local-classification-file-9-456", + categoryId: "classification", + fileId: "file-9", + fileName: "doc.pdf", + fileSize: 100, + target: "local", + browserLocal: true, + status: "RUNNING", + outputs: [], + error: null, + startedAt: 0, + }); + setFileStubs([]); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + + expect(getPolicyRunMock).not.toHaveBeenCalled(); + }); + it("keeps classification out of the server chain when the AI engine is off", async () => { // AI off: classification runs client-side (useClientSideClassification), so the // server chain must skip it - only the normal (security) policy dispatches. @@ -150,11 +326,9 @@ describe("auto-run ordered chaining", () => { [{ size: 100 }], "file-1", ); - expect(runStored).not.toHaveBeenCalledWith( - "backend-cls", - expect.anything(), - expect.anything(), - ); + expect( + runStored.mock.calls.some(([backendId]) => backendId === "backend-cls"), + ).toBe(false); }); it("never dispatches on a file marked derivedFromTool", async () => { diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx new file mode 100644 index 0000000000..d3eec22b5a --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx @@ -0,0 +1,155 @@ +/** + * The default shipped setup: Classification is the ONLY upload policy, so it dispatches directly + * on the upload rather than through the chain. This is the configuration the escalation was built + * for, and the one where it was completely dead: the browser-side first pass records its own run + * for the same (classification, file) pair, and recordRunStart claims the dispatch key, so the + * auto-run read "already dispatched" and never asked the AI - whatever the verdict said. + * + * Driven against the REAL run store; mocking the store is what let the regression through. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +vi.mock("@app/hooks/useAiEngineEnabled", () => ({ + useAiEngineEnabled: () => true, +})); +const fileStubs: { + id: string; + name: string; + derivedFromTool?: boolean; + classificationLabels?: string[]; + classificationConfidence?: "none" | "low" | "medium" | "high"; +}[] = []; +vi.mock("@app/contexts/FileContext", () => ({ + useAllFiles: () => ({ fileStubs }), + useFileManagement: () => ({ addFiles: vi.fn() }), + useFileContext: () => ({ consumeFiles: vi.fn() }), +})); +vi.mock("@app/hooks/usePolicies", () => ({ + usePolicies: () => ({ + policies: { + classification: { + configured: true, + status: "active", + backendId: "backend-cls", + runOn: "upload", + order: 0, + }, + }, + }), +})); +vi.mock("@app/services/policyApi", () => ({ + runStoredPolicy: vi.fn(), + getPolicyRun: vi.fn(), + downloadPolicyOutput: vi.fn(), + resolvePolicyRunTarget: () => "saas", +})); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() }, +})); +vi.mock("@app/contexts/IndexedDBContext", () => ({ + useIndexedDB: () => ({ bumpRevision: vi.fn() }), +})); + +import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; +import { + recordRunStart, + resetPolicyRuns, +} from "@app/components/policies/policyRunStore"; +import { runStoredPolicy } from "@app/services/policyApi"; +import { fileStorage } from "@app/services/fileStorage"; + +const runStored = vi.mocked(runStoredPolicy); +const getFile = vi.mocked(fileStorage.getStirlingFile); + +function setFileStubs(next: typeof fileStubs) { + fileStubs.length = 0; + fileStubs.push(...next); +} + +/** + * Exactly what useClientSideClassification does when its heuristic pass finishes: a run row for + * the activity feed, categorised as classification, for the file it just read. + */ +function recordLocalPassFor(fileId: string) { + recordRunStart({ + runId: `local-classification-${fileId}-1`, + categoryId: "classification", + fileId, + fileName: "low-confidence-classification-test.pdf", + fileSize: 1460, + target: "local", + browserLocal: true, + status: "COMPLETED", + outputs: [], + error: null, + startedAt: 0, + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + resetPolicyRuns(); + runStored.mockReset(); + runStored.mockResolvedValue("run-cls"); + getFile.mockReset(); + getFile.mockResolvedValue({ size: 1460 } as never); + setFileStubs([]); +}); +afterEach(() => vi.useRealTimers()); + +async function render() { + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); +} + +describe("classification escalation (single-policy setup)", () => { + it("asks the AI about an unsure verdict even though the local pass already ran", async () => { + // low-confidence-classification-test.pdf: the heuristic emits labels but only at "low". + recordLocalPassFor("file-1"); + setFileStubs([ + { + id: "file-1", + name: "low-confidence-classification-test.pdf", + classificationLabels: ["contract", "invoice"], + classificationConfidence: "low", + }, + ]); + + await render(); + + expect(runStored).toHaveBeenCalledWith( + "backend-cls", + [{ size: 1460 }], + "file-1", + ); + }); + + it("leaves a confident local verdict alone (no engine call, no charge)", async () => { + recordLocalPassFor("file-2"); + setFileStubs([ + { + id: "file-2", + name: "invoice.pdf", + classificationLabels: ["invoice"], + classificationConfidence: "high", + }, + ]); + + await render(); + + expect(runStored).not.toHaveBeenCalled(); + }); + + it("waits for the verdict rather than racing the local pass", async () => { + // No verdict yet on a plain upload: dispatching now would pay for an answer the free + // first pass is about to produce. The effect re-runs when the verdict lands. + setFileStubs([{ id: "file-3", name: "unknown.pdf" }]); + + await render(); + + expect(runStored).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 0638b1432c..fa3fcf13d3 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -308,8 +308,10 @@ export function usePolicyAutoRun(): void { for (const outputId of outputIds) { if (isDispatched(nextCategory, outputId as FileId)) continue; const outputStub = stubsRef.current.find((s) => s.id === outputId); - // Nothing to escalate: either the heuristic already answered confidently, or it has - // not reported yet and this effect re-runs when the verdict lands. + // The output's inherited verdict decides here and now (no local pass ever runs + // on a derived file, so there is nothing to defer to): a confident one stands, + // anything else - including no verdict at all, e.g. a new_file-mode delivery - + // escalates. A stub not yet in the snapshot falls through to dispatch too. if (outputStub && !shouldDispatchToAi(nextCategory, outputStub)) continue; void runPolicyOnFile( @@ -326,6 +328,9 @@ export function usePolicyAutoRun(): void { // Poll each in-flight run to a terminal state. useEffect(() => { for (const run of runs) { + // A browser-local run has no server-side status: polling it 404s (and after MAX_NOT_FOUND + // marks a run that actually succeeded as failed). Its own pass settles it. + if (run.browserLocal) continue; if (isTerminal(run.status) || polling.current.has(run.runId)) continue; polling.current.add(run.runId); void poll(run.runId, onRunFinished).finally(() => diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.test.ts b/frontend/editor/src/proprietary/data/classificationPolicy.test.ts index d3cb55c043..cb5dfec779 100644 --- a/frontend/editor/src/proprietary/data/classificationPolicy.test.ts +++ b/frontend/editor/src/proprietary/data/classificationPolicy.test.ts @@ -14,6 +14,14 @@ const stub = ( ): StirlingFileStub => ({ classificationConfidence: confidence }) as StirlingFileStub; +const derivedStub = ( + confidence?: StirlingFileStub["classificationConfidence"], +): StirlingFileStub => + ({ + derivedFromTool: true, + classificationConfidence: confidence, + }) as StirlingFileStub; + describe("isClassificationCategory", () => { it("recognises the classification category and nothing else", () => { expect(isClassificationCategory("classification")).toBe(true); @@ -88,4 +96,19 @@ describe("shouldDispatchToAi", () => { expect(shouldDispatchToAi("classification", stub("low"))).toBe(true); expect(shouldDispatchToAi("classification", stub("none"))).toBe(true); }); + + it("escalates a tool-derived file with no verdict at all", () => { + // A derived file gets no local pass (useClientSideClassification skips it), so + // there is no verdict to wait for: holding back would skip it forever. This is + // the chained case for a new_file-mode output, or a version made before the + // upload's verdict landed. + expect(shouldDispatchToAi("classification", derivedStub())).toBe(true); + }); + + it("lets a derived file's inherited verdict decide like an upload's own", () => { + expect(shouldDispatchToAi("classification", derivedStub("high"))).toBe( + false, + ); + expect(shouldDispatchToAi("classification", derivedStub("low"))).toBe(true); + }); }); diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.ts b/frontend/editor/src/proprietary/data/classificationPolicy.ts index 4327b40d39..60ef0de23f 100644 --- a/frontend/editor/src/proprietary/data/classificationPolicy.ts +++ b/frontend/editor/src/proprietary/data/classificationPolicy.ts @@ -56,8 +56,11 @@ export function orderRewritesFirst(categoryIds: string[]): string[] { const TRUSTED_CONFIDENCE: ClassificationConfidence = "high"; /** - * Whether the AI classifier should be asked about this file. Only once the heuristic has reported: - * dispatching before then races the first pass and bills for an answer it was about to produce. + * Whether the AI classifier should be asked about this file. For an upload, only once the + * heuristic has reported: dispatching before then races the first pass and bills for an answer it + * was about to produce. A tool-derived file gets no local pass (useClientSideClassification skips + * it) and only ever carries an inherited verdict, so an absent verdict there is permanent - + * escalate rather than wait for a report that will never come. */ export function shouldDispatchToAi( categoryId: string, @@ -65,5 +68,6 @@ export function shouldDispatchToAi( ): boolean { if (!isClassificationCategory(categoryId)) return true; const confidence = stub.classificationConfidence; - return confidence != null && confidence !== TRUSTED_CONFIDENCE; + if (confidence == null) return Boolean(stub.derivedFromTool); + return confidence !== TRUSTED_CONFIDENCE; } diff --git a/frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf b/frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf new file mode 100644 index 0000000000..9f1e9852ea --- /dev/null +++ b/frontend/editor/src/proprietary/services/heuristic/fixtures/low-confidence-classification.pdf @@ -0,0 +1,95 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 782 >> +stream +BT +/F1 20 Tf +72 720 Td +(Summary Document) Tj +ET +BT +/F1 12 Tf +72 680 Td +(The parties hereto acknowledge the position set out below.) Tj +ET +BT +/F1 12 Tf +72 658 Td +(Term and termination provisions apply.) Tj +ET +BT +/F1 12 Tf +72 636 Td +(Hereinafter referred to as the Supplier.) Tj +ET +BT +/F1 12 Tf +72 614 Td +(Amount due: 1,250.00) Tj +ET +BT +/F1 12 Tf +72 592 Td +(Payment terms apply.) Tj +ET +BT +/F1 12 Tf +72 570 Td +(Total payable: 1,250.00) Tj +ET +BT +/F1 12 Tf +72 548 Td +(Balance due: 1,250.00) Tj +ET +BT +/F1 12 Tf +72 526 Td +(This document has been prepared for internal review.) Tj +ET +BT +/F1 12 Tf +72 504 Td +(Please retain a copy for your records.) Tj +ET +BT +/F1 12 Tf +72 482 Td +(Reference: SD-2024-0417.) Tj +ET +BT +/F1 12 Tf +72 460 Td +(Prepared by the operations team on 17 April 2024.) Tj +ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Title (Summary Document) /Producer (Stirling-PDF classification test fixture) >> +endobj +xref +0 7 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000001074 00000 n +0000001144 00000 n +trailer +<< /Size 7 /Root 1 0 R /Info 6 0 R >> +startxref +1244 +%%EOF diff --git a/frontend/editor/src/proprietary/services/heuristic/fixtures/lowConfidenceFixture.test.ts b/frontend/editor/src/proprietary/services/heuristic/fixtures/lowConfidenceFixture.test.ts new file mode 100644 index 0000000000..69b07979f1 --- /dev/null +++ b/frontend/editor/src/proprietary/services/heuristic/fixtures/lowConfidenceFixture.test.ts @@ -0,0 +1,60 @@ +/** + * Pins the verdict of `low-confidence-classification.pdf`, the manual-repro fixture for the + * classification escalation. The document is only useful as a repro while the heuristic still + * returns something OTHER than "high" for it - a rules change that made it confident would + * silently turn the manual test into a no-op. + * + * The text below is verbatim pdf.js output for that file, so a failure here means the PDF and + * these expectations have drifted apart - re-extract before changing either. + */ +import { beforeAll, describe, expect, it } from "vitest"; +import { + classifyHeuristic, + ensureRulesLoaded, +} from "@app/services/heuristic/heuristicEngine"; +import type { HeuristicDoc } from "@app/services/heuristic/types"; + +const EXTRACTED = + "Summary Document The parties hereto acknowledge the position set out below. " + + "Term and termination provisions apply. Hereinafter referred to as the Supplier. " + + "Amount due: 1,250.00 Payment terms apply. Total payable: 1,250.00 " + + "Balance due: 1,250.00 This document has been prepared for internal review. " + + "Please retain a copy for your records. Reference: SD-2024-0417. " + + "Prepared by the operations team on 17 April 2024."; + +beforeAll(async () => { + await ensureRulesLoaded(); +}); + +describe("low-confidence-classification.pdf fixture", () => { + const doc: HeuristicDoc = { + fileName: "low-confidence-classification.pdf", + pageCount: 1, + meta: { Title: "Summary Document" }, + titleZone: "Summary Document", + firstZone: EXTRACTED, + allZone: EXTRACTED, + }; + + it("is English, so it is not rejected before scoring", () => { + expect(classifyHeuristic(doc).isEnglish).toBe(true); + }); + + it("emits labels but is not trusted, so it must escalate", () => { + const r = classifyHeuristic(doc); + expect(r.labels.length).toBeGreaterThan(0); + expect(r.confidence).not.toBe("high"); + }); + + it("stays unsure because two document types score within the medium margin", () => { + // The margin is what holds this document at "low": "medium" needs >= 8 and "high" >= 15, + // so a near-tie can't be promoted however high the raw scores go. + const r = classifyHeuristic(doc, { explain: true }); + const [first, second] = r.explain?.candidates ?? []; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(first.score - second.score).toBeLessThan(8); + // ...and comfortably clear of the floor, so it doesn't collapse to "no label" either. + expect(first.score).toBeGreaterThanOrEqual(28); + }); +}); From f7a2c626c9a62993265127488cbaa9b7e8e350cc Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:12:48 +0000 Subject: [PATCH 04/57] Persist the workbench session across the editor/processor switch (#7654) ## What Switching editor -> processor (or reloading) unmounts every editor provider, which emptied the workbench. This PR mirrors the workbench into per-tab sessionStorage and refills an empty one from that record on the next mount: - **Files, selection, view and active document survive** the shell switch and reloads. Each recorded file is resolved to its *current leaf* version on restore, so a file versioned by a policy or another tab comes back at its latest state. - **The switch back lands where the user left**: the processor sidebar's "editor" button consumes a one-shot return path saved at switch time. - **The app switch respects unsaved changes**: `useOtherAppSwitch` (proprietary + saas) now routes through `requestNavigation`, so the same warning guards it as any other navigation. - Desktop shadows `WorkbenchSessionPersistence` with a stub (OS-launched files own boot there). ## How to test I've run through each of these manually: - Upload several PDFs in the editor, select a couple, and switch to the Active Files grid. Click "Open PDF Processor" in the sidebar footer, then switch back to the editor. The same files, selection and view should return, and you should land on the editor page you left. - Open a document in the viewer, then reload the tab. The workbench should refill and come back on the viewer with the same document active. - With unsaved changes in a tool, click the processor switch. The unsaved-changes warning should appear, and the switch should only proceed if you confirm. - Open a second browser tab with different files. Each tab should restore its own workbench independently (the record is per-tab sessionStorage). - While in the processor, delete one of the open files from storage, then switch back. The remaining files should restore and a warning toast should report "Restored X of Y files". --------- Co-authored-by: Claude --- .../public/locales/en-US/translation.toml | 4 + .../src/core/components/AppProviders.tsx | 2 + .../WorkbenchSessionPersistence.test.tsx | 475 ++++++++++++++++++ .../session/WorkbenchSessionPersistence.tsx | 301 +++++++++++ .../editor/src/core/constants/featureFlags.ts | 3 + .../src/core/contexts/NavigationContext.tsx | 9 + .../src/core/extensions/accountLogout.ts | 6 + frontend/editor/src/core/pages/HomePage.tsx | 15 +- .../core/services/workbenchSession.test.ts | 193 +++++++ .../src/core/services/workbenchSession.ts | 173 +++++++ .../tests/stubbed/engine-capabilities.spec.ts | 6 + .../stubbed/workbench-session-restore.spec.ts | 207 ++++++++ .../session/WorkbenchSessionPersistence.tsx | 4 + .../src/desktop/extensions/accountLogout.ts | 5 + .../editor/src/portal/components/Sidebar.tsx | 3 +- .../proprietary/auth/spring/UseSession.tsx | 6 + .../usePolicyAutoRun.reentry.test.tsx | 119 +++++ .../src/proprietary/constants/featureFlags.ts | 3 + .../proprietary/extensions/accountLogout.ts | 6 + .../hooks/useOtherAppSwitch.test.tsx | 55 ++ .../proprietary/hooks/useOtherAppSwitch.ts | 16 +- .../src/saas/auth/AuthProvider.test.tsx | 58 ++- frontend/editor/src/saas/auth/UseSession.tsx | 13 + .../src/saas/hooks/useOtherAppSwitch.ts | 16 +- 24 files changed, 1689 insertions(+), 9 deletions(-) create mode 100644 frontend/editor/src/core/components/session/WorkbenchSessionPersistence.test.tsx create mode 100644 frontend/editor/src/core/components/session/WorkbenchSessionPersistence.tsx create mode 100644 frontend/editor/src/core/services/workbenchSession.test.ts create mode 100644 frontend/editor/src/core/services/workbenchSession.ts create mode 100644 frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts create mode 100644 frontend/editor/src/desktop/components/session/WorkbenchSessionPersistence.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.reentry.test.tsx create mode 100644 frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 1b81a551f1..540836032f 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -11797,6 +11797,10 @@ title = "Watermark Text" image = "Image" text = "Text" +[workbench.sessionRestore] +none = "Your previous files are no longer stored on this device." +partial = "Restored {{restored}} of {{total}} files. The rest are no longer stored on this device." + [workbenchBar] activeFiles = "Active Files" annotations = "Annotations" diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index 01b2e42a82..ed1194eb4b 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -39,6 +39,7 @@ import { RedactionProvider } from "@app/contexts/RedactionContext"; import { FormFillProvider } from "@app/tools/formFill/FormFillContext"; import { FolderFileContextProvider } from "@app/contexts/FolderFileContext"; import { FolderProvider } from "@app/contexts/FolderContext"; +import { WorkbenchSessionPersistence } from "@app/components/session/WorkbenchSessionPersistence"; // Component to initialize scarf tracking (must be inside AppConfigProvider) function ScarfTrackingInitializer() { @@ -163,6 +164,7 @@ export function AppProviders({ + {children} diff --git a/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.test.tsx b/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.test.tsx new file mode 100644 index 0000000000..b05928a93e --- /dev/null +++ b/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.test.tsx @@ -0,0 +1,475 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, waitFor, act } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ + getLeafStirlingFileStubs: vi.fn(), + alert: vi.fn(), + setActiveFileId: vi.fn(), + restoreWorkbench: vi.fn(), + workbench: "viewer" as string, + authUser: null as { id: string } | null, + authLoading: false, + pathname: "/editor", + activeFileId: null as string | null, +})); + +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { getLeafStirlingFileStubs: mocks.getLeafStirlingFileStubs }, +})); +vi.mock("@app/components/toast", () => ({ alert: mocks.alert })); +vi.mock("@app/contexts/NavigationContext", () => ({ + useNavigationState: () => ({ workbench: mocks.workbench }), + useNavigationActions: () => ({ + actions: { restoreWorkbench: mocks.restoreWorkbench }, + }), +})); +vi.mock("react-router-dom", () => ({ + useLocation: () => ({ pathname: mocks.pathname }), +})); +vi.mock("@app/auth/UseSession", () => ({ + useAuth: () => ({ user: mocks.authUser, loading: mocks.authLoading }), +})); +vi.mock("@app/contexts/ViewerContext", () => ({ + useViewer: () => ({ + activeFileId: mocks.activeFileId, + setActiveFileId: mocks.setActiveFileId, + }), +})); + +import { WorkbenchSessionPersistence } from "@app/components/session/WorkbenchSessionPersistence"; +import { fingerprintOwner } from "@app/services/workbenchSession"; +import { + FileStoreContext, + FileActionsContext, +} from "@app/contexts/file/contexts"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +const SESSION_KEY = "stirling.workbench.session"; + +function stub( + id: string, + originalFileId: string, + versionNumber = 1, +): StirlingFileStub { + return { id, originalFileId, versionNumber, name: `${id}.pdf` } as never; +} + +// A minimal stand-in for the FileContext store: mutable state plus subscribers. +function makeStore(open: StirlingFileStub[] = [], selected: string[] = []) { + const listeners = new Set<() => void>(); + const state = { + files: { + ids: open.map((s) => s.id), + byId: Object.fromEntries(open.map((s) => [s.id, s])), + }, + ui: { selectedFileIds: selected }, + }; + return { + state, + getState: () => state as never, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + // reopenView waits on this to know the restored bytes have landed. + selectors: { + getFiles: (ids: string[]) => ids.map((id) => ({ id })), + } as never, + notify: () => listeners.forEach((listener) => listener()), + }; +} + +const actions = { + addStirlingFileStubs: vi.fn().mockResolvedValue([]), + setSelectedFiles: vi.fn(), +}; + +function mount(store: ReturnType) { + return render( + + + + + , + ); +} + +beforeEach(() => { + // The shared setup stubs crypto.subtle.digest to one constant for every input, so every account + // would fingerprint alike - and ownership is exactly what these tests are about. + vi.spyOn(globalThis.crypto.subtle, "digest").mockImplementation( + async (_algorithm: AlgorithmIdentifier, data: BufferSource) => { + const bytes = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : new Uint8Array(data); + let hash = 0x811c9dc5; + for (const byte of bytes) { + hash = Math.imul(hash ^ byte, 0x01000193) >>> 0; + } + const out = new Uint8Array(32); + for (let i = 0; i < out.length; i++) { + hash = Math.imul(hash ^ i, 0x01000193) >>> 0; + out[i] = hash & 0xff; + } + return out.buffer; + }, + ); + sessionStorage.clear(); + vi.clearAllMocks(); + actions.addStirlingFileStubs.mockResolvedValue([]); + mocks.getLeafStirlingFileStubs.mockResolvedValue([]); + mocks.workbench = "viewer"; + mocks.authUser = null; + mocks.authLoading = false; + mocks.pathname = "/editor"; + mocks.activeFileId = null; +}); +afterEach(() => vi.useRealTimers()); + +describe("restore", () => { + it("refills an empty workbench with each file's current leaf, in saved order", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a", "root-b"], + selectedFileIds: ["root-b"], + }), + ); + // root-a forked while the user was away: v3 must win over the stale v1 leaf. + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("a-v1", "root-a", 1), + stub("a-v3", "root-a", 3), + stub("root-b", "root-b", 1), + ]); + + mount(makeStore()); + + await waitFor(() => + expect(actions.addStirlingFileStubs).toHaveBeenCalled(), + ); + const restored = actions.addStirlingFileStubs.mock.calls[0][0]; + expect(restored.map((s: StirlingFileStub) => s.id)).toEqual([ + "a-v3", + "root-b", + ]); + expect(actions.setSelectedFiles).toHaveBeenCalledWith(["root-b"]); + expect(mocks.alert).not.toHaveBeenCalled(); + }); + + it("does not touch a workbench that already holds files", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }), + ); + mount(makeStore([stub("already-open", "already-open")])); + + await act(async () => {}); + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + }); + + it("restores what still exists and says how much is gone", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a", "gone"], + selectedFileIds: [], + }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await waitFor(() => expect(mocks.alert).toHaveBeenCalled()); + expect(actions.addStirlingFileStubs.mock.calls[0][0]).toHaveLength(1); + expect(mocks.alert.mock.calls[0][0].alertType).toBe("warning"); + }); + + it("does not say 'the rest' when nothing at all could be restored", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["gone-1", "gone-2"], + selectedFileIds: [], + }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([]); + + mount(makeStore()); + + await waitFor(() => expect(mocks.alert).toHaveBeenCalled()); + expect(mocks.alert.mock.calls[0][0].title).toBe( + "workbench.sessionRestore.none", + ); + }); + + it("does nothing when no session was recorded", async () => { + mount(makeStore()); + await act(async () => {}); + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + expect(mocks.getLeafStirlingFileStubs).not.toHaveBeenCalled(); + }); + + it("reopens the document the user was viewing, at its current version", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a"], + selectedFileIds: ["root-a"], + workbench: "fileEditor", + activeFileId: "root-a", + }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("a-v2", "root-a", 2), + ]); + + mount(makeStore()); + + await waitFor(() => expect(mocks.setActiveFileId).toHaveBeenCalled()); + expect(mocks.setActiveFileId).toHaveBeenCalledWith("a-v2"); + expect(mocks.restoreWorkbench).toHaveBeenCalledWith("fileEditor"); + }); + + it("leaves a URL-owned view to the return path", async () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a"], + selectedFileIds: [], + workbench: "myFiles", + }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await waitFor(() => + expect(actions.addStirlingFileStubs).toHaveBeenCalled(), + ); + expect(mocks.restoreWorkbench).not.toHaveBeenCalled(); + }); +}); + +describe("whose workbench it is", () => { + // Records hold a fingerprint of the owner, never the account id. + const record = async (userId: string | null) => + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a"], + selectedFileIds: [], + userId: userId == null ? null : await fingerprintOwner(userId), + }), + ); + + it("does not open one user's workbench for the next person in the tab", async () => { + await record("user-a"); + mocks.authUser = { id: "user-b" }; + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await act(async () => {}); + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + // The record is theirs now - the previous person's files are gone from it, so they cannot + // resurface later in the session. + const taken = JSON.parse(sessionStorage.getItem(SESSION_KEY)!); + expect(taken.fileIds).toEqual([]); + expect(taken.userId).toBe(await fingerprintOwner("user-b")); + }); + + it("reopens it for the user who left it", async () => { + await record("user-a"); + mocks.authUser = { id: "user-a" }; + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await waitFor(() => + expect(actions.addStirlingFileStubs).toHaveBeenCalled(), + ); + }); + + it("waits for the session before deciding", async () => { + await record("user-a"); + mocks.authUser = null; + mocks.authLoading = true; + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + mount(makeStore()); + + await act(async () => {}); + // Neither restored nor discarded - who is signed in is not known yet. + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull(); + }); +}); + +describe("a lost session that comes back", () => { + const rerenderWith = ( + view: ReturnType, + store: ReturnType, + ) => + view.rerender( + + + + + , + ); + + it("survives a blip on the identity check", async () => { + // A failed /auth/me - flaky wifi, a backend redeploy, a refreshSession() that did not land - + // briefly reads as nobody signed in. It must not be mistaken for signing out. + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 2, + fileIds: ["root-a"], + selectedFileIds: [], + userId: await fingerprintOwner("user-a"), + }), + ); + mocks.authUser = { id: "user-a" }; + const store = makeStore([stub("f1", "f1")]); + const view = mount(store); + await act(async () => {}); + + mocks.authUser = null; + rerenderWith(view, store); + await act(async () => {}); + + expect(sessionStorage.getItem(SESSION_KEY)).not.toBeNull(); + + // ...and once the identity is back, the workbench is still being recorded. + mocks.authUser = { id: "user-a" }; + rerenderWith(view, store); + // Let the fingerprint land: writes hold off while a known identity has none yet. + await act(async () => {}); + store.state.files.ids = ["f2" as never]; + store.state.files.byId = { f2: stub("f2", "root-b") } as never; + act(() => store.notify()); + view.unmount(); + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([ + "root-b", + ]); + }); +}); + +describe("on the login screen", () => { + it("neither restores nor records - signing out must not rebuild the workbench there", async () => { + mocks.pathname = "/login"; + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }), + ); + mocks.getLeafStirlingFileStubs.mockResolvedValue([ + stub("root-a", "root-a"), + ]); + + const store = makeStore(); + const { unmount } = mount(store); + await act(async () => {}); + expect(actions.addStirlingFileStubs).not.toHaveBeenCalled(); + + // And the unmount flush must not write either. + store.state.files.ids = ["f1" as never]; + store.state.files.byId = { f1: stub("f1", "f1") } as never; + act(() => store.notify()); + unmount(); + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([ + "root-a", + ]); + }); +}); + +describe("writer", () => { + it("mirrors the open files and selection as original ids, debounced", async () => { + vi.useFakeTimers(); + const store = makeStore(); + mount(store); + + store.state.files.ids = ["v2" as never]; + store.state.files.byId = { v2: stub("v2", "root-a", 2) } as never; + store.state.ui.selectedFileIds = ["v2"]; + act(() => store.notify()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!)).toMatchObject({ + fileIds: ["root-a"], + selectedFileIds: ["root-a"], + workbench: "viewer", + }); + }); + + it("records the current view, so the return lands where the user left", async () => { + vi.useFakeTimers(); + mocks.workbench = "fileEditor"; + const store = makeStore([stub("f1", "f1")]); + mount(store); + + act(() => store.notify()); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).workbench).toBe( + "fileEditor", + ); + }); + + it("writes nothing until the restore has settled", () => { + vi.useFakeTimers(); + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: ["root-a"], selectedFileIds: [] }), + ); + // Restore is still awaiting storage, so this mount's empty state is not the truth. + mocks.getLeafStirlingFileStubs.mockReturnValue(new Promise(() => {})); + + const store = makeStore(); + const { unmount } = mount(store); + act(() => store.notify()); + unmount(); + + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([ + "root-a", + ]); + }); + + it("flushes on unmount, so the state at the shell switch survives", () => { + vi.useFakeTimers(); + const store = makeStore(); + const { unmount } = mount(store); + + store.state.files.ids = ["f1" as never]; + store.state.files.byId = { f1: stub("f1", "f1") } as never; + act(() => store.notify()); + unmount(); + + expect(JSON.parse(sessionStorage.getItem(SESSION_KEY)!).fileIds).toEqual([ + "f1", + ]); + }); +}); diff --git a/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.tsx b/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.tsx new file mode 100644 index 0000000000..86bf72502c --- /dev/null +++ b/frontend/editor/src/core/components/session/WorkbenchSessionPersistence.tsx @@ -0,0 +1,301 @@ +// The editor/processor shell switch unmounts every editor provider, and a reload starts from nothing: +// this mirrors the workbench into sessionStorage and refills an empty one from that record on mount. +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + FileStoreContext, + type FileStateStore, +} from "@app/contexts/file/contexts"; +import { useFileActions } from "@app/contexts/FileContext"; +import { + useNavigationActions, + useNavigationState, +} from "@app/contexts/NavigationContext"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { useAuth } from "@app/auth/UseSession"; +import { useLocation } from "react-router-dom"; +import { isAuthRoute } from "@app/constants/routes"; +import { fileStorage } from "@app/services/fileStorage"; +import { alert } from "@app/components/toast"; +import { WORKBENCH_SESSION_RESTORE } from "@app/constants/featureFlags"; +import { + beginRestoredView, + clearWorkbenchSession, + fingerprintOwner, + resumeWorkbenchSession, + endRestoredView, + isSeedableView, + originalIdOf, + readWorkbenchSession, + writeWorkbenchSession, +} from "@app/services/workbenchSession"; +import type { WorkbenchType } from "@app/types/workbench"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const WRITE_DEBOUNCE_MS = 300; + +// Current leaf per original id; a forked chain resolves to the highest version. +function leafByOriginalId( + leaves: StirlingFileStub[], +): Map { + const map = new Map(); + for (const leaf of leaves) { + const key = originalIdOf(leaf); + const current = map.get(key); + if (!current || (leaf.versionNumber ?? 1) > (current.versionNumber ?? 1)) { + map.set(key, leaf); + } + } + return map; +} + +/** How long to wait for the NEXT file to hydrate before giving up on holding the view. Restarted on + * each arrival, so a slow device with large documents keeps the view as long as it makes progress. */ +const SETTLE_TIMEOUT_MS = 5000; + +/** Released a beat late, so effects reacting to the same commit still see the restore in progress. */ +const RELEASE_GRACE_MS = 250; + +/** + * Reopen the recorded view, then hold the restore guard until the files have hydrated. + * + * The view is written ONCE. Re-asserting it after hydration would also overwrite a view the user + * picked in the meantime; holding the guard is what keeps HomePage's defaults off it instead. + */ +function reopenView( + store: FileStateStore, + reopen: (view: WorkbenchType) => void, + { + view, + fileCount, + token, + }: { view: WorkbenchType; fileCount: number; token: number }, +): void { + reopen(view); + const loaded = () => + store.selectors.getFiles(store.getState().files.ids).length; + + const release = () => + setTimeout(() => endRestoredView(token), RELEASE_GRACE_MS); + if (loaded() >= fileCount) { + release(); + return; + } + + let timer: ReturnType; + const stop = () => { + clearTimeout(timer); + unsubscribe(); + release(); + }; + const waitForNext = () => { + clearTimeout(timer); + timer = setTimeout(stop, SETTLE_TIMEOUT_MS); + }; + + let seen = loaded(); + const unsubscribe = store.subscribe(() => { + const now = loaded(); + if (now >= fileCount) return stop(); + // Progress, not completion: give the remaining files a fresh window. + if (now > seen) { + seen = now; + waitForNext(); + } + }); + waitForNext(); +} + +export function WorkbenchSessionPersistence() { + const store = useContext(FileStoreContext); + const { actions } = useFileActions(); + const { workbench } = useNavigationState(); + const { actions: navigationActions } = useNavigationActions(); + const { activeFileId, setActiveFileId } = useViewer(); + const { user, loading: authLoading } = useAuth(); + // Login/signup mount the editor's providers too. Nothing there is the user's workbench, so this + // records nothing and restores nothing - otherwise signing out rebuilds it on the login screen. + const onAuthRoute = isAuthRoute(useLocation().pathname); + const userId = user?.id != null ? String(user.id) : null; + // Fingerprinted, never stored raw - see fingerprintOwner. Computed asynchronously, so writes + // hold off until it lands rather than stamping the record "nobody's" and then failing its own + // ownership check. + const [owner, setOwner] = useState(null); + useEffect(() => { + if (userId == null) { + setOwner(null); + return; + } + let cancelled = false; + void fingerprintOwner(userId).then((fingerprint) => { + if (!cancelled) setOwner(fingerprint); + }); + return () => { + cancelled = true; + }; + }, [userId]); + const { t } = useTranslation(); + // Captured before the writer below can overwrite it with the empty boot state. + const [saved] = useState(readWorkbenchSession); + const restoreStarted = useRef(false); + // Until the restore has run, this mount's empty state is not the truth to record. + const restoreSettled = useRef(false); + + // Published so a build's restore setting is legible without reading the bundle. + useEffect(() => { + document.documentElement.dataset.workbenchRestore = String( + WORKBENCH_SESSION_RESTORE, + ); + }, []); + + const write = useCallback(() => { + if (!store || !restoreSettled.current) return; + // A known identity whose fingerprint has not landed yet: wait, do not stamp it as nobody's. + if (userId != null && owner == null) return; + const state = store.getState(); + const toOriginal = (id: FileId): string | null => { + const stub = state.files.byId[id]; + return stub ? originalIdOf(stub) : null; + }; + const isPresent = (id: string | null): id is string => id !== null; + writeWorkbenchSession({ + fileIds: state.files.ids.map(toOriginal).filter(isPresent), + selectedFileIds: state.ui.selectedFileIds + .map(toOriginal) + .filter(isPresent), + workbench, + userId: owner, + activeFileId: activeFileId + ? (toOriginal(activeFileId as FileId) ?? undefined) + : undefined, + }); + }, [store, workbench, activeFileId, userId, owner]); + + // Read by the file subscription, which must not resubscribe on every view change. + const writeRef = useRef(write); + writeRef.current = write; + + useEffect(() => { + if (!store || onAuthRoute) return; + // This mount is a new session: undo any suspension left by a sign-out in this page's lifetime. + resumeWorkbenchSession(); + let timer: ReturnType | undefined; + const unsubscribe = store.subscribe(() => { + clearTimeout(timer); + timer = setTimeout(() => writeRef.current(), WRITE_DEBOUNCE_MS); + }); + return () => { + clearTimeout(timer); + // Flush, so the state at the moment of the shell switch is what survives. + writeRef.current(); + unsubscribe(); + }; + }, [store, onAuthRoute]); + + // Changing view touches no file state, so the subscription above never sees it. + useEffect(() => write(), [write]); + + useEffect(() => { + if (restoreStarted.current) return; + if (onAuthRoute) return; + // Who is signed in decides whether this record is theirs to reopen, so settle that first. + if (authLoading) return; + restoreStarted.current = true; + + const nothingToDo = + !WORKBENCH_SESSION_RESTORE || + !store || + !saved || + saved.fileIds.length === 0 || + store.getState().files.ids.length > 0; + if (nothingToDo) { + restoreSettled.current = true; + return; + } + + void (async () => { + // A tab can outlive a sign-out (the logout clears it, but a 401 bounce or an expiry does + // not), and the next person to sign in here must not open the last person's documents. + const currentOwner = + userId == null ? null : await fingerprintOwner(userId); + if ((saved.userId ?? null) !== currentOwner) { + clearWorkbenchSession(); + restoreSettled.current = true; + return; + } + + // Held while the files land: they are added one at a time, and each landing re-runs the + // default-view heuristic, which must not overwrite the recorded view mid-restore. + let held: number | null = null; + try { + // Resolve each id to its CURRENT leaf: a policy or another tab may have versioned it since. + const leaves = leafByOriginalId( + await fileStorage.getLeafStirlingFileStubs(), + ); + const stubs = saved.fileIds + .map((id) => leaves.get(id)) + .filter((stub): stub is StirlingFileStub => stub !== undefined); + + if (stubs.length > 0) { + const view = isSeedableView(saved.workbench) ? saved.workbench : null; + if (view) held = beginRestoredView(); + // The same entry point My Files uses, so a restored file is governed by the same rules as + // any other file entering the workbench - including whether a policy has already run on it. + await actions.addStirlingFileStubs(stubs); + const selected = saved.selectedFileIds + .map((id) => leaves.get(id)?.id) + .filter((id): id is FileId => id !== undefined); + if (selected.length > 0) actions.setSelectedFiles(selected); + // After the files land: the viewer drops an active id it cannot find. + const active = saved.activeFileId + ? leaves.get(saved.activeFileId)?.id + : undefined; + if (active) setActiveFileId(active as string); + if (view && held !== null) { + reopenView(store, navigationActions.restoreWorkbench, { + view, + fileCount: stubs.length, + token: held, + }); + held = null; // reopenView owns the release from here. + } + } + + const missing = saved.fileIds.length - stubs.length; + if (missing > 0) { + alert({ + alertType: "warning", + title: + stubs.length === 0 + ? t( + "workbench.sessionRestore.none", + "Your previous files are no longer stored on this device.", + ) + : t( + "workbench.sessionRestore.partial", + "Restored {{restored}} of {{total}} files. The rest are no longer stored on this device.", + { restored: stubs.length, total: saved.fileIds.length }, + ), + }); + } + } finally { + if (held !== null) endRestoredView(held); + // Even a failed restore must release the writer, or the record freezes for the session. + restoreSettled.current = true; + } + })(); + }, [ + saved, + store, + actions, + navigationActions, + setActiveFileId, + t, + authLoading, + userId, + onAuthRoute, + ]); + + return null; +} diff --git a/frontend/editor/src/core/constants/featureFlags.ts b/frontend/editor/src/core/constants/featureFlags.ts index a60770ac3a..8981dce2d6 100644 --- a/frontend/editor/src/core/constants/featureFlags.ts +++ b/frontend/editor/src/core/constants/featureFlags.ts @@ -11,3 +11,6 @@ // Annotated as `boolean` (not the literal `false`) so call sites aren't treated // as constant/unreachable conditions by the type checker and linter. export const WATCHED_FOLDERS_ENABLED: boolean = false; + +// Refill an empty workbench from the tab's last session (survives a provider remount or a reload). +export const WORKBENCH_SESSION_RESTORE: boolean = true; diff --git a/frontend/editor/src/core/contexts/NavigationContext.tsx b/frontend/editor/src/core/contexts/NavigationContext.tsx index a7cb95d349..f158991378 100644 --- a/frontend/editor/src/core/contexts/NavigationContext.tsx +++ b/frontend/editor/src/core/contexts/NavigationContext.tsx @@ -94,6 +94,9 @@ export interface NavigationWarningHandlers { // Navigation context actions interface export interface NavigationContextActions { setWorkbench: (workbench: WorkbenchType) => void; + /** Reopen a view the user already had, bypassing the unsaved-changes prompt that + * guards a user-initiated switch - a restore is not the user leaving anything. */ + restoreWorkbench: (workbench: WorkbenchType) => void; setSelectedTool: (toolId: ToolId | null) => void; setToolAndWorkbench: ( toolId: ToolId | null, @@ -221,6 +224,10 @@ export const NavigationProvider: React.FC<{ [state.workbench, state.hasUnsavedChanges], ); + const restoreWorkbench = useCallback((workbench: WorkbenchType) => { + dispatch({ type: "SET_WORKBENCH", payload: { workbench } }); + }, []); + const setSelectedTool = useCallback((toolId: ToolId | null) => { dispatch({ type: "SET_SELECTED_TOOL", payload: { toolId } }); }, []); @@ -402,6 +409,7 @@ export const NavigationProvider: React.FC<{ const actions: NavigationContextActions = useMemo( () => ({ setWorkbench, + restoreWorkbench, setSelectedTool, setToolAndWorkbench, setHasUnsavedChanges, @@ -419,6 +427,7 @@ export const NavigationProvider: React.FC<{ }), [ setWorkbench, + restoreWorkbench, setSelectedTool, setToolAndWorkbench, setHasUnsavedChanges, diff --git a/frontend/editor/src/core/extensions/accountLogout.ts b/frontend/editor/src/core/extensions/accountLogout.ts index e4eddd7274..df4e8f4273 100644 --- a/frontend/editor/src/core/extensions/accountLogout.ts +++ b/frontend/editor/src/core/extensions/accountLogout.ts @@ -1,3 +1,5 @@ +import { suspendWorkbenchSession } from "@app/services/workbenchSession"; + type SignOutFn = () => Promise; interface AccountLogoutDeps { @@ -21,6 +23,10 @@ export function useAccountLogout() { "1", ); } + // The tab outlives the session; the next person to sign in here must not + // inherit this workbench. Suspends writing too - signing out unmounts the + // editor, and its flush would otherwise write the record straight back. + suspendWorkbenchSession(); await signOut(); } finally { redirectToLogin(); diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index e0c5303447..0d7a571f98 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -15,6 +15,7 @@ import { useNavigationState, useNavigationActions, } from "@app/contexts/NavigationContext"; +import { isApplyingRestoredView } from "@app/services/workbenchSession"; import { useViewer } from "@app/contexts/ViewerContext"; import { useLocation, useNavigate } from "react-router-dom"; import AppsIcon from "@mui/icons-material/AppsRounded"; @@ -161,8 +162,14 @@ export default function HomePage() { if (navigationState.workbench !== "myFiles") { actions.setWorkbench("myFiles"); } - } else if (navigationState.workbench === "myFiles") { - // Leaving the file manager - drop back to a sensible default. + } else if ( + navigationState.workbench === "myFiles" && + !isApplyingRestoredView() + ) { + // The URL no longer supports the file manager - drop back to a sensible default. Stays a + // state check rather than a transition one: HomePage remounts without NavigationContext + // (a share link, a login bounce), and the view has to be corrected on arrival too. + // Skipped mid-restore, which is reopening a recorded view onto files still loading. actions.setWorkbench(activeFiles.length > 1 ? "fileEditor" : "viewer"); } }, [ @@ -204,7 +211,9 @@ export default function HomePage() { navigationState.workbench, ); - if (action) { + // A session restore fills an empty workbench too, but it already knows which view the user + // left - so it wins over this heuristic rather than being overwritten by it. + if (action && !isApplyingRestoredView()) { actions.setWorkbench(action.workbench); if (typeof action.activeFileIndex === "number") { setActiveFileIndex(action.activeFileIndex); diff --git a/frontend/editor/src/core/services/workbenchSession.test.ts b/frontend/editor/src/core/services/workbenchSession.test.ts new file mode 100644 index 0000000000..d9feec8d1c --- /dev/null +++ b/frontend/editor/src/core/services/workbenchSession.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + originalIdOf, + readWorkbenchSession, + writeWorkbenchSession, + saveEditorReturnPath, + takeEditorReturnPath, + isSeedableView, + clearWorkbenchSession, + suspendWorkbenchSession, + resumeWorkbenchSession, +} from "@app/services/workbenchSession"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +const SESSION_KEY = "stirling.workbench.session"; + +beforeEach(() => { + sessionStorage.clear(); + resumeWorkbenchSession(); +}); + +describe("workbench session record", () => { + it("round-trips the open files and selection", () => { + writeWorkbenchSession({ fileIds: ["a", "b"], selectedFileIds: ["b"] }); + expect(readWorkbenchSession()).toMatchObject({ + fileIds: ["a", "b"], + selectedFileIds: ["b"], + }); + }); + + it("returns null when nothing was recorded", () => { + expect(readWorkbenchSession()).toBeNull(); + }); + + it("rejects a malformed record instead of throwing", () => { + sessionStorage.setItem(SESSION_KEY, "not json"); + expect(readWorkbenchSession()).toBeNull(); + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: "nope" }), + ); + expect(readWorkbenchSession()).toBeNull(); + }); + + it("drops non-string ids and defaults a missing selection", () => { + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ v: 2, fileIds: ["a", 7, null, "b"] }), + ); + expect(readWorkbenchSession()).toMatchObject({ + fileIds: ["a", "b"], + selectedFileIds: [], + }); + }); +}); + +describe("editor return path", () => { + it("is consumed by the first take", () => { + saveEditorReturnPath("/compress?x=1"); + expect(takeEditorReturnPath()).toBe("/compress?x=1"); + expect(takeEditorReturnPath()).toBeNull(); + }); +}); + +describe("originalIdOf", () => { + it("prefers the original id and falls back to the file id", () => { + expect( + originalIdOf({ id: "v3", originalFileId: "root" } as StirlingFileStub), + ).toBe("root"); + expect( + originalIdOf({ id: "v1", originalFileId: "" } as StirlingFileStub), + ).toBe("v1"); + }); +}); + +describe("record hygiene", () => { + it("discards a record written by an older schema", () => { + // No version stamp: a shape this build no longer understands. + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ fileIds: ["a"], selectedFileIds: [] }), + ); + expect(readWorkbenchSession()).toBeNull(); + + // v1 recorded userId before it meant anything, so those must go too rather than + // look like a workbench that legitimately belongs to an anonymous session. + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ + v: 1, + fileIds: ["a"], + selectedFileIds: [], + userId: null, + }), + ); + expect(readWorkbenchSession()).toBeNull(); + }); + + it("drops the previous record when a write fails, rather than leaving it stale", () => { + writeWorkbenchSession({ fileIds: ["old"], selectedFileIds: [] }); + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + + writeWorkbenchSession({ fileIds: ["new"], selectedFileIds: [] }); + setItem.mockRestore(); + + // Better to restore nothing than to restore a workbench the user has moved on from. + expect(readWorkbenchSession()).toBeNull(); + }); + + it("records who the workbench belonged to", () => { + writeWorkbenchSession({ + fileIds: ["a"], + selectedFileIds: [], + userId: "user-1", + }); + expect(readWorkbenchSession()?.userId).toBe("user-1"); + }); + + it("stays gone after sign-out, even though the teardown writes once more", () => { + writeWorkbenchSession({ fileIds: ["a", "b"], selectedFileIds: [] }); + + suspendWorkbenchSession(); + // Signing out unmounts the editor, whose flush writes the workbench one last time - + // with no user attached. Clearing alone let that recreate the record. + writeWorkbenchSession({ + fileIds: ["a", "b"], + selectedFileIds: [], + userId: null, + }); + + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); + + it("keeps the owner when the identity is momentarily unknown", () => { + // A sign-out teardown and a failed /auth/me both write with no user attached. Losing the + // owner here would make the record unrestorable for the person it belongs to. + writeWorkbenchSession({ + fileIds: ["a", "b"], + selectedFileIds: [], + userId: "user-a", + }); + + writeWorkbenchSession({ + fileIds: ["a", "b"], + selectedFileIds: [], + userId: null, + }); + + expect(readWorkbenchSession()?.userId).toBe("user-a"); + }); + + it("still records for a genuinely anonymous session", () => { + // Core has no auth at all, so null is the normal owner there and must keep working. + writeWorkbenchSession({ + fileIds: ["a"], + selectedFileIds: [], + userId: null, + }); + expect(readWorkbenchSession()?.fileIds).toEqual(["a"]); + }); + + it("records again once a new editor session starts", () => { + suspendWorkbenchSession(); + resumeWorkbenchSession(); + writeWorkbenchSession({ fileIds: ["a"], selectedFileIds: [] }); + expect(readWorkbenchSession()?.fileIds).toEqual(["a"]); + }); + + it("clears on request", () => { + writeWorkbenchSession({ fileIds: ["a"], selectedFileIds: [] }); + clearWorkbenchSession(); + expect(readWorkbenchSession()).toBeNull(); + }); +}); + +describe("views the restore may reopen", () => { + it("accepts the workbench views a session can land on", () => { + expect(isSeedableView("viewer")).toBe(true); + expect(isSeedableView("fileEditor")).toBe(true); + expect(isSeedableView("pageEditor")).toBe(true); + }); + + it("leaves URL-owned and tool-owned views alone", () => { + // HomePage pins myFiles to /files and bounces it elsewhere; custom views belong to a tool. + expect(isSeedableView("myFiles")).toBe(false); + expect(isSeedableView("custom:compare")).toBe(false); + expect(isSeedableView(undefined)).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/services/workbenchSession.ts b/frontend/editor/src/core/services/workbenchSession.ts new file mode 100644 index 0000000000..67f6d1abfc --- /dev/null +++ b/frontend/editor/src/core/services/workbenchSession.ts @@ -0,0 +1,173 @@ +// The tab's last editor session (open files, selection, view), so a processor switch or reload +// does not cost the user their workbench. sessionStorage on purpose: per-tab, tabs never clobber. + +import type { StirlingFileStub } from "@app/types/fileContext"; + +const SESSION_KEY = "stirling.workbench.session"; +/** Bumped when the record's shape or meaning changes, so an old one is discarded rather than + * half-read. v2: `userId` became meaningful - v1 records were written without a real owner and + * would otherwise look like they belonged to an anonymous session forever. */ +const SESSION_VERSION = 2; +const RETURN_PATH_KEY = "stirling.workbench.editorReturnPath"; + +// All ids are ORIGINAL file ids - a file's stable identity across versions. +export interface WorkbenchSession { + fileIds: string[]; + selectedFileIds: string[]; + /** Which view was on screen. Absent for a record written before this was tracked. */ + workbench?: string; + activeFileId?: string; + /** Fingerprint of who the workbench belonged to, so the next person in this tab does not + * inherit it. Never the account id itself - see {@link fingerprintOwner}. */ + userId?: string | null; +} + +/** A file's stable identity across versions - what the session records. */ +export function originalIdOf(stub: StirlingFileStub): string { + return stub.originalFileId || (stub.id as string); +} + +export function readWorkbenchSession(): WorkbenchSession | null { + try { + const raw = sessionStorage.getItem(SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial & { + v?: number; + }; + if (parsed.v !== SESSION_VERSION) return null; + if (!Array.isArray(parsed.fileIds)) return null; + return { + fileIds: parsed.fileIds.filter((id) => typeof id === "string"), + selectedFileIds: Array.isArray(parsed.selectedFileIds) + ? parsed.selectedFileIds.filter((id) => typeof id === "string") + : [], + workbench: + typeof parsed.workbench === "string" ? parsed.workbench : undefined, + activeFileId: + typeof parsed.activeFileId === "string" + ? parsed.activeFileId + : undefined, + userId: typeof parsed.userId === "string" ? parsed.userId : null, + }; + } catch { + return null; + } +} + +// Sign-out clears the record, but signing out also tears the editor down - and that teardown +// flushes the workbench one last time, recreating what we just deleted (with no user attached). +// So a sign-out has to stop writing too, not merely clear. +let writesSuspended = false; + +/** Sign-out: drop the record and stop recording, so the teardown cannot put it back. */ +export function suspendWorkbenchSession(): void { + writesSuspended = true; + clearWorkbenchSession(); +} + +/** A fresh editor mount is a new session, so recording starts again. */ +export function resumeWorkbenchSession(): void { + writesSuspended = false; +} + +export function writeWorkbenchSession(session: WorkbenchSession): void { + if (writesSuspended) return; + try { + // Never downgrade a known owner to "nobody". Signing out and a failed identity check both + // read as no user, and dropping the owner would either hand the workbench to whoever signs + // in next or lose it for the person it belongs to. Keeping the owner leaves the restore's + // ownership check to decide, which it does with a settled identity. + const owner = session.userId ?? readWorkbenchSession()?.userId ?? null; + sessionStorage.setItem( + SESSION_KEY, + JSON.stringify({ ...session, userId: owner, v: SESSION_VERSION }), + ); + } catch { + // Storage refused (quota, privacy mode). setItem is atomic, so the PREVIOUS record would + // survive and restore an older workbench - drop it, so the failure is "no restore" instead. + clearWorkbenchSession(); + } +} + +/** + * A one-way fingerprint of the signed-in user. Owners are only ever compared, never read back, so + * the account id itself never needs to reach storage. Falls back to a non-cryptographic digest + * where SubtleCrypto is absent (a self-hosted instance served over plain http): the fingerprint + * only has to tell two accounts sharing one tab apart, and the files it gates are reachable from + * My Files regardless, since IndexedDB is per-origin. + */ +export async function fingerprintOwner(userId: string): Promise { + if (globalThis.crypto?.subtle) { + const digest = await globalThis.crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(userId), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + .slice(0, 32); + } + let hash = 0x811c9dc5; + for (let i = 0; i < userId.length; i++) { + hash ^= userId.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return `fnv-${hash.toString(16)}`; +} + +/** Drop the record: on sign-out, and whenever it would otherwise be restored for the wrong person. */ +export function clearWorkbenchSession(): void { + try { + sessionStorage.removeItem(SESSION_KEY); + } catch { + // A record we cannot remove is also one we cannot read. + } +} + +/** Views a restore may seed directly. "myFiles" is URL-owned (HomePage pins it to /files) and a + * custom view belongs to its tool - the editor return path restores those instead. */ +const SEEDABLE_VIEWS = ["viewer", "fileEditor", "pageEditor"]; + +// Raised while a restore is applying its recorded view, so writers that pick a default view from +// whatever is loaded at the time defer to the restore rather than race it. +let applyingRestoredView = false; +let restoreGeneration = 0; + +/** Returns a token for endRestoredView, so a stale release cannot end a newer restore. */ +export function beginRestoredView(): number { + applyingRestoredView = true; + return ++restoreGeneration; +} + +export function endRestoredView(token: number): void { + if (token === restoreGeneration) applyingRestoredView = false; +} + +export function isApplyingRestoredView(): boolean { + return applyingRestoredView; +} + +export function isSeedableView( + view: string | undefined, +): view is "viewer" | "fileEditor" | "pageEditor" { + return view !== undefined && SEEDABLE_VIEWS.includes(view); +} + +export function saveEditorReturnPath(path: string): void { + try { + sessionStorage.setItem(RETURN_PATH_KEY, path); + } catch { + // Best-effort: the switch back just lands on the editor root. + } +} + +/** One-shot: consumed by the switch back so a stale path cannot linger. */ +export function takeEditorReturnPath(): string | null { + try { + const path = sessionStorage.getItem(RETURN_PATH_KEY); + if (path !== null) sessionStorage.removeItem(RETURN_PATH_KEY); + return path; + } catch { + return null; + } +} diff --git a/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts b/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts index 6107fb5276..c5b24dd6ac 100644 --- a/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/engine-capabilities.spec.ts @@ -107,6 +107,12 @@ test.describe("engine capabilities", { tag: "@engine-capability" }, () => { await uploadFiles(page, SAMPLE_PDF); + // Dropped before the reload boots, so it cannot reopen the file for us: the eye + // below toggles, and whether the restore runs is a build flag this spec does not own. + await page.addInitScript(() => + sessionStorage.removeItem("stirling.workbench.session"), + ); + // Full reload: FileContext rehydrates from IndexedDB, not from memory. await page.reload({ waitUntil: "domcontentloaded" }); diff --git a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts new file mode 100644 index 0000000000..48765392fe --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts @@ -0,0 +1,207 @@ +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles } from "@app/tests/helpers/ui-helpers"; +import path from "path"; + +const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures"); +const SAMPLES = [ + "compare_sample_a.pdf", + "compare_sample_b.pdf", + "sample.pdf", + "rotated-pages.pdf", + "annotations_out_of_order.pdf", +].map((name) => path.join(FIXTURES_DIR, name)); + +// Read from the running app, not imported: a spec resolves @app/* to a different layer than +// the browser build does, so an imported WORKBENCH_SESSION_RESTORE can disagree with reality. +async function restoreEnabled( + page: import("@playwright/test").Page, +): Promise { + await page.waitForFunction( + () => document.documentElement.dataset.workbenchRestore !== undefined, + null, + { timeout: 20000 }, + ); + return page.evaluate( + () => document.documentElement.dataset.workbenchRestore === "true", + ); +} + +const NO_RESTORE = "this build ships the workbench restore off"; + +// Switching editor -> processor unmounts every editor provider; the session record +// in sessionStorage is what brings the workbench back on return. +test.describe("Workbench survives the editor/processor switch", () => { + test.use({ + stubOptions: { + enableLogin: true, + user: { + id: 44, + username: "owner", + email: "owner@example.com", + role: "ROLE_USER", + portalAccess: true, + }, + }, + seedJwt: true, + }); + + test("open files and the library return after a round-trip", async ({ + page, + }) => { + test.skip(!(await restoreEnabled(page)), NO_RESTORE); + + // Portal endpoints the processor shell fetches on mount. + for (const [pattern, json] of [ + ["**/api/v1/policies", []], + ["**/api/v1/policies/runs", []], + ["**/api/v1/policies/overview", { pipelines: [] }], + ["**/api/v1/sources", { sources: [] }], + ["**/api/v1/team/my", []], + ] as const) { + await page.route(pattern, (route) => route.fulfill({ json })); + } + + await uploadFiles(page, SAMPLES); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount( + SAMPLES.length, + { timeout: 15000 }, + ); + + // Uploading lands on the file grid, not the viewer - so the return has a + // view it can get wrong (NavigationContext boots to "viewer"). + await expect( + page.getByRole("radio", { name: /Active Files/i }), + ).toBeChecked(); + + // Out through the sidebar footer switch - the real user path. + await page.getByRole("button", { name: "Open PDF Processor" }).click(); + await expect(page).toHaveURL(/\/processor/, { timeout: 15000 }); + + // Split the two halves of the feature: if this fails the writer is at fault, + // if it passes but the view below is wrong the seeding is. + expect( + await page.evaluate(() => ({ + session: JSON.parse( + sessionStorage.getItem("stirling.workbench.session") ?? "{}", + ), + returnPath: sessionStorage.getItem( + "stirling.workbench.editorReturnPath", + ), + })), + ).toMatchObject({ + session: { workbench: "fileEditor" }, + returnPath: "/editor", + }); + + // Load the editor cold. Every provider mounts from nothing here, which is + // the loss the restore has to cover on the way back. + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + + await expect(page.locator(".file-sidebar-file-item")).toHaveCount( + SAMPLES.length, + { timeout: 20000 }, + ); + await expect(page.getByText(/compare_sample_a/i).first()).toBeVisible(); + await expect( + page.getByRole("radio", { name: /Active Files/i }), + ).toBeChecked({ timeout: 15000 }); + await expect(page.locator(".file-sidebar-loading")).toHaveCount(0, { + timeout: 15000, + }); + }); +}); + +test.describe("The view survives a reload", () => { + test.use({ + stubOptions: { + enableLogin: true, + user: { + id: 44, + username: "owner", + email: "o@e.com", + role: "ROLE_USER", + portalAccess: true, + }, + }, + seedJwt: true, + }); + + const currentView = (page: import("@playwright/test").Page) => + page.evaluate(() => { + const r = Array.from( + document.querySelectorAll("input[type=radio]"), + ).find((x) => x.checked); + return r?.value ?? "none"; + }); + + test("comes back on the same view the user left", async ({ page }) => { + test.skip(!(await restoreEnabled(page)), NO_RESTORE); + + await uploadFiles(page, SAMPLES.slice(0, 3)); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, { + timeout: 15000, + }); + + // Open a document, so the view under test is the viewer rather than the grid. + await page + .getByRole("button", { name: /Open in Viewer/i }) + .first() + .click({ force: true }); + await expect + .poll(() => currentView(page), { timeout: 10000 }) + .toBe("viewer"); + + // Whatever the workbench settled on is what a reload must reproduce. + const before = await currentView(page); + await page.waitForTimeout(600); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, { + timeout: 20000, + }); + await page.waitForTimeout(3000); + expect(await currentView(page)).toBe(before); + }); + + // The conjunction neither neighbour covers: the spec above proves the VIEW comes back, + // engine-capabilities proves stored bytes decode, and nothing proved that the file the + // restore reopened is one whose pixels actually arrive. + test("a file the restore reopened renders its pages", async ({ page }) => { + test.setTimeout(120_000); + test.skip(!(await restoreEnabled(page)), NO_RESTORE); + + await uploadFiles(page, SAMPLES.slice(0, 3)); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, { + timeout: 15_000, + }); + + await page + .getByRole("button", { name: /Open in Viewer/i }) + .first() + .click({ force: true }); + await expect + .poll(() => currentView(page), { timeout: 10_000 }) + .toBe("viewer"); + // Let the record settle: the writer debounces, so a reload can outrun it. + await page.waitForTimeout(600); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator(".file-sidebar-file-item")).toHaveCount(3, { + timeout: 30_000, + }); + + // A tile that decoded has non-zero naturalWidth. The restore resolves each recorded id + // to its current leaf, so an empty tile here means it reopened something unreadable. + const tile = page + .locator('[data-page-index="0"]') + .first() + .locator('img[src^="blob:"]') + .first(); + await expect(tile).toBeAttached({ timeout: 30_000 }); + await expect + .poll(() => tile.evaluate((img: HTMLImageElement) => img.naturalWidth), { + timeout: 30_000, + }) + .toBeGreaterThan(0); + }); +}); diff --git a/frontend/editor/src/desktop/components/session/WorkbenchSessionPersistence.tsx b/frontend/editor/src/desktop/components/session/WorkbenchSessionPersistence.tsx new file mode 100644 index 0000000000..2957ac3735 --- /dev/null +++ b/frontend/editor/src/desktop/components/session/WorkbenchSessionPersistence.tsx @@ -0,0 +1,4 @@ +// Stub: desktop opens OS-launched files on boot; a session restore would collide with that. +export function WorkbenchSessionPersistence() { + return null; +} diff --git a/frontend/editor/src/desktop/extensions/accountLogout.ts b/frontend/editor/src/desktop/extensions/accountLogout.ts index b97b06ec6e..ca75c9c5e9 100644 --- a/frontend/editor/src/desktop/extensions/accountLogout.ts +++ b/frontend/editor/src/desktop/extensions/accountLogout.ts @@ -1,4 +1,5 @@ import { connectionModeService } from "@app/services/connectionModeService"; +import { suspendWorkbenchSession } from "@app/services/workbenchSession"; type SignOutFn = () => Promise; @@ -16,6 +17,10 @@ export function useAccountLogout() { redirectToLogin, }: AccountLogoutDeps): Promise => { try { + // The tab outlives the session; the next person to sign in here must not + // inherit this workbench. Suspends writing too - signing out unmounts the + // editor, and its flush would otherwise write the record straight back. + suspendWorkbenchSession(); await signOut(); const currentConfig = await connectionModeService.getCurrentConfig(); diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 8ce7008d67..685fa32722 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -14,6 +14,7 @@ import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { takeEditorReturnPath } from "@app/services/workbenchSession"; import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, @@ -57,7 +58,7 @@ export function Sidebar() { // the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup) // needs a full page load. const goToEditor = () => { - if (EDITOR_IS_SAME_APP) navigate(EDITOR_BASENAME); + if (EDITOR_IS_SAME_APP) navigate(takeEditorReturnPath() ?? EDITOR_BASENAME); else window.location.href = EDITOR_URL; }; diff --git a/frontend/editor/src/proprietary/auth/spring/UseSession.tsx b/frontend/editor/src/proprietary/auth/spring/UseSession.tsx index 289f2ec895..27a722b2d7 100644 --- a/frontend/editor/src/proprietary/auth/spring/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/spring/UseSession.tsx @@ -12,6 +12,7 @@ import { type AuthUser, type AuthTranslate, } from "@app/auth/types"; +import { suspendWorkbenchSession } from "@app/services/workbenchSession"; /** * Strip the configured base path so route comparisons work under subpath @@ -97,6 +98,11 @@ export function SpringAuthProvider({ const signOut = useCallback(async () => { try { setError(null); + // Signing out is deliberate, unlike an identity check that merely failed: drop the + // workbench record here and stop recording, so the teardown that follows cannot + // write it back for whoever signs in next. + suspendWorkbenchSession(); + const { error } = await springAuth.signOut(); // Always clear the in-memory session: springAuth.signOut() removes the diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.reentry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.reentry.test.tsx new file mode 100644 index 0000000000..dc8ff6397d --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.reentry.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; + +// A file can re-enter the workbench without being a new upload (My Files reopen, session restore). +// The persisted dispatch record must stop the upload policy (and its billing) firing a second time. + +const mocks = vi.hoisted(() => ({ + workspace: [] as Array<{ id: string; derivedFromTool?: boolean }>, + runStoredPolicy: vi.fn(), + getPolicyRun: vi.fn(), + listPolicyRuns: vi.fn(), + getStirlingFile: vi.fn(), +})); + +vi.mock("@app/hooks/useAiEngineEnabled", () => ({ + useAiEngineEnabled: () => true, +})); +vi.mock("@app/contexts/FileContext", () => ({ + useAllFiles: () => ({ fileStubs: mocks.workspace }), + useFileManagement: () => ({ + addFiles: vi.fn(), + updateStirlingFileStub: vi.fn(), + }), + useFileContext: () => ({ consumeFiles: vi.fn() }), +})); +vi.mock("@app/contexts/IndexedDBContext", () => ({ + useIndexedDB: () => ({ bumpRevision: vi.fn() }), +})); +vi.mock("@app/hooks/usePolicies", () => ({ + usePolicies: () => ({ + policies: { + security: { + configured: true, + status: "active", + backendId: "backend-security", + runOn: "upload", + order: 0, + outputMode: "new_version", + outputName: "", + }, + }, + }), +})); +vi.mock("@app/services/policyApi", () => ({ + runStoredPolicy: mocks.runStoredPolicy, + getPolicyRun: mocks.getPolicyRun, + listPolicyRuns: mocks.listPolicyRuns, + downloadPolicyOutput: vi.fn(), + resolvePolicyRunTarget: () => "saas", +})); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getStirlingFile: mocks.getStirlingFile, + getStirlingFileStub: vi.fn().mockResolvedValue(null), + persistVersionedOutputs: vi.fn(), + updateFileMetadata: vi.fn().mockResolvedValue(true), + }, +})); + +import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; +import { + markDispatched, + resetPolicyRuns, +} from "@app/components/policies/policyRunStore"; + +beforeEach(() => { + localStorage.clear(); + resetPolicyRuns(); + vi.clearAllMocks(); + mocks.listPolicyRuns.mockResolvedValue([]); + mocks.getStirlingFile.mockResolvedValue( + new File(["x"], "doc.pdf", { type: "application/pdf" }), + ); + mocks.runStoredPolicy.mockResolvedValue("run-0"); + // Completed with no outputs: the run settles without the import machinery. + mocks.getPolicyRun.mockResolvedValue({ + runId: "run-0", + policyId: null, + status: "COMPLETED", + currentStep: 1, + stepCount: 1, + error: null, + outputs: [], + }); +}); + +describe("upload policies and files re-entering the workbench", () => { + it("does not re-run on a file the policy already ran on", async () => { + markDispatched("security", "already-enforced"); + mocks.workspace = [{ id: "already-enforced" }, { id: "fresh-upload" }]; + + renderHook(() => usePolicyAutoRun()); + + await waitFor(() => expect(mocks.runStoredPolicy).toHaveBeenCalledTimes(1)); + expect(mocks.getStirlingFile).toHaveBeenCalledWith("fresh-upload"); + expect(mocks.getStirlingFile).not.toHaveBeenCalledWith("already-enforced"); + }); + + it("stays silent when every file in the workbench has already been enforced", async () => { + markDispatched("security", "one"); + markDispatched("security", "two"); + mocks.workspace = [{ id: "one" }, { id: "two" }]; + + renderHook(() => usePolicyAutoRun()); + + // Give the dispatch effect a tick to (wrongly) fire before asserting silence. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mocks.runStoredPolicy).not.toHaveBeenCalled(); + }); + + it("still skips a policy's own output, which is not an upload at all", async () => { + mocks.workspace = [{ id: "policy-output", derivedFromTool: true }]; + + renderHook(() => usePolicyAutoRun()); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mocks.runStoredPolicy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/proprietary/constants/featureFlags.ts b/frontend/editor/src/proprietary/constants/featureFlags.ts index e432b34599..84af388005 100644 --- a/frontend/editor/src/proprietary/constants/featureFlags.ts +++ b/frontend/editor/src/proprietary/constants/featureFlags.ts @@ -13,3 +13,6 @@ * Watched Folders implementation to navigate to). */ export const WATCHED_FOLDERS_ENABLED: boolean = false; + +// Refill an empty workbench from the tab's last session (survives the editor/processor switch). +export const WORKBENCH_SESSION_RESTORE: boolean = true; diff --git a/frontend/editor/src/proprietary/extensions/accountLogout.ts b/frontend/editor/src/proprietary/extensions/accountLogout.ts index 6ae83b8e26..8c4d9f15d7 100644 --- a/frontend/editor/src/proprietary/extensions/accountLogout.ts +++ b/frontend/editor/src/proprietary/extensions/accountLogout.ts @@ -1,3 +1,5 @@ +import { suspendWorkbenchSession } from "@app/services/workbenchSession"; + type SignOutFn = () => Promise; interface AccountLogoutDeps { @@ -21,6 +23,10 @@ export function useAccountLogout() { "1", ); } + // The tab outlives the session; the next person to sign in here must not + // inherit this workbench. Suspends writing too - signing out unmounts the + // editor, and its flush would otherwise write the record straight back. + suspendWorkbenchSession(); await signOut(); } finally { redirectToLogin(); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx new file mode 100644 index 0000000000..c2a81c53d9 --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + requestNavigation: vi.fn(), + portalAccess: true, +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => mocks.navigate, + useLocation: () => ({ pathname: "/compress", search: "?mode=fast" }), +})); +vi.mock("@app/auth/context", () => ({ + useAuth: () => ({ portalAccess: mocks.portalAccess }), +})); +vi.mock("@app/contexts/NavigationContext", () => ({ + useNavigationActions: () => ({ + actions: { requestNavigation: mocks.requestNavigation }, + }), +})); + +import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; +import { takeEditorReturnPath } from "@app/services/workbenchSession"; + +beforeEach(() => { + sessionStorage.clear(); + vi.clearAllMocks(); + mocks.portalAccess = true; +}); + +describe("useOtherAppSwitch", () => { + it("offers no switch without portal access", () => { + mocks.portalAccess = false; + const { result } = renderHook(() => useOtherAppSwitch()); + expect(result.current).toBeNull(); + }); + + it("routes the switch through the unsaved-changes guard", () => { + const { result } = renderHook(() => useOtherAppSwitch()); + result.current?.onOpen(); + + expect(mocks.navigate).not.toHaveBeenCalled(); + expect(mocks.requestNavigation).toHaveBeenCalledTimes(1); + }); + + it("records where to return to, then navigates to the processor", () => { + const { result } = renderHook(() => useOtherAppSwitch()); + result.current?.onOpen(); + mocks.requestNavigation.mock.calls[0][0](); + + expect(takeEditorReturnPath()).toBe("/compress?mode=fast"); + expect(mocks.navigate).toHaveBeenCalledWith("/processor"); + }); +}); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts index 8bf07b5c2f..dff36a06f4 100644 --- a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts @@ -1,6 +1,8 @@ -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "@app/auth/context"; +import { useNavigationActions } from "@app/contexts/NavigationContext"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { saveEditorReturnPath } from "@app/services/workbenchSession"; import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter"; /** @@ -10,6 +12,16 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote export function useOtherAppSwitch(): NavFooterAppLink | null { const { portalAccess } = useAuth(); const navigate = useNavigate(); + const location = useLocation(); + const { actions } = useNavigationActions(); if (!portalAccess) return null; - return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) }; + return { + app: "processor", + onOpen: () => + // Through the guard, so unsaved edits get the same warning as any other navigation. + actions.requestNavigation(() => { + saveEditorReturnPath(location.pathname + location.search); + navigate(PORTAL_BASENAME); + }), + }; } diff --git a/frontend/editor/src/saas/auth/AuthProvider.test.tsx b/frontend/editor/src/saas/auth/AuthProvider.test.tsx index ffeca5433d..bfd3bef352 100644 --- a/frontend/editor/src/saas/auth/AuthProvider.test.tsx +++ b/frontend/editor/src/saas/auth/AuthProvider.test.tsx @@ -1,6 +1,7 @@ import { act, render, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Session, User } from "@supabase/supabase-js"; +import { expectConsole } from "@app/tests/failOnConsole"; /** * Request-count tests for {@link AuthProvider}'s data loading. It used to fetch @@ -16,6 +17,7 @@ const createSignedUrl = vi.fn(); const storageFrom = vi.fn((_bucket: string) => ({ createSignedUrl })); const getSession = vi.fn(); const onAuthStateChange = vi.fn(); +const supabaseSignOut = vi.fn(); const unsubscribe = vi.fn(); vi.mock("@app/auth/supabase", () => ({ @@ -26,7 +28,7 @@ vi.mock("@app/auth/supabase", () => ({ refreshSession: vi .fn() .mockResolvedValue({ data: { session: null }, error: null }), - signOut: vi.fn().mockResolvedValue({ error: null }), + signOut: () => supabaseSignOut(), }, rpc: (...args: unknown[]) => rpc(...args), storage: { from: (bucket: string) => storageFrom(bucket) }, @@ -53,6 +55,8 @@ vi.mock("@app/services/userService", () => ({ // Imported after the mocks so the provider picks them up. const { AuthProvider, useAuth } = await import("@app/auth/UseSession"); +const { writeWorkbenchSession, readWorkbenchSession, resumeWorkbenchSession } = + await import("@app/services/workbenchSession"); /** Surfaces `loading` so a test can assert on it rather than on the container. */ function LoadingProbe() { @@ -337,3 +341,55 @@ describe("AuthProvider user-data loading", () => { expect(rpc).toHaveBeenCalledTimes(1); }); }); + +/** + * Signing out suspends workbench recording before the request, so a teardown cannot write the + * record back for whoever signs in next. When the request fails the session stands, and a + * still-signed-in user must not be left silently not recording. + */ +describe("a sign-out that fails", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + resumeWorkbenchSession(); + getSession.mockResolvedValue({ + data: { session: makeSession() }, + error: null, + }); + onAuthStateChange.mockImplementation(() => ({ + data: { subscription: { unsubscribe } }, + })); + rpc.mockResolvedValue({ data: null, error: null }); + getProfilePictureMetadata.mockResolvedValue(null); + syncOAuthAvatar.mockResolvedValue(undefined); + synchronizeUserUpgrade.mockResolvedValue(undefined); + }); + + it("leaves the workbench still being recorded", async () => { + expectConsole.error(/\[Auth Debug\] Sign out error/); + supabaseSignOut.mockResolvedValue({ error: new Error("network down") }); + + let signOut: (() => Promise) | null = null; + function SignOutProbe() { + signOut = useAuth().signOut; + return null; + } + render( + + + , + ); + await waitFor(() => expect(signOut).not.toBeNull()); + + await act(async () => { + await signOut!(); + }); + + writeWorkbenchSession({ + fileIds: ["still-here"], + selectedFileIds: [], + userId: "user-a", + }); + expect(readWorkbenchSession()?.fileIds).toEqual(["still-here"]); + }); +}); diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index a1c4db2396..4bd3b99346 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -22,6 +22,10 @@ import { getProviderAvatarUrl, type ProfilePictureMetadata, } from "@app/services/avatarSyncService"; +import { + resumeWorkbenchSession, + suspendWorkbenchSession, +} from "@app/services/workbenchSession"; // Extend Supabase User to include optional username for compatibility export type User = SupabaseUser & { username?: string }; @@ -355,11 +359,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signOut = async () => { try { setError(null); + // Signing out is deliberate, unlike an identity check that merely failed: drop the + // workbench record here and stop recording, so the teardown that follows cannot + // write it back for whoever signs in next. + suspendWorkbenchSession(); + const { error } = await supabase.auth.signOut(); if (error) { console.error("[Auth Debug] Sign out error:", error); setError(error); + // The sign-out did not happen and the session stands, so keep recording: + // otherwise a still-signed-in user silently stops persisting their workbench. + resumeWorkbenchSession(); } else { console.debug("[Auth Debug] Signed out successfully"); setSession(null); @@ -367,6 +379,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } catch (err) { console.error("[Auth Debug] Unexpected error during sign out:", err); setError(err as AuthError); + resumeWorkbenchSession(); } }; diff --git a/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts b/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts index 824e612bd8..67d0aa7d49 100644 --- a/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts +++ b/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts @@ -1,6 +1,8 @@ -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { usePortalAccess } from "@app/hooks/usePortalAccess"; +import { useNavigationActions } from "@app/contexts/NavigationContext"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { saveEditorReturnPath } from "@app/services/workbenchSession"; import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter"; /** @@ -11,6 +13,16 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote export function useOtherAppSwitch(): NavFooterAppLink | null { const portalAccess = usePortalAccess(); const navigate = useNavigate(); + const location = useLocation(); + const { actions } = useNavigationActions(); if (!portalAccess) return null; - return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) }; + return { + app: "processor", + onOpen: () => + // Through the guard, so unsaved edits get the same warning as any other navigation. + actions.requestNavigation(() => { + saveEditorReturnPath(location.pathname + location.search); + navigate(PORTAL_BASENAME); + }), + }; } From 732ef18ae56758e78782446315e7d1b6f31bca4f Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:32:32 +0000 Subject: [PATCH 05/57] feat(account-link): redirect-based connect handshake for self-hosted linking (#7494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Links a self-hosted instance to a SaaS team over an ordinary redirect, and leaves the admin's browser holding a Stirling session at the same time. ## The problem A self-hosted server needs a device credential bound to a SaaS team, and the admin's Supabase JWT must never reach the instance backend. Three things ruled out the obvious approaches: - **A customer hostname can never be in Supabase's redirect allow-list**, so the sign-in cannot happen on the instance's own origin. That is why SSO and sign-up did not work for linking at all. - **A device credential identifies a server, not a person.** Every attended portal read (Usage, Billing, Documents, Infrastructure) goes through `getPortalSaasToken()` and needs a *user* session, so a credential-only link left all of them asking for a second sign-in. - **The previous design relayed a JWT** from the browser into the instance, which is the thing we wanted to avoid. That path is deleted here. ## The solution Redirect and nonce, modelled on desktop's `authService.loginWithSelfHostedOAuth`: mint a nonce, hand the browser off, accept only a callback carrying that nonce back. Desktop has the OS route the reply; self-hosted has no OS hop, so our own approval page performs it. That is the point — the human half happens on an origin we control. ``` instance SaaS admin's browser | POST connect/request | | | (name, callback, nonce, | | | claim-secret hash) | | |-------------------------->| | | <- requestId + authorizeUrl | | | GET /link?request=... | | |<-------------------------------| | | sign in (SSO works here), | | | see ACCOUNT + ORIGIN, approve | | |------------------------------->| | | 302 callback#nonce+session | | POST connect/claim | | | (requestId, claim secret)| | |-------------------------->| | | <- device credential | | ``` Four properties carry the safety, and each is stated in the code because each is easy to lose in a refactor: - **The redirect target is never caller-supplied.** Validated once at creation, then read back from the stored row, so nothing in the approval page's URL can steer the token elsewhere. - **Approval and minting are separate.** Approval records the team and hands out nothing usable; the credential is minted only on claim, authenticated by a secret that never entered a browser. - **A re-authentication cannot move a server between teams.** The team is pinned at creation from the credential only that instance holds, so an approver from another team gets `WRONG_TEAM` instead of a rebind. - **The approver has to confirm what they are binding.** The page shows the address and the signed-in account, with a way to switch, and a checkbox naming the address gates the approve button. The name the server reports is deliberately not shown: the requester picks it on an unauthenticated endpoint, and its honest value is the hostname already in the address. The session rides the URL fragment, so it stays out of access logs and `Referer`, and is stripped before anything awaits. The claim is row-locked, so one approval mints once. A request lives 30 minutes; a settled one is not offered again, since approving it fails server-side. Signing in mid-flow no longer loses the request. The id is kept on the SaaS origin and resumed after any sign-in, which is what makes creating an account work: the confirmation email opens a new tab, where the `next` parameter is gone. Reading it does not consume it — the request may be open in two tabs — and only a recorded decision retires it. The result lands as a modal over the portal the admin started from, and the portal re-reads its link status so the page behind agrees with the modal. Plaintext `http://` callbacks are accepted rather than refused, because many self-hosted instances legitimately run plain HTTP on a private network; the address carries a warning icon explaining the risk, derived server-side so a requester cannot suppress it. Hard-refusing `http://` to a public IP literal is a reasonable follow-up; a bare hostname can't be classified without a DNS lookup, so the warning stays the general mechanism. ## Configuration Four surfaces. Placeholders below, not values. **SaaS backend** | Setting | Needed | Why | |---|---|---| | `stirling.billing.account-link.enabled` | Yes, `true` | The connect controller and service are `@ConditionalOnProperty` with no default, so without it the endpoints do not exist. | | `system.frontendUrl` | Only when the approval page is not on the API's own origin | Where the approver is sent. Must include the app's base path if it is served under one, or the redirect misses `/link`. | **SaaS frontend** | Setting | Needed | Why | |---|---|---| | `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes | Its own sign-in. Must be the project the SaaS backend validates tokens against. | | `RUN_SUBPATH` | Only if served under a subpath | Moves the approval page to `//link`, so `system.frontendUrl` has to agree. | **Self-hosted backend** | Setting | Needed | Why | |---|---|---| | `stirling.billing.account-link.enabled` | Yes, `true` | Defaults to `false`. | | `stirling.billing.account-link.saas-base-url` | Yes | Origin of the SaaS API it links to. Not the SaaS frontend. | | `system.frontendUrl` | Optional | Externally reachable base URL for the callback. Otherwise derived from the request's `Origin`, which is right for ordinary deployments and wrong behind a rewriting proxy. | **Self-hosted frontend** | Setting | Needed | Why | |---|---|---| | `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes | Accepts the session handed over in the callback fragment. | | `VITE_SAAS_API_URL` | For Usage and Billing | Attended reads go to the SaaS API with the admin's token. Absent, those surfaces stay on the mock. | | `VITE_INCLUDE_PORTAL` | Production builds | Dev builds include the portal automatically; without it there is no link UI and no callback route. | Two things worth stating because neither fails loudly: - **Both frontends must use the URL *and* key of the same Supabase project**, and the same one the SaaS backend validates against. A key from one project with a URL from another is accepted by the browser and rejected by Supabase, which surfaces much later as "session expired" on Usage rather than as an error at hand-over. - **The Supabase redirect allow-list must contain the SaaS app's `/auth/callback`**, since a confirmation email returns through it. Entries are matched exactly. - **`system.frontendUrl` is the existing setting for this**, not a new one, so each side reads its own value and there is nothing extra to configure. It also gates share links, so on a stack with storage and sharing already on, setting it here turns those on too. The self-hosted side deliberately does **not** configure where the approval page lives — SaaS answers that in the connect-request reply, being the only party that knows. Also here, because testing this needs two stacks side by side: `linked:staging` / `linked:dev` (which derive `system.frontendUrl` and `RUN_SUBPATH` themselves), the missing `frontend:staging:saas`, and a per-mode vite `cacheDir` — two dev servers in different modes otherwise re-optimise over one shared dep cache. ## How to test Automated and green: `task frontend:check:all` plus both backend modules. `ConnectRequestServiceTest` covers callback validation, the per-IP cap, single-use approval, claim outcomes, expiry, `WRONG_TEAM` and reauth confirming without minting; `ConnectServiceTest` covers callback-resolution precedence including a foreign-origin callback being discarded; `ConnectControllerTest` covers the authorize URL, including the forwarded-header path and only the first hop being trusted; `ConnectCallback.test.tsx` covers the fragment being stripped synchronously and malformed fragments refused; `LinkAccountModal.test.tsx` covers link and reauth hitting different endpoints. Manual walkthrough: 1. `task linked:staging` — added here; brings up a SaaS stack and a self-hosted instance pointed at it, on discovered ports, and prints the four addresses. 2. Open the link-account modal in the self-hosted portal and continue. Expect the SaaS approval page at `/link?request=`. 3. Sign in as a team leader, or create an account and confirm the email. Either way you should come back to the approval page. 4. Tick the acknowledgement and approve. Expect the fragment gone from the address bar immediately, a result modal over the portal, the portal showing linked without a reload, and attended reads (Usage, Billing) working without a second sign-in. 5. Repeat, approving as a member of a different team. Expect a refusal, not a rebind. ## Outstanding - #7415 to be reworked against this design once this lands. - **No SaaS-side UI to disconnect a server.** `GET /account-link/instances` and `POST /account-link/instances/{id}/revoke` are already team-scoped and leader-gated, and the portal has a panel that uses them, but `portal-saas/components/settings/accountLinkSettings.tsx` exports `null` on the reasoning that "SaaS has no account-link concept". That held when linking was a self-hosted admin managing their own instance; here a leader approves a server they may not administer, and has no way to withdraw it. The seam to fill is that one file. Expected to land with the CTA work in #7415. --------- Co-authored-by: James Brunton --- .taskfiles/backend.yml | 65 ++- .taskfiles/frontend.yml | 16 +- Taskfile.yml | 86 ++++ .../src/main/resources/settings.yml.template | 2 +- .../accountlink/AccountLinkClient.java | 206 ++++++--- .../accountlink/AccountLinkController.java | 132 ++++-- .../accountlink/AccountLinkProperties.java | 35 +- .../accountlink/AccountLinkService.java | 26 +- .../accountlink/AccountLinkSyncState.java | 2 +- .../AccountLinkSyncStateRepository.java | 2 +- .../accountlink/ConnectService.java | 276 ++++++++++++ .../proprietary/accountlink/ConnectState.java | 60 +++ .../accountlink/ConnectStateRepository.java | 6 + .../accountlink/DeviceCredential.java | 4 +- .../accountlink/InstanceEntitlementGate.java | 2 +- .../InstanceEntitlementInterceptor.java | 4 +- .../accountlink/MeteredInputSignature.java | 10 +- .../MeteredInputSignatureRepository.java | 2 +- .../proprietary/accountlink/UsageCounter.java | 8 +- .../accountlink/UsageCounterRepository.java | 2 +- .../accountlink/UsageSyncService.java | 4 +- .../proprietary/billing/ContentHasher.java | 6 +- .../accountlink/AccountLinkClientTest.java | 94 ++-- .../AccountLinkControllerTest.java | 73 ++-- .../accountlink/AccountLinkServiceTest.java | 22 +- .../accountlink/ConnectServiceTest.java | 408 ++++++++++++++++++ .../accountlink/AccountLinkController.java | 92 +--- .../saas/accountlink/AccountLinkService.java | 35 +- .../saas/accountlink/ConnectController.java | 277 ++++++++++++ .../saas/accountlink/ConnectRequest.java | 103 +++++ .../ConnectRequestCleanupService.java | 47 ++ .../accountlink/ConnectRequestRepository.java | 28 ++ .../accountlink/ConnectRequestService.java | 391 +++++++++++++++++ .../DeviceCredentialAuthenticationFilter.java | 2 +- .../saas/accountlink/InstanceController.java | 6 +- .../saas/accountlink/LeaderTeamResolver.java | 68 +++ .../saas/accountlink/LinkedInstance.java | 2 +- .../LinkedInstanceAuthenticationToken.java | 2 +- .../software/saas/accountlink/Origins.java | 22 + .../saas/config/SaasSchemaOwnership.java | 2 + .../instance/InstanceUsageIngestService.java | 12 +- .../saas/payg/instance/PaygInstanceUsage.java | 6 +- .../saas/security/SupabaseSecurityConfig.java | 55 ++- .../saas/service/SaasTeamService.java | 4 +- .../AccountLinkControllerTest.java | 46 +- .../accountlink/ConnectControllerTest.java | 129 ++++++ .../ConnectRequestServiceTest.java | 358 +++++++++++++++ .../SupabaseSecurityConfigMoreTest.java | 54 ++- .../public/locales/en-US/translation.toml | 82 +++- .../editor/src/portal/PortalProviders.tsx | 39 +- frontend/editor/src/portal/api/link.test.ts | 26 +- frontend/editor/src/portal/api/link.ts | 128 +++--- .../editor/src/portal/auth/saasSupabase.ts | 11 +- .../account-link/ConnectCallbackHost.tsx | 184 ++++++++ .../account-link/ConnectCallbackView.tsx | 162 +++++++ .../account-link/LinkAccountCard.stories.tsx | 3 +- .../account-link/LinkAccountModal.css | 26 ++ .../account-link/LinkAccountModal.stories.tsx | 9 +- .../account-link/LinkAccountModal.test.tsx | 128 ++++++ .../account-link/LinkAccountModal.tsx | 162 +++---- .../portal/contexts/AccountLinkContext.tsx | 10 +- .../src/portal/contexts/LinkContext.tsx | 18 +- .../src/portal/hooks/useAccountLink.test.tsx | 75 ---- .../editor/src/portal/hooks/useAccountLink.ts | 112 +---- .../editor/src/portal/mocks/handlers/link.ts | 51 ++- frontend/editor/src/portal/mocks/link.ts | 2 +- .../src/portal/views/ConnectCallback.css | 32 ++ .../src/portal/views/ConnectCallback.test.tsx | 175 ++++++++ .../src/portal/views/ConnectCallback.tsx | 41 ++ .../routes/adminRouteExtensions.tsx | 27 +- frontend/editor/src/saas/App.tsx | 7 + .../editor/src/saas/routes/AuthCallback.tsx | 17 +- .../editor/src/saas/routes/ConnectApprove.tsx | 172 ++++++++ .../src/saas/routes/ConnectApproveView.tsx | 182 ++++++++ .../src/saas/routes/ResumePendingConnect.tsx | 38 ++ frontend/editor/src/saas/routes/connect.css | 93 ++++ .../editor/src/saas/routes/pendingConnect.ts | 61 +++ frontend/editor/vite.config.ts | 10 + 78 files changed, 4524 insertions(+), 853 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/ConnectController.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequest.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestCleanupService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/LeaderTeamResolver.java create mode 100644 app/saas/src/main/java/stirling/software/saas/accountlink/Origins.java create mode 100644 app/saas/src/test/java/stirling/software/saas/accountlink/ConnectControllerTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/accountlink/ConnectRequestServiceTest.java create mode 100644 frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx create mode 100644 frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx create mode 100644 frontend/editor/src/portal/components/account-link/LinkAccountModal.css create mode 100644 frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx delete mode 100644 frontend/editor/src/portal/hooks/useAccountLink.test.tsx create mode 100644 frontend/editor/src/portal/views/ConnectCallback.css create mode 100644 frontend/editor/src/portal/views/ConnectCallback.test.tsx create mode 100644 frontend/editor/src/portal/views/ConnectCallback.tsx create mode 100644 frontend/editor/src/saas/routes/ConnectApprove.tsx create mode 100644 frontend/editor/src/saas/routes/ConnectApproveView.tsx create mode 100644 frontend/editor/src/saas/routes/ResumePendingConnect.tsx create mode 100644 frontend/editor/src/saas/routes/connect.css create mode 100644 frontend/editor/src/saas/routes/pendingConnect.ts diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 08a12b9535..3e43321937 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -40,12 +40,15 @@ tasks: AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}' + # Set by dev:linked. Inline rather than in `env:` so an empty value emits nothing + # and cannot blank the committed default. + ACCOUNT_LINK_SAAS_BASE_URL: '{{.ACCOUNT_LINK_SAAS_BASE_URL | default ""}}' env: SERVER_PORT: '{{.PORT}}' cmds: - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' platforms: [windows] - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}./gradlew :stirling-pdf:bootRun' platforms: [linux, darwin] dev:bundled: @@ -84,6 +87,8 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + APP_BASE_URL: '{{.APP_BASE_URL}}' + BASE_PATH: '{{.BASE_PATH}}' staging:saas: desc: "Start SaaS backend against the shared v3 staging project" @@ -95,10 +100,47 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + APP_BASE_URL: '{{.APP_BASE_URL}}' + BASE_PATH: '{{.BASE_PATH}}' + + dev:linked: + desc: "Self-hosted backend linked to a locally running SaaS backend (see task linked:*)" + ignore_error: true + vars: + PORT: '{{.PORT | default "8080"}}' + SAAS_BASE_URL: '{{.SAAS_BASE_URL | default "http://localhost:8081"}}' + cmds: + - 'echo ">> self-hosted :{{.PORT}} linking to SaaS at {{.SAAS_BASE_URL}}"' + # The two backends run different STIRLING_FLAVOURs, which are different Gradle + # project graphs sharing one build/ tree. Waiting avoids overlapping builds; it + # does not make the sharing safe, so avoid rebuilding one while the other runs. + - cmd: | + n=0 + while [ "$n" -lt 150 ]; do + if curl -s -m 2 "{{.SAAS_BASE_URL}}" >/dev/null 2>&1; then + echo ">> SaaS backend is up, starting self-hosted" + break + fi + n=$((n + 1)) + {{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}} + done + if [ "$n" -ge 150 ]; then + echo ">> SaaS backend never answered; starting anyway" + fi + - task: dev:proprietary + vars: + PORT: '{{.PORT}}' + ACCOUNT_LINK_SAAS_BASE_URL: '{{.SAAS_BASE_URL}}' _run:saas: internal: true - dotenv: ['app/.env.saas.local', 'app/.env.saas'] + # The frontend files are here only for RUN_SUBPATH, which the authorize URL needs. + # Last, because dotenv is set-if-absent: app/* still decides everything else. + dotenv: + - 'app/.env.saas.local' + - 'app/.env.saas' + - 'frontend/editor/.env.saas.local' + - 'frontend/editor/.env.saas' ignore_error: true vars: PORT: '{{.PORT | default "8080"}}' @@ -111,12 +153,29 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL | default ""}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' + # Empty is the same as unset: the property defaults to empty and is blank-checked. + APP_BASE_URL: '{{.APP_BASE_URL | default ""}}' + # Relocates configs/pipeline/logs, for a second backend in the same directory. + # Empty is the same as unset: the reader blank-checks it. + BASE_PATH: '{{.BASE_PATH | default ""}}' env: SERVER_PORT: '{{.PORT}}' STIRLING_FLAVOR: saas + STIRLING_BASE_PATH: '{{.BASE_PATH}}' AIENGINE_URL: '{{.AIENGINE_URL}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + # Appends RUN_SUBPATH: the approval page is at /link, so a subpath build + # serves it at /app/link. An explicit value still wins. + SYSTEM_FRONTENDURL: + sh: | + if [ -n "${SYSTEM_FRONTENDURL:-}" ]; then + echo "${SYSTEM_FRONTENDURL}" + elif [ -n "{{.APP_BASE_URL}}" ] && [ -n "${RUN_SUBPATH:-}" ]; then + echo "{{.APP_BASE_URL}}/${RUN_SUBPATH}" + else + echo "{{.APP_BASE_URL}}" + fi cmds: # PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile # against SAAS_DB_* (production). diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 844306824d..7867315037 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -121,17 +121,17 @@ tasks: sh: | case "${SAAS_ENV:-dev}" in staging) ref="${SAAS_STAGING_PROJECT_REF:?set it in app/.env.saas.local}" ;; - *) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or run task staging:saas}" ;; + *) ref="${SAAS_DEV_PROJECT_REF:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;; esac echo "https://${ref}.supabase.co" VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: sh: | case "${SAAS_ENV:-dev}" in staging) echo "${SAAS_STAGING_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;; - *) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local}" ;; + *) echo "${SAAS_DEV_PUBLISHABLE_KEY:?set it in app/.env.saas.local, or pass SAAS_ENV=staging}" ;; esac cmds: - - 'echo ">> frontend Supabase target: $VITE_SUPABASE_URL"' + - 'echo ">> frontend {{.SAAS_ENV}}: Supabase $VITE_SUPABASE_URL, backend $BACKEND_URL"' - npx vite editor --mode saas --port {{.PORT}}{{if .OPEN}} --open{{end}} dev: @@ -173,6 +173,16 @@ tasks: OPEN: '{{.OPEN}}' SAAS_ENV: '{{.SAAS_ENV}}' + staging:saas: + desc: "Start frontend dev server against the shared v3 staging project" + cmds: + - task: dev:saas + vars: + SAAS_ENV: staging + PORT: '{{.PORT}}' + BACKEND_URL: '{{.BACKEND_URL}}' + OPEN: '{{.OPEN}}' + dev:desktop: desc: "Start frontend dev server in desktop mode" deps: diff --git a/Taskfile.yml b/Taskfile.yml index 92dcdcc742..e364242f1f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -121,6 +121,92 @@ tasks: cmds: - task: dev:_all + # No engine: linking never calls it. + linked:staging: + desc: "SaaS on the shared v3 project + a self-hosted instance linked to it" + cmds: + - task: linked:_all + vars: { SAAS_ENV: staging } + + linked:dev: + desc: "SaaS on the current PR's preview branch + a self-hosted instance linked to it" + cmds: + - task: linked:_all + vars: { SAAS_ENV: dev } + + linked:_all: + internal: true + vars: + SAAS_ENV: '{{.SAAS_ENV | default "staging"}}' + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8081 5174 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8081 5174 8080 5173{{end}}' + SAAS_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + SAAS_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}' + APP_BACKEND_PORT: '{{index (splitList "\n" .PORTS) 2}}' + APP_FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 3}}' + deps: + # APP_BASE_URL is the SaaS *frontend*: the approval page is served by vite, not + # by the API. BASE_PATH moves this backend's configs/pipeline aside so it does not + # race the self-hosted one, which keeps ./configs and its existing database. + - task: 'backend:{{.SAAS_ENV}}:saas' + vars: + PORT: '{{.SAAS_BACKEND_PORT}}' + APP_BASE_URL: 'http://localhost:{{.SAAS_FRONTEND_PORT}}' + BASE_PATH: 'tmp/linked-saas' + - task: frontend:dev:saas + vars: + PORT: '{{.SAAS_FRONTEND_PORT}}' + BACKEND_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}' + SAAS_ENV: '{{.SAAS_ENV}}' + - task: backend:dev:linked + vars: + PORT: '{{.APP_BACKEND_PORT}}' + SAAS_BASE_URL: 'http://localhost:{{.SAAS_BACKEND_PORT}}' + - task: frontend:dev:proprietary + vars: + PORT: '{{.APP_FRONTEND_PORT}}' + BACKEND_URL: 'http://localhost:{{.APP_BACKEND_PORT}}' + OPEN: "true" + - task: linked:_ready + vars: + SAAS_BACKEND_PORT: '{{.SAAS_BACKEND_PORT}}' + SAAS_FRONTEND_PORT: '{{.SAAS_FRONTEND_PORT}}' + APP_BACKEND_PORT: '{{.APP_BACKEND_PORT}}' + APP_FRONTEND_PORT: '{{.APP_FRONTEND_PORT}}' + + # Waits for all four to answer, then prints where they landed. + linked:_ready: + internal: true + cmds: + - cmd: | + n=0 + ok=0 + while [ "$n" -lt 150 ]; do + ok=1 + for u in "http://localhost:{{.SAAS_BACKEND_PORT}}" \ + "http://localhost:{{.SAAS_FRONTEND_PORT}}" \ + "http://localhost:{{.APP_BACKEND_PORT}}" \ + "http://localhost:{{.APP_FRONTEND_PORT}}"; do + # Not -o /dev/null: Windows curl.exe treats it as a real path and exits 23. + curl -s -m 2 "$u" >/dev/null 2>&1 || ok=0 + done + if [ "$ok" = 1 ]; then break; fi + n=$((n + 1)) + # `sleep` is a binary, not a builtin, and Windows has none. + {{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}} + done + echo "" + if [ "$ok" = 1 ]; then + echo ">> all four answering" + else + echo ">> still waiting on one or more after 5 minutes; addresses below anyway" + fi + echo ">> self-hosted UI http://localhost:{{.APP_FRONTEND_PORT}}/processor" + echo ">> self-hosted api http://localhost:{{.APP_BACKEND_PORT}}" + echo ">> saas UI http://localhost:{{.SAAS_FRONTEND_PORT}}" + echo ">> saas api http://localhost:{{.SAAS_BACKEND_PORT}}" + echo "" + dev:_all: internal: true vars: diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index fdfe40b352..ecf7ea8538 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -186,7 +186,7 @@ system: maxDPI: 500 # Maximum allowed DPI for PDF to image conversion corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down. backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. - frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. + frontendUrl: "" # Base URL of the web app, as a browser reaches it (e.g. 'https://app.example.com', or 'https://example.com/app' if served under a base path). Optional - if not set, will use backendUrl. Used for any link handed to a browser: invite emails, share links, mobile QR codes, and the account-link handshake. enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured. enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured. mobileScannerSettings: diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java index bdd9df10a8..558341745d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java @@ -24,22 +24,6 @@ import tools.jackson.databind.node.ObjectNode; /** * Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode * A"). - * - *

Calls: - * - *

    - *
  • {@link #register} — relays the admin's short-lived Supabase JWT to {@code POST - * /api/v1/account-link/register}; the SaaS side mints + returns a device credential. - *
  • {@link #fetchEntitlement} — authenticates with the stored device credential against {@code - * GET /api/v1/instance/entitlement}; what the local gate consults. - *
  • {@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports - * cumulative units and returns the refreshed entitlement. - *
  • {@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST - * /api/v1/instance/revoke-self}). - *
- * - *

Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see - * {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS. */ @Slf4j @Service @@ -72,13 +56,7 @@ public class AccountLinkClient { this.httpClient = httpClient; } - /** The device credential a successful {@link #register} returns. */ - public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {} - - /** - * A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can - * map auth failures (401/403) through rather than masking everything as a 502. - */ + /** A non-2xx reply from the SaaS account-link API. */ public static class UpstreamException extends IOException { private final int status; @@ -92,11 +70,7 @@ public class AccountLinkClient { } } - /** - * Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a - * transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on - * this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch. - */ + /** Authoritative deny (401/403) — the device credential is revoked or invalid. */ public static final class RevokedException extends RuntimeException { private final int status; @@ -110,46 +84,142 @@ public class AccountLinkClient { } } + /** What the SaaS side hands back when it records a connect handshake. */ + public record ConnectRequestResult( + String requestId, int expiresInSeconds, String authorizeUrl) {} + + public enum ConnectClaimOutcome { + /** Approved and collected; the credential fields are populated. */ + GRANTED, + /** A re-authentication was approved. */ + CONFIRMED, + /** No human decision yet. */ + PENDING, + /** Declined, expired or already used. */ + REJECTED, + /** SaaS unreachable or erroring. */ + UNAVAILABLE + } + + public record ConnectClaimResult( + ConnectClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) { + static ConnectClaimResult of(ConnectClaimOutcome outcome) { + return new ConnectClaimResult(outcome, null, null, null); + } + } + + /** Opens a connect handshake. */ + public ConnectRequestResult connectRequest( + String name, String callbackUrl, String nonce, String claimSecret) throws IOException { + return connectRequest(name, callbackUrl, nonce, claimSecret, null); + } + /** - * Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted - * credential. - * - * @throws IOException on transport failure or a non-2xx response (caller surfaces to the - * admin). + * As {@link #connectRequest}, but presenting an existing device credential so the SaaS side + * treats this as a re-authentication and pins the handshake to the team we already belong to. */ - public RegisterResult register(String supabaseJwt, String instanceName) throws IOException { - String body = - instanceName == null || instanceName.isBlank() - ? "{}" - : "{\"name\":" + mapper.writeValueAsString(instanceName) + "}"; - HttpRequest request = + public ConnectRequestResult connectRequest( + String name, + String callbackUrl, + String nonce, + String claimSecret, + DeviceCredential credential) + throws IOException { + ObjectNode root = mapper.createObjectNode(); + if (name != null && !name.isBlank()) { + root.put("name", name); + } + root.put("callbackUrl", callbackUrl); + root.put("nonce", nonce); + root.put("claimSecret", claimSecret); + + HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(uri("/api/v1/account-link/register")) - .header("Authorization", "Bearer " + supabaseJwt) + .uri(uri("/api/v1/account-link/connect/request")) .header("Content-Type", "application/json") .header("Accept", "application/json") .timeout(timeout()) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(); + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(root))); + if (credential != null) { + builder.header(HEADER_DEVICE_ID, credential.getDeviceId()) + .header(HEADER_DEVICE_SECRET, credential.getDeviceSecret()); + } - HttpResponse response = send(request); + HttpResponse response = send(builder.build()); if (response.statusCode() / 100 != 2) { throw new UpstreamException(response.statusCode(), response.body()); } - JsonNode root = mapper.readTree(response.body()); - String deviceId = text(root, "deviceId"); - String deviceSecret = text(root, "deviceSecret"); - if (deviceId == null || deviceSecret == null) { - throw new IOException("SaaS register response missing deviceId/deviceSecret"); + JsonNode body = mapper.readTree(response.body()); + String requestId = text(body, "requestId"); + if (requestId == null) { + throw new IOException("SaaS connect response missing requestId"); + } + String authorizeUrl = text(body, "authorizeUrl"); + if (authorizeUrl == null || !isAbsoluteHttpUrl(authorizeUrl)) { + throw new IOException("SaaS connect response carried no usable authorizeUrl"); + } + return new ConnectRequestResult(requestId, body.path("expiresIn").asInt(0), authorizeUrl); + } + + /** + * Collects the device credential for an approved handshake, proving possession of the claim + * secret. + */ + public ConnectClaimResult connectClaim(String requestId, String claimSecret) { + HttpResponse response; + try { + ObjectNode root = mapper.createObjectNode(); + root.put("requestId", requestId); + root.put("claimSecret", claimSecret); + HttpRequest request = + HttpRequest.newBuilder() + .uri(uri("/api/v1/account-link/connect/claim")) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .timeout(timeout()) + .POST( + HttpRequest.BodyPublishers.ofString( + mapper.writeValueAsString(root))) + .build(); + response = send(request); + } catch (Exception e) { + log.debug("Connect claim failed (transport): {}", e.getMessage()); + return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE); + } + int status = response.statusCode(); + if (status == 202) { + return ConnectClaimResult.of(ConnectClaimOutcome.PENDING); + } + if (status >= 500 && status <= 599) { + return ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE); + } + if (status < 200 || status > 299) { + return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED); + } + try { + JsonNode body = mapper.readTree(response.body()); + Long teamId = body.hasNonNull("teamId") ? body.get("teamId").asLong() : null; + // A re-authentication says so explicitly and carries no credential, so an absent + // credential is only an error when we were expecting one. + if ("confirmed".equals(text(body, "status"))) { + return new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, teamId); + } + String deviceId = text(body, "deviceId"); + String deviceSecret = text(body, "deviceSecret"); + if (deviceId == null || deviceSecret == null) { + log.warn("Connect claim succeeded but the reply carried no credential"); + return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED); + } + return new ConnectClaimResult( + ConnectClaimOutcome.GRANTED, deviceId, deviceSecret, teamId); + } catch (RuntimeException e) { + log.debug("Connect claim parse failed: {}", e.getMessage()); + return ConnectClaimResult.of(ConnectClaimOutcome.REJECTED); } - Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null; - return new RegisterResult(deviceId, deviceSecret, teamId); } /** * Revokes this instance's own credential on the SaaS side, authenticated by that credential. - * Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local - * unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS. */ public boolean revokeSelf(String deviceId, String deviceSecret) { try { @@ -174,17 +244,7 @@ public class AccountLinkClient { } } - /** - * Fetches the current entitlement using the stored device credential. Three outcomes: - * - *

    - *
  • 2xx → the parsed snapshot. - *
  • 401/403 → {@link RevokedException} (authoritative deny — revoked/invalid credential); - * the caller must BLOCK, not fail open. - *
  • transport failure, other non-2xx (e.g. 5xx), or a malformed body → {@code null} - * ("unknown" — the caller fails open). - *
- */ + /** Fetches the current entitlement using the stored device credential. */ public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) { HttpResponse response; try { @@ -224,9 +284,6 @@ public class AccountLinkClient { /** * Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and * returns the fresh entitlement in the same reply — one round-trip both reports and refreshes. - * SaaS bills the delta against its last-seen cumulative, so resending the same totals is - * idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must - * not advance its last-synced markers so the usage retries next sync. */ public InstanceEntitlement reportUsage( String deviceId, @@ -360,4 +417,19 @@ public class AccountLinkClient { private static String text(JsonNode node, String field) { return node.hasNonNull(field) ? node.get(field).asText() : null; } + + /** Absolute http(s) with a host. */ + static boolean isAbsoluteHttpUrl(String candidate) { + try { + URI uri = URI.create(candidate.strip()); + String scheme = uri.getScheme(); + return uri.isAbsolute() + && scheme != null + && ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + && uri.getHost() != null + && !uri.getHost().isBlank(); + } catch (IllegalArgumentException e) { + return false; + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java index 52af366df4..b1826b7cca 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java @@ -16,21 +16,11 @@ import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Hidden; +import jakarta.servlet.http.HttpServletRequest; + import lombok.extern.slf4j.Slf4j; -/** - * Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A"). - * - *

The portal (served from this same origin, admin authenticated by the existing self-hosted - * security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS - * backend, which mints + returns a device credential we store locally. {@code GET /status} backs - * the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds - * to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now" - * / test aid). - * - *

Admin-only, {@code @Profile("!saas")}, gated behind {@code - * stirling.billing.account-link.enabled} — off → bean absent → 404. - */ +/** Same-origin account-link surface on the self-hosted instance (combined billing). */ @Slf4j @Hidden @RestController @@ -41,51 +31,110 @@ import lombok.extern.slf4j.Slf4j; public class AccountLinkController { private final AccountLinkService service; + private final ConnectService connectService; private final LocalUsageService localUsageService; // Present only when metering is on (its own flag); absent → /sync-now reports 409. private final ObjectProvider syncServiceProvider; public AccountLinkController( AccountLinkService service, + ConnectService connectService, LocalUsageService localUsageService, ObjectProvider syncServiceProvider) { this.service = service; + this.connectService = connectService; this.localUsageService = localUsageService; this.syncServiceProvider = syncServiceProvider; } - /** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */ - public record LinkRequest(String supabaseJwt, String name) {} + /** {@code callbackUrl} is the portal telling us where its own callback route lives. */ + public record ConnectStartRequest(String name, String callbackUrl) {} - @PostMapping("/link") - public ResponseEntity link(@RequestBody LinkRequest req) { - if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) { - return ResponseEntity.badRequest() - .body(java.util.Map.of("error", "supabaseJwt is required")); - } + /** {@code nonce} comes from the callback fragment the approval page redirected to. */ + public record ConnectCompleteRequest(String nonce) {} + + /** + * Opens a browser-mediated link handshake and returns the approval URL to send the admin to. + */ + @PostMapping("/connect/start") + public ResponseEntity connectStart( + @RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) { try { - return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name())); + return ResponseEntity.ok( + connectService.start(req != null ? req.name() : null, callbackHint(req, http))); } catch (AccountLinkClient.UpstreamException e) { - // Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so - // the portal can prompt a re-sign-in. Anything else upstream → 502. Don't echo the - // raw upstream body back to the browser. - HttpStatus status = - e.status() == HttpStatus.UNAUTHORIZED.value() - || e.status() == HttpStatus.FORBIDDEN.value() - ? HttpStatus.valueOf(e.status()) - : HttpStatus.BAD_GATEWAY; - log.warn("Account-link register rejected upstream: HTTP {}", e.status()); - return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED")); - } catch (IOException e) { - // Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the - // configured SaaS host/IP. Log it server-side; return the same opaque body the - // UpstreamException branch does. - log.warn("Account-link failed (transport): {}", e.getMessage()); + log.warn("Account-link connect rejected upstream: HTTP {}", e.status()); return ResponseEntity.status(HttpStatus.BAD_GATEWAY) - .body(java.util.Map.of("error", "LINK_FAILED")); + .body(java.util.Map.of("error", "CONNECT_FAILED")); + } catch (IOException e) { + // Same reasoning as /link: a transport message can carry the configured SaaS host. + log.warn("Account-link connect failed (transport): {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(java.util.Map.of("error", "CONNECT_FAILED")); } } + /** Re-establishes the admin's SaaS session for a server that is already linked. */ + @PostMapping("/connect/reauth") + public ResponseEntity connectReauth( + @RequestBody(required = false) ConnectStartRequest req, HttpServletRequest http) { + try { + return ResponseEntity.ok(connectService.startReauth(callbackHint(req, http))); + } catch (AccountLinkClient.UpstreamException e) { + log.warn("Account-link reauth rejected upstream: HTTP {}", e.status()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(java.util.Map.of("error", "CONNECT_FAILED")); + } catch (IOException e) { + log.warn("Account-link reauth failed: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(java.util.Map.of("error", "CONNECT_FAILED")); + } + } + + /** Called by the callback page with the nonce it found in the fragment. */ + @PostMapping("/connect/complete") + public ResponseEntity connectComplete( + @RequestBody(required = false) ConnectCompleteRequest req) { + return ResponseEntity.ok(connectService.complete(req != null ? req.nonce() : null)); + } + + /** Everything we know about where the admin's browser is, for the callback. */ + private static ConnectService.CallbackHint callbackHint( + ConnectStartRequest req, HttpServletRequest http) { + return new ConnectService.CallbackHint( + req != null ? req.callbackUrl() : null, http.getHeader("Origin"), baseUrlOf(http)); + } + + /** + * This instance's base URL as the browser reached it, including any context path so a subpath + * deployment builds a callback that actually resolves. + */ + private static String baseUrlOf(HttpServletRequest request) { + String forwardedProto = firstHop(request.getHeader("X-Forwarded-Proto")); + String forwardedHost = firstHop(request.getHeader("X-Forwarded-Host")); + String scheme = forwardedProto != null ? forwardedProto : request.getScheme(); + String hostPort; + if (forwardedHost != null) { + hostPort = forwardedHost; + } else { + int port = request.getServerPort(); + boolean defaultPort = + ("http".equals(scheme) && port == 80) + || ("https".equals(scheme) && port == 443); + hostPort = defaultPort ? request.getServerName() : request.getServerName() + ":" + port; + } + String context = request.getContextPath() == null ? "" : request.getContextPath(); + return scheme + "://" + hostPort + context; + } + + private static String firstHop(String headerValue) { + if (headerValue == null || headerValue.isBlank()) { + return null; + } + String first = headerValue.split(",")[0].strip(); + return first.isEmpty() ? null : first; + } + @GetMapping("/status") public ResponseEntity status() { return ResponseEntity.ok(service.status()); @@ -106,12 +155,7 @@ public class AccountLinkController { return ResponseEntity.ok(localUsageService.currentPeriodUnsynced()); } - /** - * Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin - * "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent: - * re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run; - * {@code 409} when metering is off (the sync bean is absent). - */ + /** Forces an immediate usage sync to SaaS — the same work the daily scheduler does. */ @PostMapping("/sync-now") public ResponseEntity syncNow() { UsageSyncService sync = syncServiceProvider.getIfAvailable(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java index 6d1f1fb151..619aa10e86 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java @@ -8,29 +8,17 @@ import org.springframework.stereotype.Component; import lombok.Getter; import lombok.Setter; -/** - * Self-hosted side of combined-billing "Mode A" (connected self-hosted). - * - *

Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag - * the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional - * code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is - * off by default and dark — when off nothing gates and the link endpoints 404. - */ +/** Self-hosted side of combined billing: this instance bills through a linked SaaS team. */ @Getter @Setter @Component @ConfigurationProperties(prefix = "stirling.billing.account-link") public class AccountLinkProperties { - /** Master switch. When {@code false} (default) the feature is fully inert. */ + /** Master switch. */ private boolean enabled = false; - /** - * Base URL of the SaaS backend this instance links to (register + entitlement live there). - * - *

STUB: defaults to the public cloud host; an operator overrides it for staging. There is no - * existing SaaS-base-url property in the self-hosted profile, so this is introduced here. - */ + /** Base URL of the SaaS backend this instance links to (register + entitlement live there). */ private String saasBaseUrl = "https://stirling.com/app"; /** Cached entitlement is reused for this long before a refresh is attempted. */ @@ -39,20 +27,18 @@ public class AccountLinkProperties { /** Connect/read timeout for the outbound SaaS calls. */ private int requestTimeoutSeconds = 10; - /** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */ + /** Phase 2 usage metering + daily sync. */ private final Metering metering = new Metering(); /** - * Dedicated billing switch, separate from {@link #enabled} so the link plumbing can be - * enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap - * enforcement. Both default off; metering requires the master flag too. This is the production - * safety key — flipping it on is what actually bills linked instances. + * Separate from {@link #enabled} so linking can be exercised without billing anything. Both + * default off, and metering needs the master flag as well. */ @Getter @Setter public static class Metering { - /** Turns on usage metering, the daily sync, and cap enforcement. Default off. */ + /** Turns on usage metering, the daily sync, and cap enforcement. */ private boolean enabled = false; /** @@ -65,12 +51,7 @@ public class AccountLinkProperties { */ private int graceDays = 3; - /** - * Dedup window for identical input sets. A re-run of the same inputs within this window is - * treated as workflow chaining and not re-charged; the same inputs run again after it are - * billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op - * costs the same on the instance and in the cloud. - */ + /** Dedup window for identical input sets. */ private Duration workflowWindow = Duration.ofMinutes(5); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java index 1bb27d9cd6..e1283d83ba 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkService.java @@ -1,6 +1,5 @@ package stirling.software.proprietary.accountlink; -import java.io.IOException; import java.util.Optional; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -9,13 +8,7 @@ import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; -/** - * Linking orchestrator (self-hosted side of combined-billing "Mode A"). - * - *

{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase - * JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest. - * The credential — not the JWT — authenticates all later unattended entitlement calls. - */ +/** Linking orchestrator (self-hosted side of combined billing). */ @Slf4j @Service @Profile("!saas") @@ -38,24 +31,9 @@ public class AccountLinkService { /** Status of this instance's link, for the portal's "Account link" card. */ public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {} - /** - * Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the - * credential. - * - * @throws IOException if the SaaS register call fails (surfaced to the admin as a link error). - */ - public LinkStatus link(String supabaseJwt, String instanceName) throws IOException { - AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName); - credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId()); - entitlementCache.invalidate(); - log.info("Account-link: instance linked to team {}", result.teamId()); - return status(); - } - /** * Unlinks this instance — best-effort tells SaaS to revoke first (so the row gets {@code - * revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear - * still proceeds (admin's intent must win); the orphan row can be revoked from the portal. + * revoked_at} set), then clears locally regardless. */ public void unlink() { credentialStore diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java index fbac6a8603..2715b4743b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java @@ -12,7 +12,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; /** - * Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A"). + * Singleton row holding this instance's daily-sync bookkeeping (combined billing). * *

{@link #lastSyncSeq} is reserved (incremented + persisted) before each report so it * is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java index 15b5e3842d..d0cdf36f90 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java @@ -2,5 +2,5 @@ package stirling.software.proprietary.accountlink; import org.springframework.data.jpa.repository.JpaRepository; -/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */ +/** Persistence for the singleton {@link AccountLinkSyncState} (combined billing). */ public interface AccountLinkSyncStateRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java new file mode 100644 index 0000000000..978d6d494e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectService.java @@ -0,0 +1,276 @@ +package stirling.software.proprietary.accountlink; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; + +/** Browser-mediated account linking, instance side. */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class ConnectService { + + /** Frontend route that consumes the callback fragment. */ + static final String CALLBACK_PATH = "/account-link/callback"; + + private static final int SECRET_BYTES = 32; + + private final AccountLinkClient client; + private final ConnectStateRepository stateRepo; + private final DeviceCredentialStore credentialStore; + private final EntitlementCache entitlementCache; + private final ApplicationProperties applicationProperties; + private final SecureRandom random = new SecureRandom(); + + public ConnectService( + AccountLinkClient client, + ConnectStateRepository stateRepo, + DeviceCredentialStore credentialStore, + EntitlementCache entitlementCache, + ApplicationProperties applicationProperties) { + this.client = client; + this.stateRepo = stateRepo; + this.credentialStore = credentialStore; + this.entitlementCache = entitlementCache; + this.applicationProperties = applicationProperties; + } + + public enum Phase { + /** Nothing in flight and not linked. */ + NONE, + /** A handshake is open, waiting for a leader to approve it on the SaaS site. */ + PENDING, + /** Linked. */ + LINKED, + /** The handshake outlived its window; start a new one. */ + EXPIRED, + /** Declined or already used; start a new one. */ + REJECTED, + /** SaaS could not be reached; the handshake is still valid and can be retried. */ + UNAVAILABLE + } + + /** What the portal renders. */ + public record ConnectStatus( + Phase phase, String authorizeUrl, Long secondsRemaining, Long teamId) { + static ConnectStatus of(Phase phase) { + return new ConnectStatus(phase, null, null, null); + } + } + + /** Everything we know about where the admin's browser actually is, in decreasing authority. */ + public record CallbackHint( + String requestedCallbackUrl, String browserOrigin, String derivedBaseUrl) {} + + /** Opens a handshake and returns where to send the admin. */ + @Transactional + public ConnectStatus start(String name, CallbackHint hint) throws IOException { + if (credentialStore.isLinked()) { + return status(); + } + return open(name, hint, null); + } + + /** + * Opens a handshake that only re-establishes the admin's browser session, for an instance that + * is already linked. + */ + @Transactional + public ConnectStatus startReauth(CallbackHint hint) throws IOException { + DeviceCredential credential = + credentialStore + .get() + .orElseThrow( + () -> + new IOException( + "This server is not linked, so there is no session" + + " to re-establish")); + return open(credential.getDeviceId(), hint, credential); + } + + private ConnectStatus open(String name, CallbackHint hint, DeviceCredential credential) + throws IOException { + String callbackUrl = resolveCallbackUrl(hint); + if (callbackUrl == null) { + throw new IOException( + "Cannot determine where to send the admin back to; set system.frontendUrl"); + } + String nonce = randomSecret(); + String claimSecret = randomSecret(); + + AccountLinkClient.ConnectRequestResult created = + client.connectRequest(name, callbackUrl, nonce, claimSecret, credential); + + LocalDateTime now = LocalDateTime.now(); + ConnectState state = new ConnectState(); + state.setId(ConnectState.SINGLETON_ID); + state.setRequestId(created.requestId()); + state.setNonce(nonce); + state.setClaimSecret(claimSecret); + state.setCallbackUrl(callbackUrl); + state.setAuthorizeUrl(created.authorizeUrl()); + state.setCreatedAt(now); + state.setExpiresAt( + now.plusSeconds(created.expiresInSeconds() > 0 ? created.expiresInSeconds() : 900)); + stateRepo.save(state); + + log.info("Account-link connect: handshake {} opened", created.requestId()); + return pendingStatus(state, now); + } + + /** Finishes a handshake from the callback the approval page redirected to. */ + @Transactional + public ConnectStatus complete(String nonce) { + Optional found = stateRepo.findById(ConnectState.SINGLETON_ID); + if (found.isEmpty()) { + // Already finished (a double-submitted callback) or never started. + return status(); + } + ConnectState state = found.get(); + if (state.isExpired(LocalDateTime.now())) { + stateRepo.delete(state); + return ConnectStatus.of(Phase.EXPIRED); + } + if (nonce == null || !nonceMatches(nonce, state.getNonce())) { + log.warn( + "Account-link connect: callback for handshake {} had a bad nonce", + state.getRequestId()); + return ConnectStatus.of(Phase.REJECTED); + } + + AccountLinkClient.ConnectClaimResult claim = + client.connectClaim(state.getRequestId(), state.getClaimSecret()); + return switch (claim.outcome()) { + case GRANTED -> { + credentialStore.save(claim.deviceId(), claim.deviceSecret(), claim.teamId()); + entitlementCache.invalidate(); + stateRepo.delete(state); + log.info("Account-link connect: linked to team {}", claim.teamId()); + yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId()); + } + case CONFIRMED -> { + stateRepo.delete(state); + log.info( + "Account-link connect: session re-established for team {}", claim.teamId()); + yield new ConnectStatus(Phase.LINKED, null, null, claim.teamId()); + } + case PENDING -> + // The admin reached the callback before the approval committed. The row stays, + // so a retry finishes it. + ConnectStatus.of(Phase.PENDING); + case REJECTED -> { + stateRepo.delete(state); + yield ConnectStatus.of(Phase.REJECTED); + } + case UNAVAILABLE -> ConnectStatus.of(Phase.UNAVAILABLE); + }; + } + + @Transactional(readOnly = true) + public ConnectStatus status() { + Optional credential = credentialStore.get(); + if (credential.isPresent()) { + return new ConnectStatus(Phase.LINKED, null, null, credential.get().getTeamId()); + } + Optional state = stateRepo.findById(ConnectState.SINGLETON_ID); + if (state.isEmpty()) { + return ConnectStatus.of(Phase.NONE); + } + LocalDateTime now = LocalDateTime.now(); + if (state.get().isExpired(now)) { + return ConnectStatus.of(Phase.EXPIRED); + } + return pendingStatus(state.get(), now); + } + + private static ConnectStatus pendingStatus(ConnectState state, LocalDateTime now) { + long remaining = Duration.between(now, state.getExpiresAt()).toSeconds(); + return new ConnectStatus( + Phase.PENDING, state.getAuthorizeUrl(), Math.max(remaining, 0), null); + } + + /** Decides the callback, preferring knowledge over inference. */ + String resolveCallbackUrl(CallbackHint hint) { + String configured = applicationProperties.getSystem().getFrontendUrl(); + if (configured != null && !configured.isBlank()) { + return trimTrailingSlash(configured.strip()) + CALLBACK_PATH; + } + String browserOrigin = originOf(hint.browserOrigin()); + if (browserOrigin != null) { + String requested = hint.requestedCallbackUrl(); + if (requested != null && browserOrigin.equals(originOf(requested))) { + return requested.strip(); + } + return browserOrigin + CALLBACK_PATH; + } + return hint.derivedBaseUrl() == null || hint.derivedBaseUrl().isBlank() + ? null + : trimTrailingSlash(hint.derivedBaseUrl().strip()) + CALLBACK_PATH; + } + + /** Scheme, host and port of an absolute http(s) URL; null if it is not one. */ + private static String originOf(String candidate) { + if (candidate == null || candidate.isBlank()) { + return null; + } + URI uri; + try { + uri = new URI(candidate.strip()); + } catch (URISyntaxException e) { + return null; + } + if (uri.getScheme() == null || uri.getHost() == null) { + return null; + } + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + return null; + } + int port = uri.getPort(); + boolean defaultPort = + port == -1 + || ("http".equals(scheme) && port == 80) + || ("https".equals(scheme) && port == 443); + return defaultPort + ? scheme + "://" + uri.getHost() + : scheme + "://" + uri.getHost() + ":" + port; + } + + private static String trimTrailingSlash(String value) { + return value.replaceAll("/+$", ""); + } + + private String randomSecret() { + byte[] buf = new byte[SECRET_BYTES]; + random.nextBytes(buf); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + } + + /** Constant-time so a caller cannot probe the nonce a character at a time. */ + private static boolean nonceMatches(String candidate, String expected) { + if (expected == null) { + return false; + } + return MessageDigest.isEqual( + candidate.getBytes(StandardCharsets.UTF_8), + expected.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java new file mode 100644 index 0000000000..c0dcea032e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectState.java @@ -0,0 +1,60 @@ +package stirling.software.proprietary.accountlink; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** The one in-flight "connect this server" handshake, instance side. */ +@Entity +@Table(name = "account_link_connect_state") +@NoArgsConstructor +@Getter +@Setter +public class ConnectState implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final Long SINGLETON_ID = 1L; + + @Id + @Column(name = "id") + private Long id = SINGLETON_ID; + + /** Opaque handle the SaaS side gave us; identifies the handshake on both sides. */ + @Column(name = "request_id", nullable = false, length = 64) + private String requestId; + + /** Correlator we minted. */ + @Column(name = "nonce", nullable = false, length = 128) + private String nonce; + + /** Secret we minted and sent to SaaS server to server. */ + @Column(name = "claim_secret", nullable = false, length = 128) + private String claimSecret; + + /** Where we asked the approval page to send the admin back to. */ + @Column(name = "callback_url", nullable = false, length = 2048) + private String callbackUrl; + + /** The approval URL handed to the browser, so a reload can offer it again. */ + @Column(name = "authorize_url", nullable = false, length = 2048) + private String authorizeUrl; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + public boolean isExpired(LocalDateTime now) { + return expiresAt != null && expiresAt.isBefore(now); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java new file mode 100644 index 0000000000..995dfccde1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/ConnectStateRepository.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.accountlink; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** Data access for the singleton {@link ConnectState} row. */ +public interface ConnectStateRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java index 4625572310..7da486b741 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/DeviceCredential.java @@ -13,8 +13,8 @@ import lombok.NoArgsConstructor; import lombok.Setter; /** - * The device credential this self-hosted instance received when it linked a SaaS account - * (combined-billing "Mode A"). Singleton — one instance links to exactly one SaaS team. + * The device credential this self-hosted instance received when it linked a SaaS account (combined + * billing). Singleton — one instance links to exactly one SaaS team. * *

Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code * deviceSecret} so it can present it on every unattended entitlement call. It lives in the local diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java index 018684b73f..c0cc901e1f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java @@ -8,7 +8,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; /** - * Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance. + * Decides whether a request may proceed under combined billing on a self-hosted instance. * *

Rules (in order): * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java index 0cd71c9bda..9e73267561 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java @@ -39,8 +39,8 @@ import stirling.software.proprietary.policy.controller.PolicyRunRoutes; import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; /** - * Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API / - * AI / automation) work when the instance is unlinked or over its limit; manual tools pass through. + * Request-time gate + meter for combined billing. {@code preHandle} blocks billable (API / AI / + * automation) work when the instance is unlinked or over its limit; manual tools pass through. * {@code afterCompletion} meters a successful billable op into the per-period cumulative counter. * *

Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate" diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java index 1ed49d6a8b..278380a5da 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java @@ -16,11 +16,11 @@ import lombok.NoArgsConstructor; /** * The last time the instance metered a given input set this period — the local equivalent of the - * cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling workflow - * window: an identical input set re-submitted within the window (see {@link - * AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the - * same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job - * window so the same operation costs the same on the instance and in the cloud. + * cloud's lineage join (combined billing). The meter dedups on a rolling workflow window: an + * identical input set re-submitted within the window (see {@link AccountLinkProperties.Metering}) + * is treated as workflow chaining and not re-charged, while the same inputs run again after the + * window are billed afresh — matching the cloud's 5-minute open-job window so the same operation + * costs the same on the instance and in the cloud. * *

{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud * artifact touches its job). One row per {@code (period, signature)}; the unique constraint also diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java index 863f503f61..a31310a7b3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java @@ -5,7 +5,7 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */ +/** Persistence for the per-period metered input-set signatures (combined billing). */ public interface MeteredInputSignatureRepository extends JpaRepository { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java index b90af07958..4c06089040 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java @@ -17,10 +17,10 @@ import lombok.NoArgsConstructor; import stirling.software.proprietary.billing.BillingCategory; /** - * Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A". - * Each successful billable op increments its row; the daily sync reports the cumulative totals and - * SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills - * nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start, + * Durable per-(billing period, category) cumulative usage counter for combined billing. Each + * successful billable op increments its row; the daily sync reports the cumulative totals and SaaS + * bills the delta since the last sync. The cumulative model is idempotent (a resend bills nothing) + * and tamper-evident (a counter that drops is a signal). One row per {@code (period_start, * category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it. */ @Entity diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java index 2140775abc..3013d00a52 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java @@ -9,7 +9,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; -/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */ +/** Persistence for the per-period/per-category usage counters (combined billing). */ public interface UsageCounterRepository extends JpaRepository { /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java index 4c4ce2377c..a12a26eb8a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java @@ -18,8 +18,8 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.billing.BillingCategory; /** - * Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category - * usage to SaaS, which bills the delta against its own last-seen totals. + * Daily usage sender for combined billing. Reports each period's cumulative per-category usage to + * SaaS, which bills the delta against its own last-seen totals. * *

Resilience: the sync seq is persisted before the report so it never regresses across * restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java index 232dd499dc..182c162f6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java @@ -11,9 +11,9 @@ import java.util.HexFormat; /** * SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's - * meter (combined-billing "Mode A"), so both derive an identical signature for the same - * bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent - * of file size), hardware-accelerated by the JVM where available. + * meter (combined billing), so both derive an identical signature for the same bytes — the + * basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent of file + * size), hardware-accelerated by the JVM where available. * *

Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core * build yet is reachable from {@code :saas} (which depends on {@code :proprietary}). diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java index 969111e9f5..2bce17d732 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java @@ -21,9 +21,9 @@ import org.mockito.ArgumentCaptor; import tools.jackson.databind.ObjectMapper; /** - * Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register - * relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on - * unreachable) behaviour hold. + * Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms the connect + * handshake refuses an authorize URL it would not navigate to and carries no user token, and that + * entitlement parsing + the fail-open (null on unreachable) behaviour hold. */ class AccountLinkClientTest { @@ -48,39 +48,79 @@ class AccountLinkClientTest { return resp; } + // register() is gone with the JWT relay, and with it the two tests that asserted this client + // sends an Authorization: Bearer header. Nothing here carries a user token any more. + @Test @SuppressWarnings("unchecked") - void registerRelaysJwtAndParsesCredential() throws Exception { - // Build the stub response first: nesting response() inside when() trips Mockito's - // unfinished-stubbing check (inner when() runs mid outer when()). + void connectRequestRefusesAnAuthorizeUrlItWouldNotNavigateTo() throws Exception { + // The reply drives a browser navigation, so a non-absolute or non-http(s) value must fail + // loudly here rather than reach the admin. HttpResponse resp = - response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}"); - ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); - when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) - .thenReturn(resp); + response(201, "{\"requestId\":\"req-1\",\"authorizeUrl\":\"/link?request=req-1\"}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); - AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server"); - - assertEquals("dev-1", result.deviceId()); - assertEquals("sec-1", result.deviceSecret()); - assertEquals(42L, result.teamId()); - - HttpRequest sent = captor.getValue(); - assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null)); - assertEquals( - "https://saas.example.com/api/v1/account-link/register", sent.uri().toString()); + assertThrows( + java.io.IOException.class, + () -> client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret")); } @Test @SuppressWarnings("unchecked") - void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception { - HttpResponse resp = response(401, "{\"error\":\"unauthorized\"}"); + void connectRequestParsesTheAuthorizeUrlItIsGiven() throws Exception { + HttpResponse resp = + response( + 201, + "{\"requestId\":\"req-1\",\"expiresIn\":900," + + "\"authorizeUrl\":\"https://app.example.com/link?request=req-1\"}"); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + AccountLinkClient.ConnectRequestResult result = + client.connectRequest("n", "https://pdf.example.com/cb", "nonce", "secret"); + + assertEquals("req-1", result.requestId()); + assertEquals("https://app.example.com/link?request=req-1", result.authorizeUrl()); + // No user token on this call, by design. + assertEquals(null, captor.getValue().headers().firstValue("Authorization").orElse(null)); + } + + @Test + @SuppressWarnings("unchecked") + void connectClaimGrantsTheCredentialOnSuccess() throws Exception { + HttpResponse resp = + response(200, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":7}"); when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); - AccountLinkClient.UpstreamException ex = - assertThrows( - AccountLinkClient.UpstreamException.class, - () -> client.register("jwt", null)); - assertEquals(401, ex.status()); + + AccountLinkClient.ConnectClaimResult result = client.connectClaim("req-1", "secret"); + + assertEquals(AccountLinkClient.ConnectClaimOutcome.GRANTED, result.outcome()); + assertEquals("dev-1", result.deviceId()); + assertEquals("sec-1", result.deviceSecret()); + } + + @Test + @SuppressWarnings("unchecked") + void connectClaimMapsTheStatusItIsGiven() throws Exception { + // The whole point of these four: a claim consumes the request server-side, so + // reading 200 as anything but success loses the credential irrecoverably. + assertEquals(AccountLinkClient.ConnectClaimOutcome.PENDING, claimOutcome(202, "{}")); + assertEquals(AccountLinkClient.ConnectClaimOutcome.UNAVAILABLE, claimOutcome(503, "{}")); + assertEquals(AccountLinkClient.ConnectClaimOutcome.REJECTED, claimOutcome(400, "{}")); + assertEquals( + AccountLinkClient.ConnectClaimOutcome.CONFIRMED, + claimOutcome(200, "{\"status\":\"confirmed\",\"teamId\":7}")); + } + + @SuppressWarnings("unchecked") + private AccountLinkClient.ConnectClaimOutcome claimOutcome(int status, String body) + throws Exception { + // Built before the when(), not inside it: response() stubs a mock of its own, and + // Mockito cannot have that happen mid-stubbing. + HttpResponse resp = response(status, body); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + return client.connectClaim("req-1", "secret").outcome(); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java index 41f544de7e..6e13021a40 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.accountlink; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -14,16 +15,15 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest; - /** - * The local (self-hosted) account-link controller's error mapping: an upstream auth rejection - * surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport - * faults are a 502. + * The local (self-hosted) account-link controller's error mapping. Every upstream or transport + * failure is a 502, and the response body never echoes the exception, because a DNS or TLS message + * can carry the configured SaaS host. */ class AccountLinkControllerTest { private AccountLinkService service; + private ConnectService connectService; private UsageSyncService syncService; private ObjectProvider syncProvider; private AccountLinkController controller; @@ -32,47 +32,54 @@ class AccountLinkControllerTest { @SuppressWarnings("unchecked") void setUp() { service = mock(AccountLinkService.class); + connectService = mock(ConnectService.class); syncService = mock(UsageSyncService.class); syncProvider = mock(ObjectProvider.class); controller = - new AccountLinkController(service, mock(LocalUsageService.class), syncProvider); + new AccountLinkController( + service, connectService, mock(LocalUsageService.class), syncProvider); } - @Test - void link_missingJwt_returns400() { - ResponseEntity resp = controller.link(new LinkRequest(" ", null)); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - } + // These asserted POST /link's error mapping, which distinguished 401/403 so the portal could + // prompt a re-sign-in. That endpoint is gone with the JWT relay, and the distinction went with + // it: connect/start carries no user token, so an upstream refusal is never the admin's session + // and everything non-transport is a plain gateway failure. @Test - void link_upstreamUnauthorized_maps401() throws Exception { - when(service.link("jwt", null)) - .thenThrow(new AccountLinkClient.UpstreamException(401, "bad token")); - ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - @Test - void link_upstreamForbidden_maps403() throws Exception { - when(service.link("jwt", null)) - .thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden")); - ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - } - - @Test - void link_upstreamServerError_maps502() throws Exception { - when(service.link("jwt", null)) + void connectStart_upstreamFailure_maps502() throws Exception { + when(connectService.start(any(), any())) .thenThrow(new AccountLinkClient.UpstreamException(500, "boom")); - ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + + ResponseEntity resp = controller.connectStart(null, request()); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); } @Test - void link_transportFailure_maps502() throws Exception { - when(service.link("jwt", null)).thenThrow(new IOException("connection refused")); - ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); + void connectStart_transportFailure_maps502WithoutLeakingTheHost() throws Exception { + when(connectService.start(any(), any())) + .thenThrow(new IOException("connection refused to saas.internal:8081")); + + ResponseEntity resp = controller.connectStart(null, request()); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + // The body must not echo the exception: a DNS/TLS message can carry the configured SaaS + // host. + assertThat(String.valueOf(resp.getBody())).doesNotContain("saas.internal"); + } + + @Test + void connectReauth_onAnUnlinkedServer_maps502() throws Exception { + when(connectService.startReauth(any())).thenThrow(new IOException("not linked")); + + ResponseEntity resp = controller.connectReauth(null, request()); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + } + + /** Minimal request: the controller only reads Origin and the forwarded/host details from it. */ + private static jakarta.servlet.http.HttpServletRequest request() { + return new org.springframework.mock.web.MockHttpServletRequest(); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java index b909fb37a0..410ef8a8c7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkServiceTest.java @@ -3,12 +3,10 @@ package stirling.software.proprietary.accountlink; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.io.IOException; import java.time.LocalDateTime; import java.util.Optional; @@ -30,33 +28,25 @@ class AccountLinkServiceTest { service = new AccountLinkService(client, store, cache); } + // The two link() tests here are gone with the JWT relay. Storing a credential and invalidating + // the entitlement cache is now ConnectService's job and is covered by ConnectServiceTest; what + // remains in this service is status and unlink. + @Test - void link_storesCredentialAndInvalidatesCache() throws IOException { - when(client.register("jwt", "name")) - .thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L)); + void status_linkedFromTheStoredCredential() { DeviceCredential stored = new DeviceCredential(); stored.setDeviceId("dev-1"); stored.setTeamId(7L); stored.setLinkedAt(LocalDateTime.now()); when(store.get()).thenReturn(Optional.of(stored)); - AccountLinkService.LinkStatus status = service.link("jwt", "name"); + AccountLinkService.LinkStatus status = service.status(); - verify(store).save("dev-1", "sec-1", 7L); - verify(cache).invalidate(); assertTrue(status.linked()); assertEquals("dev-1", status.deviceId()); assertEquals(7L, status.teamId()); } - @Test - void link_propagatesRegisterFailure() throws IOException { - when(client.register(any(), any())).thenThrow(new IOException("boom")); - org.junit.jupiter.api.Assertions.assertThrows( - IOException.class, () -> service.link("jwt", null)); - verify(cache, org.mockito.Mockito.never()).invalidate(); - } - @Test void status_unlinkedWhenNoCredential() { when(store.get()).thenReturn(Optional.empty()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java new file mode 100644 index 0000000000..5e040e4ad7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java @@ -0,0 +1,408 @@ +package stirling.software.proprietary.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimOutcome; +import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectClaimResult; +import stirling.software.proprietary.accountlink.AccountLinkClient.ConnectRequestResult; +import stirling.software.proprietary.accountlink.ConnectService.Phase; + +/** Unit tests for the instance half of the connect handshake. */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConnectServiceTest { + + private static final String NONCE = "the-nonce"; + private static final String CLAIM_SECRET = "the-claim-secret"; + private static final String AUTHORIZE_URL = "https://app.example.com/link?request=req-1"; + + @Mock private AccountLinkClient client; + @Mock private ConnectStateRepository stateRepo; + @Mock private DeviceCredentialStore credentialStore; + @Mock private EntitlementCache entitlementCache; + + private ApplicationProperties applicationProperties; + private ConnectService service; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + service = + new ConnectService( + client, + stateRepo, + credentialStore, + entitlementCache, + applicationProperties); + } + + private void configureFrontendUrl(String url) { + applicationProperties.getSystem().setFrontendUrl(url); + } + + @Test + void start_advertisesTheConfiguredFrontendUrlInPreferenceToTheRequest() throws Exception { + configureFrontendUrl("https://pdf.example.com/"); + stubCreate(); + + service.start("prod-1", fromRequest("http://10.0.0.5:8080")); + + verify(client) + .connectRequest( + anyString(), + // Trailing slash trimmed, and the request's own view ignored. + org.mockito.ArgumentMatchers.eq( + "https://pdf.example.com" + ConnectService.CALLBACK_PATH), + anyString(), + anyString(), + // A first link carries no credential; that is what makes it a first link. + org.mockito.ArgumentMatchers.isNull()); + } + + @Test + void start_fallsBackToTheAddressTheRequestArrivedOn() throws Exception { + stubCreate(); + + service.start(null, fromRequest("https://pdf.internal:8443/stirling")); + + ArgumentCaptor callback = ArgumentCaptor.forClass(String.class); + verify(client).connectRequest(any(), callback.capture(), anyString(), anyString(), any()); + // Context path preserved, so a subpath deployment gets a callback that resolves. + assertThat(callback.getValue()) + .isEqualTo("https://pdf.internal:8443/stirling" + ConnectService.CALLBACK_PATH); + } + + @Test + void start_withNoAddressAtAllFailsRatherThanGuessing() { + assertThat(catchIo(() -> service.start(null, fromRequest(null)))) + .hasMessageContaining("system.frontendUrl"); + verifyNoInteractions(client); + } + + @Test + void resolveCallback_honoursThePortalsOwnCallbackWhenTheBrowserOriginAgrees() { + // The frontend is the only party that knows its router's base path. + String requested = "http://localhost:5173/app/account-link/callback"; + + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + requested, + "http://localhost:5173", + "http://localhost:8080"))) + .isEqualTo(requested); + } + + @Test + void resolveCallback_ignoresACallbackFromADifferentOrigin() { + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + "https://evil.example.com/steal", + "http://localhost:5173", + "http://localhost:8080"))) + .isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH); + } + + @Test + void resolveCallback_prefersTheBrowserOriginOverTheApiRequest() { + // The whole point: :5173 is where the admin is, :8080 is where the call landed. + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + null, "http://localhost:5173", "http://localhost:8080"))) + .isEqualTo("http://localhost:5173" + ConnectService.CALLBACK_PATH); + } + + @Test + void resolveCallback_letsConfigurationBeatEverything() { + configureFrontendUrl("https://pdf.example.com/"); + + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + "http://localhost:5173/account-link/callback", + "http://localhost:5173", + "http://localhost:8080"))) + .isEqualTo("https://pdf.example.com" + ConnectService.CALLBACK_PATH); + } + + @Test + void resolveCallback_ignoresAnUnusableOriginHeader() { + // "null" is what a browser sends for an opaque origin; it must not become a callback. + assertThat( + service.resolveCallbackUrl( + new ConnectService.CallbackHint( + null, "null", "http://localhost:8080"))) + .isEqualTo("http://localhost:8080" + ConnectService.CALLBACK_PATH); + } + + @Test + void start_sendsTheAdminWhereverSaaSSaidToSendThem() throws Exception { + stubCreate(); + + ConnectService.ConnectStatus status = + service.start(null, fromRequest("https://pdf.example.com")); + + assertThat(status.phase()).isEqualTo(Phase.PENDING); + // Not composed here: only the SaaS side knows where its approval page lives, so an + // instance configuring that could only get it wrong. + assertThat(status.authorizeUrl()).isEqualTo(AUTHORIZE_URL); + } + + @Test + void start_keepsTheNonceAndClaimSecretItSent() throws Exception { + stubCreate(); + + service.start(null, fromRequest("https://pdf.example.com")); + + ArgumentCaptor nonce = ArgumentCaptor.forClass(String.class); + ArgumentCaptor secret = ArgumentCaptor.forClass(String.class); + verify(client).connectRequest(any(), anyString(), nonce.capture(), secret.capture(), any()); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectState.class); + verify(stateRepo).save(saved.capture()); + assertThat(saved.getValue().getNonce()).isEqualTo(nonce.getValue()); + assertThat(saved.getValue().getClaimSecret()).isEqualTo(secret.getValue()); + // Two independent secrets, not one value used twice. + assertThat(nonce.getValue()).isNotEqualTo(secret.getValue()); + } + + @Test + void start_whenAlreadyLinkedDoesNothing() throws Exception { + when(credentialStore.isLinked()).thenReturn(true); + when(credentialStore.get()).thenReturn(Optional.of(credential(7L))); + + ConnectService.ConnectStatus status = + service.start(null, fromRequest("https://pdf.example.com")); + + assertThat(status.phase()).isEqualTo(Phase.LINKED); + verifyNoInteractions(client); + verify(stateRepo, never()).save(any()); + } + + @Test + void complete_withTheRightNonceStoresTheCredentialAndClearsTheHandshake() { + ConnectState state = openHandshake(); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.connectClaim("req-1", CLAIM_SECRET)) + .thenReturn(new ConnectClaimResult(ConnectClaimOutcome.GRANTED, "dev", "sec", 7L)); + + ConnectService.ConnectStatus status = service.complete(NONCE); + + assertThat(status.phase()).isEqualTo(Phase.LINKED); + assertThat(status.teamId()).isEqualTo(7L); + verify(credentialStore).save("dev", "sec", 7L); + verify(entitlementCache).invalidate(); + verify(stateRepo).delete(state); + } + + @Test + void complete_withAWrongNonceClaimsNothingAndLeavesTheHandshakeAlone() { + ConnectState state = openHandshake(); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + + ConnectService.ConnectStatus status = service.complete("not-the-nonce"); + + assertThat(status.phase()).isEqualTo(Phase.REJECTED); + // The important half: an unverified caller cannot cancel a legitimate handshake. + verify(stateRepo, never()).delete(any()); + verifyNoInteractions(credentialStore); + verify(client, never()).connectClaim(anyString(), anyString()); + } + + @Test + void complete_withNoNonceAtAllIsRejected() { + when(stateRepo.findById(ConnectState.SINGLETON_ID)) + .thenReturn(Optional.of(openHandshake())); + + assertThat(service.complete(null).phase()).isEqualTo(Phase.REJECTED); + verify(client, never()).connectClaim(anyString(), anyString()); + } + + @Test + void complete_whenSaaSHasNotCommittedTheApprovalKeepsTheHandshake() { + when(stateRepo.findById(ConnectState.SINGLETON_ID)) + .thenReturn(Optional.of(openHandshake())); + when(client.connectClaim(anyString(), anyString())) + .thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.PENDING)); + + assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.PENDING); + verify(stateRepo, never()).delete(any()); + } + + @Test + void complete_whenSaaSIsUnreachableKeepsTheHandshakeForARetry() { + when(stateRepo.findById(ConnectState.SINGLETON_ID)) + .thenReturn(Optional.of(openHandshake())); + when(client.connectClaim(anyString(), anyString())) + .thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.UNAVAILABLE)); + + assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.UNAVAILABLE); + verify(stateRepo, never()).delete(any()); + verifyNoInteractions(credentialStore); + } + + @Test + void complete_whenDeclinedClearsTheHandshake() { + ConnectState state = openHandshake(); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.connectClaim(anyString(), anyString())) + .thenReturn(ConnectClaimResult.of(ConnectClaimOutcome.REJECTED)); + + assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.REJECTED); + verify(stateRepo).delete(state); + verifyNoInteractions(credentialStore); + } + + @Test + void complete_onAnExpiredHandshakeClearsItWithoutClaiming() { + ConnectState state = openHandshake(); + state.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + + assertThat(service.complete(NONCE).phase()).isEqualTo(Phase.EXPIRED); + verify(stateRepo).delete(state); + verify(client, never()).connectClaim(anyString(), anyString()); + } + + @Test + void startReauth_presentsTheCredentialSoSaaSCanPinTheTeam() throws Exception { + when(credentialStore.get()).thenReturn(Optional.of(credential(7L))); + when(client.connectRequest(any(), anyString(), anyString(), anyString(), any())) + .thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL)); + + service.startReauth(fromRequest("https://pdf.example.com")); + + // Sending the credential is what makes the pinning trustworthy: the team comes from + // something only this instance holds. + verify(client) + .connectRequest( + any(), + anyString(), + anyString(), + anyString(), + org.mockito.ArgumentMatchers.argThat( + c -> c != null && "dev".equals(c.getDeviceId()))); + } + + @Test + void startReauth_onAnUnlinkedServerFails() { + assertThat(catchIo(() -> service.startReauth(fromRequest("https://pdf.example.com")))) + .hasMessageContaining("not linked"); + verifyNoInteractions(client); + } + + @Test + void complete_onAConfirmedReauthKeepsTheExistingCredential() { + ConnectState state = openHandshake(); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.connectClaim(anyString(), anyString())) + .thenReturn(new ConnectClaimResult(ConnectClaimOutcome.CONFIRMED, null, null, 7L)); + + ConnectService.ConnectStatus status = service.complete(NONCE); + + assertThat(status.phase()).isEqualTo(Phase.LINKED); + assertThat(status.teamId()).isEqualTo(7L); + // Nothing to store: a second credential would orphan the one we already hold. + verify(credentialStore, never()).save(anyString(), anyString(), any()); + verify(stateRepo).delete(state); + } + + @Test + void status_reportsNothingInFlightWhenThereIsNoHandshakeOrCredential() { + assertThat(service.status().phase()).isEqualTo(Phase.NONE); + } + + @Test + void status_reportsAnExpiredHandshakeRatherThanOfferingAStaleLink() { + ConnectState state = openHandshake(); + state.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + when(stateRepo.findById(ConnectState.SINGLETON_ID)).thenReturn(Optional.of(state)); + + ConnectService.ConnectStatus status = service.status(); + + assertThat(status.phase()).isEqualTo(Phase.EXPIRED); + assertThat(status.authorizeUrl()).isNull(); + } + + @Test + void status_countsDownWhileAHandshakeIsOpen() { + when(stateRepo.findById(ConnectState.SINGLETON_ID)) + .thenReturn(Optional.of(openHandshake())); + + ConnectService.ConnectStatus status = service.status(); + + assertThat(status.phase()).isEqualTo(Phase.PENDING); + assertThat(status.secondsRemaining()).isPositive(); + assertThat(status.authorizeUrl()).isEqualTo("https://app.example.com/link?request=req-1"); + } + + // --------------------------------------------------------------------------------------- + + /** A start with nothing but the reconstructed request URL, as a headless caller would send. */ + private static ConnectService.CallbackHint fromRequest(String derivedBaseUrl) { + return new ConnectService.CallbackHint(null, null, derivedBaseUrl); + } + + private void stubCreate() throws Exception { + // The five-argument overload: a first link passes a null credential rather than none. + when(client.connectRequest(any(), anyString(), anyString(), anyString(), any())) + .thenReturn(new ConnectRequestResult("req-1", 900, AUTHORIZE_URL)); + } + + private static ConnectState openHandshake() { + ConnectState state = new ConnectState(); + state.setId(ConnectState.SINGLETON_ID); + state.setRequestId("req-1"); + state.setNonce(NONCE); + state.setClaimSecret(CLAIM_SECRET); + state.setCallbackUrl("https://pdf.example.com/account-link/callback"); + state.setAuthorizeUrl("https://app.example.com/link?request=req-1"); + state.setCreatedAt(LocalDateTime.now()); + state.setExpiresAt(LocalDateTime.now().plusMinutes(10)); + return state; + } + + private static DeviceCredential credential(Long teamId) { + DeviceCredential credential = new DeviceCredential(); + credential.setDeviceId("dev"); + credential.setDeviceSecret("sec"); + credential.setTeamId(teamId); + credential.setLinkedAt(LocalDateTime.now()); + return credential; + } + + /** Runs a throwing call and returns the exception, so the assertion reads in one line. */ + private static Throwable catchIo(ThrowingCall call) { + try { + call.run(); + throw new AssertionError("expected the call to fail"); + } catch (Exception e) { + return e; + } + } + + private interface ThrowingCall { + void run() throws Exception; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java index 646791f76e..4703c32496 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java @@ -4,14 +4,12 @@ import java.util.List; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -19,25 +17,9 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.proprietary.model.TeamMembership; -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.User; -import stirling.software.proprietary.security.repository.TeamMembershipRepository; -import stirling.software.saas.util.AuthenticationUtils; +import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam; -/** - * Account-link registration surface (combined-billing "Mode A"). - * - *

A self-hosted instance's local backend calls {@code POST /register} with the admin's - * short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain — no new - * auth here). We resolve the caller's team, mint a device credential bound to it, and return the - * secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this - * JWT. - * - *

Whole surface gated behind {@code stirling.billing.account-link.enabled}: off → beans absent → - * 404. Leader-only, and the team is always derived from the caller (never the request body). - */ +/** Team-wide management of linked instances (combined billing). */ @Slf4j @Hidden @RestController @@ -47,25 +29,13 @@ import stirling.software.saas.util.AuthenticationUtils; public class AccountLinkController { private final AccountLinkService service; - private final TeamMembershipRepository memberRepo; - private final UserRepository userRepository; + private final LeaderTeamResolver leaderTeams; - public AccountLinkController( - AccountLinkService service, - TeamMembershipRepository memberRepo, - UserRepository userRepository) { + public AccountLinkController(AccountLinkService service, LeaderTeamResolver leaderTeams) { this.service = service; - this.memberRepo = memberRepo; - this.userRepository = userRepository; + this.leaderTeams = leaderTeams; } - /** Optional display name for the instance (hostname / label). */ - public record RegisterRequest(String name) {} - - /** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */ - public record RegisterResponse( - Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {} - public record InstanceRow( Long instanceId, String deviceId, @@ -74,31 +44,10 @@ public class AccountLinkController { String lastSeenAt, boolean revoked) {} - @PostMapping("/register") - @PreAuthorize("isAuthenticated()") - public ResponseEntity register( - @RequestBody(required = false) RegisterRequest req, Authentication auth) { - LeaderTeam lt = resolveLeaderTeam(auth); - if (lt.error() != null) { - return ResponseEntity.status(lt.error()).build(); - } - String name = req != null ? req.name() : null; - AccountLinkService.RegisteredInstance reg = - service.register(lt.teamId(), lt.userId(), name); - return ResponseEntity.status(HttpStatus.CREATED) - .body( - new RegisterResponse( - reg.instanceId(), - lt.teamId(), - reg.deviceId(), - reg.deviceSecret(), - reg.name())); - } - @GetMapping("/instances") @PreAuthorize("isAuthenticated()") public ResponseEntity> list(Authentication auth) { - LeaderTeam lt = resolveLeaderTeam(auth); + LeaderTeam lt = leaderTeams.resolve(auth); if (lt.error() != null) { return ResponseEntity.status(lt.error()).build(); } @@ -124,38 +73,11 @@ public class AccountLinkController { @PostMapping("/instances/{instanceId}/revoke") @PreAuthorize("isAuthenticated()") public ResponseEntity revoke(@PathVariable Long instanceId, Authentication auth) { - LeaderTeam lt = resolveLeaderTeam(auth); + LeaderTeam lt = leaderTeams.resolve(auth); if (lt.error() != null) { return ResponseEntity.status(lt.error()).build(); } boolean ok = service.revoke(lt.teamId(), instanceId); return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build(); } - - // --------------------------------------------------------------------------------------- - // Helpers — team always derived from the caller; instance linking is a leader (billing) action. - // --------------------------------------------------------------------------------------- - - /** - * Resolved caller team, or an {@code error} status to return (teamId/userId null when error). - */ - private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {} - - private LeaderTeam resolveLeaderTeam(Authentication auth) { - User user; - try { - user = AuthenticationUtils.getCurrentUser(auth, userRepository); - } catch (SecurityException e) { - return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED); - } - List rows = memberRepo.findPrimaryMembership(user.getId()); - if (rows.isEmpty()) { - return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); - } - TeamMembership m = rows.getFirst(); - if (m.getRole() != TeamRole.LEADER) { - return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); - } - return new LeaderTeam(m.getTeam().getId(), user.getId(), null); - } } diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java index c31fc1b03e..fe6a0cc0e6 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkService.java @@ -18,16 +18,7 @@ import org.springframework.transaction.annotation.Transactional; import lombok.extern.slf4j.Slf4j; -/** - * Account-link instance registration + lifecycle (combined-billing "Mode A"). - * - *

Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound - * to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its - * unattended entitlement reads with that credential. - * - *

Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so - * {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404. - */ +/** Account-link instance registration + lifecycle (combined billing). */ @Slf4j @Service @Profile("saas") @@ -80,10 +71,7 @@ public class AccountLinkService { return repo.findByTeamIdOrderByCreatedAtDesc(teamId); } - /** - * Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by - * a different team (so a caller can never revoke another team's instance). Idempotent. - */ + /** Revokes an instance iff it belongs to {@code teamId}. */ @Transactional public boolean revoke(Long teamId, Long instanceId) { Optional found = repo.findById(instanceId); @@ -99,13 +87,30 @@ public class AccountLinkService { return true; } + /** + * Resolves an active instance from a device credential, or empty if it does not authenticate. + */ + @Transactional(readOnly = true) + public Optional resolveActiveInstance(String deviceId, String deviceSecret) { + if (deviceId == null || deviceSecret == null) { + return Optional.empty(); + } + return repo.findByDeviceIdAndRevokedAtIsNull(deviceId) + .filter( + instance -> + MessageDigest.isEqual( + sha256Hex(deviceSecret).getBytes(StandardCharsets.UTF_8), + instance.getDeviceSecretHash() + .getBytes(StandardCharsets.UTF_8))); + } + private String randomSecret() { byte[] buf = new byte[SECRET_BYTES]; random.nextBytes(buf); return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); } - /** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */ + /** SHA-256 hex of a value. */ static String sha256Hex(String value) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectController.java new file mode 100644 index 0000000000..95f2ba5de7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectController.java @@ -0,0 +1,277 @@ +package stirling.software.saas.accountlink; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.saas.accountlink.LeaderTeamResolver.LeaderTeam; + +/** Browser-mediated "connect this server" handshake. */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/account-link/connect") +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class ConnectController { + + /** Same headers the device-credential filter uses on the {@code /api/v1/instance} paths. */ + static final String HEADER_DEVICE_ID = "X-Device-Id"; + + static final String HEADER_DEVICE_SECRET = "X-Device-Secret"; + + /** Frontend route serving the approval page. */ + static final String LINK_PATH = "/link"; + + private final ConnectRequestService service; + private final LeaderTeamResolver leaderTeams; + private final AccountLinkService accountLinkService; + private final ApplicationProperties applicationProperties; + + public ConnectController( + ConnectRequestService service, + LeaderTeamResolver leaderTeams, + AccountLinkService accountLinkService, + ApplicationProperties applicationProperties) { + this.service = service; + this.leaderTeams = leaderTeams; + this.accountLinkService = accountLinkService; + this.applicationProperties = applicationProperties; + } + + /** Sent by the instance's own backend, before it holds any credential. */ + public record CreateBody(String name, String callbackUrl, String nonce, String claimSecret) {} + + /** {@code authorizeUrl} is where the instance should send its admin. */ + public record CreateResponse(String requestId, int expiresIn, String authorizeUrl) {} + + /** What the approval page renders. */ + public record ViewResponse( + String requestId, + String name, + String callbackOrigin, + boolean insecureTransport, + String mode, + String status) {} + + /** Where the approver's browser goes next, and the correlator the instance is waiting on. */ + public record ApproveResponse(String callbackUrl, String nonce) {} + + public record ClaimBody(String requestId, String claimSecret) {} + + public record ClaimResponse(String deviceId, String deviceSecret, Long teamId) {} + + /** Opens a handshake. */ + @PostMapping("/request") + public ResponseEntity request( + @RequestBody(required = false) CreateBody body, HttpServletRequest http) { + if (body == null) { + return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST")); + } + String deviceId = http.getHeader(HEADER_DEVICE_ID); + String deviceSecret = http.getHeader(HEADER_DEVICE_SECRET); + boolean reauthRequested = deviceId != null || deviceSecret != null; + + ConnectRequestService.CreateResult result; + if (reauthRequested) { + Long pinnedTeamId = + accountLinkService + .resolveActiveInstance(deviceId, deviceSecret) + .map(LinkedInstance::getTeamId) + .orElse(null); + result = + service.createReauth( + body.name(), + body.callbackUrl(), + body.nonce(), + body.claimSecret(), + clientIp(http), + pinnedTeamId); + } else { + result = + service.create( + body.name(), + body.callbackUrl(), + body.nonce(), + body.claimSecret(), + clientIp(http)); + } + if (result.isRejected()) { + return switch (result.rejection()) { + case RATE_LIMITED -> + ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(Map.of("error", "RATE_LIMITED")); + case BAD_CALLBACK -> + ResponseEntity.badRequest().body(Map.of("error", "BAD_CALLBACK")); + case BAD_NONCE -> ResponseEntity.badRequest().body(Map.of("error", "BAD_NONCE")); + case BAD_SECRET -> ResponseEntity.badRequest().body(Map.of("error", "BAD_SECRET")); + // A credential was offered and did not authenticate. Same answer as any other bad + // credential, and deliberately not distinguishable from "revoked". + case NOT_LINKED -> + ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "NOT_LINKED")); + }; + } + return ResponseEntity.status(HttpStatus.CREATED) + .body( + new CreateResponse( + result.requestId(), + result.expiresInSeconds(), + authorizeUrl(result.requestId(), http))); + } + + /** + * Where to send the admin to approve a handshake. {@code system.frontendUrl} is the web app's + * own base URL, including any base path; without it the API's origin has to serve the app too. + */ + private String authorizeUrl(String requestId, HttpServletRequest http) { + String frontendUrl = applicationProperties.getSystem().getFrontendUrl(); + String base = + frontendUrl != null && !frontendUrl.isBlank() + ? frontendUrl.strip().replaceAll("/+$", "") + : requestOrigin(http); + return base + + LINK_PATH + + "?request=" + + URLEncoder.encode(requestId, StandardCharsets.UTF_8); + } + + /** Scheme, host and context path as the browser reached us, honouring a reverse proxy. */ + private static String requestOrigin(HttpServletRequest request) { + String proto = firstHop(request.getHeader("X-Forwarded-Proto")); + String host = firstHop(request.getHeader("X-Forwarded-Host")); + String scheme = proto != null ? proto : request.getScheme(); + // A forwarded host already carries its own port, if it needs one. + String hostPort = + host != null + ? host + : Origins.hostPort( + scheme, request.getServerName(), request.getServerPort()); + String context = request.getContextPath() == null ? "" : request.getContextPath(); + return scheme + "://" + hostPort + context; + } + + private static String firstHop(String headerValue) { + if (headerValue == null || headerValue.isBlank()) { + return null; + } + String first = headerValue.split(",")[0].strip(); + return first.isEmpty() ? null : first; + } + + /** Detail for the approval page. */ + @GetMapping("/{requestId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity view(@PathVariable String requestId) { + return service.lookup(requestId) + .map( + v -> + ResponseEntity.ok( + new ViewResponse( + v.requestId(), + v.name(), + v.callbackOrigin(), + v.insecureTransport(), + v.mode().name(), + v.status().name()))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + /** Approves a handshake. */ + @PostMapping("/{requestId}/approve") + @PreAuthorize("isAuthenticated()") + public ResponseEntity approve(@PathVariable String requestId, Authentication auth) { + Optional view = service.lookup(requestId); + if (view.isEmpty()) { + return ResponseEntity.notFound().build(); + } + boolean reauth = view.get().mode() == ConnectRequest.Mode.REAUTH; + LeaderTeam lt = reauth ? leaderTeams.resolveMember(auth) : leaderTeams.resolve(auth); + if (lt.isError()) { + return ResponseEntity.status(lt.error()).build(); + } + ConnectRequestService.ApproveResult result = + service.approve(requestId, lt.teamId(), lt.userId()); + if (result.isRejected()) { + return switch (result.rejection()) { + // Named separately so the page can say "you are signed in to a different account" + // rather than implying the request itself was bad. + case WRONG_TEAM -> + ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "WRONG_TEAM")); + case UNAVAILABLE -> ResponseEntity.notFound().build(); + }; + } + return ResponseEntity.ok( + new ApproveResponse(result.target().callbackUrl(), result.target().nonce())); + } + + @PostMapping("/{requestId}/deny") + @PreAuthorize("isAuthenticated()") + public ResponseEntity deny(@PathVariable String requestId, Authentication auth) { + LeaderTeam lt = leaderTeams.resolve(auth); + if (lt.isError()) { + return ResponseEntity.status(lt.error()).build(); + } + return service.deny(requestId) + ? ResponseEntity.noContent().build() + : ResponseEntity.notFound().build(); + } + + /** Collects the device credential. */ + @PostMapping("/claim") + public ResponseEntity claim(@RequestBody(required = false) ClaimBody body) { + if (body == null) { + return ResponseEntity.badRequest().body(Map.of("error", "BAD_REQUEST")); + } + ConnectRequestService.ClaimResult result = + service.claim(body.requestId(), body.claimSecret()); + return switch (result.outcome()) { + case GRANTED -> + ResponseEntity.ok( + new ClaimResponse( + result.deviceId(), result.deviceSecret(), result.teamId())); + // A re-authentication carries no credential: the instance already has one. It only + // needs to know the browser leg succeeded, and which team it was confirmed against. + case CONFIRMED -> + ResponseEntity.ok(Map.of("status", "confirmed", "teamId", result.teamId())); + case PENDING -> + ResponseEntity.status(HttpStatus.ACCEPTED).body(Map.of("status", "pending")); + case REJECTED -> ResponseEntity.badRequest().body(Map.of("error", "CONNECT_REJECTED")); + }; + } + + /** + * Source address for the creation cap. + * + *

Deliberately not reading {@code X-Forwarded-For}: the caller sets it, so keying a cap on + * it lets one rotate fake addresses and have no cap at all. {@code + * server.forward-headers-strategy} is NATIVE, so the container has already resolved the real + * client from trusted proxies. + */ + private static String clientIp(HttpServletRequest request) { + String remote = request.getRemoteAddr(); + return remote == null || remote.length() <= 45 ? remote : remote.substring(0, 45); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequest.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequest.java new file mode 100644 index 0000000000..f9c204eef3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequest.java @@ -0,0 +1,103 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** One in-flight "connect this server" handshake. Short lived and single use. */ +@Entity +@Table( + name = "account_link_connect_request", + indexes = @Index(name = "idx_alcr_ip_created", columnList = "requester_ip,created_at")) +@Getter +@Setter +@NoArgsConstructor +public class ConnectRequest { + + public enum Mode { + LINK, + REAUTH + } + + public enum Status { + PENDING, + APPROVED, + DENIED, + CONSUMED + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "request_id", nullable = false, unique = true, length = 64) + private String requestId; + + @Column(name = "name", length = 255) + private String name; + + /** + * Read back from here on approval, never from the request: that is what stops an open redirect. + */ + @Column(name = "callback_url", nullable = false, length = 2048) + private String callbackUrl; + + @Column(name = "callback_origin", nullable = false, length = 255) + private String callbackOrigin; + + @Column(name = "nonce", nullable = false, length = 128) + private String nonce; + + /** SHA-256; the secret itself is never stored. */ + @Column(name = "claim_secret_hash", nullable = false, length = 64) + private String claimSecretHash; + + @Enumerated(EnumType.STRING) + @Column(name = "mode", nullable = false, length = 16) + private Mode mode = Mode.LINK; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private Status status = Status.PENDING; + + /** LINK: set on approval. REAUTH: pinned at creation, so approval can only confirm it. */ + @Column(name = "team_id") + private Long teamId; + + @Column(name = "approved_by_user_id") + private Long approvedByUserId; + + @Column(name = "requester_ip", length = 45) + private String requesterIp; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @Column(name = "consumed_at") + private LocalDateTime consumedAt; + + public boolean isExpired(LocalDateTime now) { + return expiresAt != null && expiresAt.isBefore(now); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestCleanupService.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestCleanupService.java new file mode 100644 index 0000000000..7712ee297a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestCleanupService.java @@ -0,0 +1,47 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Removes connect requests that are past use. + * + *

Needed rather than merely tidy: anyone can create a row on {@code POST /connect/request}, and + * nothing else deletes one. Requests hold a callback URL and the requester's address, so they are + * swept soon after expiry rather than kept. + */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +@RequiredArgsConstructor +public class ConnectRequestCleanupService { + + /** Long enough to answer "what happened to my link?" the next morning, and no longer. */ + private static final int RETAIN_HOURS = 24; + + private final ConnectRequestRepository repo; + + @Scheduled(cron = "0 30 3 * * *") + @Transactional + public void purgeExpired() { + try { + LocalDateTime cutoff = LocalDateTime.now().minusHours(RETAIN_HOURS); + int deleted = repo.deleteByExpiresAtBefore(cutoff); + if (deleted > 0) { + log.info("Account-link connect: purged {} expired requests", deleted); + } + } catch (Exception e) { + // A failed sweep must not take the scheduler down; the next run retries. + log.error("Account-link connect: purge failed", e); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestRepository.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestRepository.java new file mode 100644 index 0000000000..6c5dec098b --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestRepository.java @@ -0,0 +1,28 @@ +package stirling.software.saas.accountlink; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; + +/** Data access for {@link ConnectRequest}. */ +public interface ConnectRequestRepository extends JpaRepository { + + Optional findByRequestId(String requestId); + + /** Row-locking read used by approve, deny and claim. */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM ConnectRequest r WHERE r.requestId = :requestId") + Optional findByRequestIdForUpdate(@Param("requestId") String requestId); + + /** Backs the per-IP creation cap, since creating a request needs no authentication. */ + long countByRequesterIpAndCreatedAtAfter(String requesterIp, LocalDateTime after); + + /** Sweeps rows past use, whatever they settled as. Anyone can create these. */ + int deleteByExpiresAtBefore(LocalDateTime cutoff); +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestService.java b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestService.java new file mode 100644 index 0000000000..64abc12fa6 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/ConnectRequestService.java @@ -0,0 +1,391 @@ +package stirling.software.saas.accountlink; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +/** The "connect this server" handshake, SaaS side. */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class ConnectRequestService { + + /** + * Long enough for the approver to sign in, pick the right account and read the origin. Sized + * for the slowest real route: signing up, waiting for a confirmation email, and coming back. + */ + static final int LIFETIME_MINUTES = 30; + + /** Creating a request needs no authentication, so the only brake is per-source volume. */ + static final int MAX_REQUESTS_PER_IP = 10; + + private static final int REQUEST_ID_BYTES = 32; + private static final int MAX_NONCE_LENGTH = 128; + private static final int MAX_CALLBACK_LENGTH = 2048; + private static final int MAX_NAME_LENGTH = 255; + + private final ConnectRequestRepository repo; + private final AccountLinkService accountLinkService; + private final SecureRandom random = new SecureRandom(); + + public ConnectRequestService( + ConnectRequestRepository repo, AccountLinkService accountLinkService) { + this.repo = repo; + this.accountLinkService = accountLinkService; + } + + /** Rejected creation attempts, so the controller can pick a status without parsing messages. */ + public enum CreateRejection { + BAD_CALLBACK, + BAD_NONCE, + BAD_SECRET, + RATE_LIMITED, + /** + * A re-authentication was asked for by something that could not prove it is a linked + * instance. + */ + NOT_LINKED + } + + /** Either a created request id, or the reason we would not create one. */ + public record CreateResult(String requestId, int expiresInSeconds, CreateRejection rejection) { + static CreateResult ok(String requestId, int expiresInSeconds) { + return new CreateResult(requestId, expiresInSeconds, null); + } + + static CreateResult rejected(CreateRejection rejection) { + return new CreateResult(null, 0, rejection); + } + + public boolean isRejected() { + return rejection != null; + } + } + + /** What the approval page shows. */ + public record ConnectView( + String requestId, + String name, + String callbackOrigin, + boolean insecureTransport, + ConnectRequest.Mode mode, + ConnectRequest.Status status) {} + + /** Where to send the browser once approved, plus the correlator the instance is expecting. */ + public record ApprovalTarget(String callbackUrl, String nonce) {} + + public enum ClaimOutcome { + /** Approved and collected; {@code credential} is populated. */ + GRANTED, + /** A re-authentication was approved. */ + CONFIRMED, + /** Still waiting on a human. */ + PENDING, + /** Declined, expired, unknown, already collected, or a bad claim secret. */ + REJECTED + } + + public record ClaimResult( + ClaimOutcome outcome, String deviceId, String deviceSecret, Long teamId) { + static ClaimResult of(ClaimOutcome outcome) { + return new ClaimResult(outcome, null, null, null); + } + } + + /** Records a handshake on behalf of an instance that has no credential yet. */ + @Transactional + public CreateResult create( + String name, String callbackUrl, String nonce, String claimSecret, String requesterIp) { + return create(name, callbackUrl, nonce, claimSecret, requesterIp, null); + } + + /** + * As {@link #create}, but for an instance that is already linked and only needs its admin's + * browser signed in again. + */ + @Transactional + public CreateResult createReauth( + String name, + String callbackUrl, + String nonce, + String claimSecret, + String requesterIp, + Long pinnedTeamId) { + if (pinnedTeamId == null) { + return CreateResult.rejected(CreateRejection.NOT_LINKED); + } + return create(name, callbackUrl, nonce, claimSecret, requesterIp, pinnedTeamId); + } + + private CreateResult create( + String name, + String callbackUrl, + String nonce, + String claimSecret, + String requesterIp, + Long pinnedTeamId) { + if (nonce == null || nonce.isBlank() || nonce.length() > MAX_NONCE_LENGTH) { + return CreateResult.rejected(CreateRejection.BAD_NONCE); + } + if (claimSecret == null || claimSecret.isBlank()) { + return CreateResult.rejected(CreateRejection.BAD_SECRET); + } + Optional parsed = validateCallback(callbackUrl); + if (parsed.isEmpty()) { + return CreateResult.rejected(CreateRejection.BAD_CALLBACK); + } + LocalDateTime now = LocalDateTime.now(); + if (requesterIp != null + && repo.countByRequesterIpAndCreatedAtAfter(requesterIp, now.minusHours(1)) + >= MAX_REQUESTS_PER_IP) { + return CreateResult.rejected(CreateRejection.RATE_LIMITED); + } + + URI uri = parsed.get(); + ConnectRequest request = new ConnectRequest(); + request.setRequestId(randomToken()); + request.setName(trim(name, MAX_NAME_LENGTH)); + request.setCallbackUrl(uri.toString()); + request.setCallbackOrigin(originOf(uri)); + request.setNonce(nonce); + request.setClaimSecretHash(sha256Hex(claimSecret)); + request.setStatus(ConnectRequest.Status.PENDING); + request.setMode( + pinnedTeamId == null ? ConnectRequest.Mode.LINK : ConnectRequest.Mode.REAUTH); + request.setTeamId(pinnedTeamId); + request.setRequesterIp(requesterIp); + request.setExpiresAt(now.plusMinutes(LIFETIME_MINUTES)); + repo.save(request); + + // Never log the nonce or the claim secret; both are live. The request id is the safe + // handle for correlating a support request against this row. + log.info( + "Account-link connect: request {} created for origin {}", + request.getRequestId(), + request.getCallbackOrigin()); + return CreateResult.ok(request.getRequestId(), LIFETIME_MINUTES * 60); + } + + /** The approver's view of a handshake. */ + @Transactional(readOnly = true) + public Optional lookup(String requestId) { + return repo.findByRequestId(requestId) + .filter(r -> !r.isExpired(LocalDateTime.now())) + .map( + r -> + new ConnectView( + r.getRequestId(), + r.getName(), + r.getCallbackOrigin(), + !"https".equals(schemeOf(r.getCallbackOrigin())), + r.getMode(), + r.getStatus())); + } + + /** Why an approval was refused, so the page can say something useful. */ + public enum ApproveRejection { + /** Unknown, expired, or already settled. */ + UNAVAILABLE, + /** The approver's team is not the team this server already belongs to. */ + WRONG_TEAM + } + + public record ApproveResult(ApprovalTarget target, ApproveRejection rejection) { + public boolean isRejected() { + return target == null; + } + } + + /** Binds a pending handshake to the approver's team and returns where to send them next. */ + @Transactional + public ApproveResult approve(String requestId, Long teamId, Long userId) { + Optional found = repo.findByRequestIdForUpdate(requestId); + if (found.isEmpty()) { + return new ApproveResult(null, ApproveRejection.UNAVAILABLE); + } + ConnectRequest request = found.get(); + LocalDateTime now = LocalDateTime.now(); + if (request.isExpired(now) || request.getStatus() != ConnectRequest.Status.PENDING) { + return new ApproveResult(null, ApproveRejection.UNAVAILABLE); + } + Long pinned = request.getTeamId(); + if (pinned != null && !pinned.equals(teamId)) { + log.warn( + "Account-link connect: request {} approved by team {} but is pinned to team {};" + + " refusing", + requestId, + teamId, + pinned); + return new ApproveResult(null, ApproveRejection.WRONG_TEAM); + } + request.setStatus(ConnectRequest.Status.APPROVED); + request.setTeamId(teamId); + request.setApprovedByUserId(userId); + request.setApprovedAt(now); + repo.save(request); + log.info( + "Account-link connect: request {} approved for team {} ({})", + requestId, + teamId, + request.getMode()); + return new ApproveResult( + new ApprovalTarget(request.getCallbackUrl(), request.getNonce()), null); + } + + /** Declines a pending handshake. */ + @Transactional + public boolean deny(String requestId) { + Optional found = repo.findByRequestIdForUpdate(requestId); + if (found.isEmpty()) { + return false; + } + ConnectRequest request = found.get(); + if (request.getStatus() != ConnectRequest.Status.PENDING) { + return false; + } + request.setStatus(ConnectRequest.Status.DENIED); + repo.save(request); + log.info("Account-link connect: request {} denied", requestId); + return true; + } + + /** Collects the device credential for an approved handshake. */ + @Transactional + public ClaimResult claim(String requestId, String claimSecret) { + if (requestId == null || claimSecret == null) { + return ClaimResult.of(ClaimOutcome.REJECTED); + } + Optional found = repo.findByRequestIdForUpdate(requestId); + if (found.isEmpty()) { + return ClaimResult.of(ClaimOutcome.REJECTED); + } + ConnectRequest request = found.get(); + if (!secretMatches(claimSecret, request.getClaimSecretHash())) { + // Same answer as an unknown id: a caller probing ids learns nothing from the + // difference. + log.warn("Account-link connect: claim for request {} had a bad secret", requestId); + return ClaimResult.of(ClaimOutcome.REJECTED); + } + if (request.isExpired(LocalDateTime.now())) { + return ClaimResult.of(ClaimOutcome.REJECTED); + } + return switch (request.getStatus()) { + case PENDING -> ClaimResult.of(ClaimOutcome.PENDING); + case APPROVED -> mint(request); + case DENIED, CONSUMED -> ClaimResult.of(ClaimOutcome.REJECTED); + }; + } + + /** Settles an approved handshake. */ + private ClaimResult mint(ConnectRequest request) { + if (request.getMode() == ConnectRequest.Mode.REAUTH) { + request.setStatus(ConnectRequest.Status.CONSUMED); + request.setConsumedAt(LocalDateTime.now()); + repo.save(request); + log.info( + "Account-link connect: request {} re-authenticated for team {}", + request.getRequestId(), + request.getTeamId()); + return new ClaimResult(ClaimOutcome.CONFIRMED, null, null, request.getTeamId()); + } + AccountLinkService.RegisteredInstance registered = + accountLinkService.register( + request.getTeamId(), request.getApprovedByUserId(), request.getName()); + request.setStatus(ConnectRequest.Status.CONSUMED); + request.setConsumedAt(LocalDateTime.now()); + repo.save(request); + log.info( + "Account-link connect: request {} claimed, instance {} bound to team {}", + request.getRequestId(), + registered.instanceId(), + request.getTeamId()); + return new ClaimResult( + ClaimOutcome.GRANTED, + registered.deviceId(), + registered.deviceSecret(), + request.getTeamId()); + } + + /** Absolute http(s) URL, with a host, no credentials and no fragment of its own. */ + static Optional validateCallback(String candidate) { + if (candidate == null || candidate.isBlank() || candidate.length() > MAX_CALLBACK_LENGTH) { + return Optional.empty(); + } + URI uri; + try { + uri = new URI(candidate.strip()); + } catch (URISyntaxException e) { + return Optional.empty(); + } + if (!uri.isAbsolute() || uri.getScheme() == null) { + return Optional.empty(); + } + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + return Optional.empty(); + } + if (uri.getHost() == null || uri.getHost().isBlank()) { + return Optional.empty(); + } + if (uri.getUserInfo() != null || uri.getFragment() != null) { + return Optional.empty(); + } + return Optional.of(uri); + } + + /** Scheme, host and port, with the default port omitted so origins compare cleanly. */ + static String originOf(URI uri) { + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + return scheme + "://" + Origins.hostPort(scheme, uri.getHost(), uri.getPort()); + } + + private static String schemeOf(String origin) { + int sep = origin.indexOf("://"); + return sep < 0 ? "" : origin.substring(0, sep); + } + + private static String trim(String value, int max) { + if (value == null) { + return null; + } + String stripped = value.strip(); + if (stripped.isEmpty()) { + return null; + } + return stripped.length() <= max ? stripped : stripped.substring(0, max); + } + + private String randomToken() { + byte[] buf = new byte[REQUEST_ID_BYTES]; + random.nextBytes(buf); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + } + + /** Constant-time comparison so a claim cannot be brute-forced a byte at a time. */ + private static boolean secretMatches(String candidate, String expectedHash) { + if (expectedHash == null) { + return false; + } + return MessageDigest.isEqual( + sha256Hex(candidate).getBytes(StandardCharsets.UTF_8), + expectedHash.getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256Hex(String value) { + return AccountLinkService.sha256Hex(value); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java index a2fd13a095..17cb340d60 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/DeviceCredentialAuthenticationFilter.java @@ -19,7 +19,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; /** - * Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A"). + * Authenticates a linked self-hosted instance by its device credential (combined billing). * *

Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link * LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java index 7c98d5e59b..9c313a1dfa 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java @@ -32,9 +32,9 @@ import stirling.software.saas.payg.policy.PricingPolicy; import stirling.software.saas.payg.policy.PricingPolicyService; /** - * Instance-facing surface (combined-billing "Mode A"), authenticated by the device - * credential — not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device - * credential is scoped here and nowhere else. + * Instance-facing surface (combined billing), authenticated by the device credential — not a + * user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device credential is scoped + * here and nowhere else. * *

{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid * device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LeaderTeamResolver.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LeaderTeamResolver.java new file mode 100644 index 0000000000..7118562a28 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LeaderTeamResolver.java @@ -0,0 +1,68 @@ +package stirling.software.saas.accountlink; + +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** Who is allowed to bind a self-hosted instance to a team. */ +@Component +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class LeaderTeamResolver { + + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + + public LeaderTeamResolver(TeamMembershipRepository memberRepo, UserRepository userRepository) { + this.memberRepo = memberRepo; + this.userRepository = userRepository; + } + + /** + * Resolved caller, or an {@code error} status to return ({@code teamId}/{@code userId} null). + */ + public record LeaderTeam(Long teamId, Long userId, HttpStatus error) { + public boolean isError() { + return error != null; + } + } + + /** Caller must lead their team. */ + public LeaderTeam resolve(Authentication auth) { + return resolve(auth, true); + } + + /** Caller need only belong to a team. */ + public LeaderTeam resolveMember(Authentication auth) { + return resolve(auth, false); + } + + private LeaderTeam resolve(Authentication auth, boolean requireLeader) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED); + } + List rows = memberRepo.findPrimaryMembership(user.getId()); + if (rows.isEmpty()) { + return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); + } + TeamMembership membership = rows.getFirst(); + if (requireLeader && membership.getRole() != TeamRole.LEADER) { + return new LeaderTeam(null, null, HttpStatus.FORBIDDEN); + } + return new LeaderTeam(membership.getTeam().getId(), user.getId(), null); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java index ec92c97758..f460e82d5b 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstance.java @@ -16,7 +16,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; /** - * One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code + * One self-hosted instance that has linked a SaaS account (combined billing, {@code * linked_instance}, V22). * *

Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java index af883bd66a..e393a162db 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/LinkedInstanceAuthenticationToken.java @@ -6,7 +6,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; /** - * Authentication for a linked self-hosted instance (combined-billing "Mode A"). + * Authentication for a linked self-hosted instance (combined billing). * *

Deliberately not a user: the principal is the instance ({@code instanceId}) bound to * a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/Origins.java b/app/saas/src/main/java/stirling/software/saas/accountlink/Origins.java new file mode 100644 index 0000000000..629e0f7fc1 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/Origins.java @@ -0,0 +1,22 @@ +package stirling.software.saas.accountlink; + +/** + * Origin formatting shared by the connect handshake. + * + *

One place on purpose: the origin a request arrives on and the origin parsed out of a callback + * URL are compared with each other, so if either side stopped omitting the default port the + * comparison would start failing quietly. + */ +final class Origins { + + private Origins() {} + + /** {@code host} or {@code host:port}, dropping a port that is the scheme's default. */ + static String hostPort(String scheme, String host, int port) { + boolean isDefault = + port <= 0 + || ("http".equals(scheme) && port == 80) + || ("https".equals(scheme) && port == 443); + return isDefault ? host : host + ":" + port; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java index dffd1eccf1..7853c56856 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasSchemaOwnership.java @@ -41,6 +41,7 @@ public final class SaasSchemaOwnership { */ public static final Set MIGRATION_OWNED = Set.of( + "account_link_connect_request", "ai_create_sessions", "audit_events", "authorities", @@ -78,6 +79,7 @@ public final class SaasSchemaOwnership { */ public static final Set HIBERNATE_MANAGED = Set.of( + "account_link_connect_state", "account_link_device_credential", "account_link_metered_signature", "account_link_sync_state", diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java index c672540870..67e9c581d7 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java @@ -18,12 +18,12 @@ import stirling.software.saas.payg.model.ProcessType; import stirling.software.saas.payg.repository.PaygInstanceUsageRepository; /** - * Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a - * monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the - * last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, - * ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and - * tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The - * cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}. + * Ingests a linked instance's daily usage sync (combined billing). The instance reports a monotonic + * cumulative unit total per {@link BillingCategory}; we bill only the delta since the last sync via + * {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, ledger DEBIT, + * Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and tamper-evident (a + * backwards total is refused; a monotonic {@code syncSeq} dedups replays). The cap is enforced at + * the instance gate, not here. Gated behind {@code account-link.enabled}. */ @Slf4j @Service diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java index 45a6435746..345cf46b75 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java @@ -19,9 +19,9 @@ import lombok.Setter; /** * Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team, - * billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative - * unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via - * the standard charge path and advances this row. {@code lastSyncSeq} dedups replays. + * billing period, category)} (combined billing). The instance reports monotonic cumulative unit + * totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via the + * standard charge path and advances this row. {@code lastSyncSeq} dedups replays. */ @Entity @Table( diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index 36f3a4c06e..ea1c9bca9b 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -16,6 +16,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.config.Customizer; @@ -71,6 +72,7 @@ public class SupabaseSecurityConfig { private final SaasTeamService saasTeamService; private final ApplicationProperties applicationProperties; private final ApiKeyAuthenticationService apiKeyAuthenticationService; + private final Environment environment; @Value("${app.supabase.issuer:}") private String issuer; @@ -105,6 +107,17 @@ public class SupabaseSecurityConfig { .permitAll() .requestMatchers("/actuator/health", "/api/v1/config/**") .permitAll() + // Account-link connect handshake: an instance calls these + // before it holds any credential, so there is nothing to + // authenticate with yet. Neither grants anything on its + // own — /request records an intent a human must approve, + // and /claim requires a secret only the instance that + // created the request has ever held. + .requestMatchers( + HttpMethod.POST, + "/api/v1/account-link/connect/request", + "/api/v1/account-link/connect/claim") + .permitAll() .requestMatchers( req -> RequestUriUtils.isStaticResource( @@ -144,7 +157,7 @@ public class SupabaseSecurityConfig { SupabaseSecurityConfig ::toAuthentication))); - // Device-credential auth for linked self-hosted instances (combined-billing Mode A). + // Device-credential auth for linked self-hosted instances (combined billing). // The filter bean exists only when stirling.billing.account-link.enabled=true; when off it // is absent here, so the instance surface cannot authenticate at all until release. DeviceCredentialAuthenticationFilter deviceFilter = @@ -268,6 +281,28 @@ public class SupabaseSecurityConfig { } } + /** + * Loopback on any port, as Spring origin patterns. Only added outside production; see {@link + * #corsConfigurationSource()}. + */ + private static final List LOOPBACK_ANY_PORT = + List.of("http://localhost:[*]", "http://127.0.0.1:[*]"); + + /** + * Profiles that mean "a developer's machine or a preview environment", never the production + * deployment. Production runs the bare {@code saas} profile. + */ + private static final List NON_PRODUCTION_PROFILES = List.of("dev", "staging", "local"); + + private boolean isNonProduction() { + for (String profile : environment.getActiveProfiles()) { + if (NON_PRODUCTION_PROFILES.contains(profile)) { + return true; + } + } + return false; + } + @Bean CorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg = new CorsConfiguration(); @@ -297,7 +332,23 @@ public class SupabaseSecurityConfig { origins.add(desktopOrigin); } } - if (origins.stream().anyMatch(o -> o.contains("*"))) { + // Outside production, allow loopback on ANY port. Several dev servers run side by side + // (editor, saas web app, one per flavour under test) and their ports move, so pinning a + // list means every new local environment shows up as an opaque CORS failure. Unlike a + // wildcard subdomain, a wildcard port on loopback cannot be taken over: nothing but this + // machine can answer on it, so there is no lapsed-DNS or abandoned-vhost risk. Absent in + // production, where the profile check below is false. + if (!operatorOverride && isNonProduction()) { + origins.addAll(LOOPBACK_ANY_PORT); + log.info( + "Non-production profile active: allowing loopback CORS origins on any port {}", + LOOPBACK_ANY_PORT); + } + // Loopback port wildcards are exempt: the warning below is about hostname takeover, which + // does not apply to an origin only this machine can serve. + if (origins.stream() + .filter(o -> !LOOPBACK_ANY_PORT.contains(o)) + .anyMatch(o -> o.contains("*"))) { log.warn( "CORS origins contain a wildcard paired with allowCredentials=true: {}." + " Wildcard subdomains can be taken over by an attacker (lapsed DNS," diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index 0038cca42a..d81e0d9ee7 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -519,8 +519,8 @@ public class SaasTeamService { * membership and its wallet) rather than deleting it, so a plain team is never orphaned. The * only real hazard is a team the user is the last leader of that still carries live * billing: an active paid/PAYG subscription, or a non-revoked linked self-hosted instance - * ("Mode A"). Those block the join until the plan is cancelled / leadership transferred / - * instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks. + * (combined billing). Those block the join until the plan is cancelled / leadership transferred + * / instances revoked. An unpaid, unlinked team (personal or shared) no longer blocks. * *

The home team and the team being joined are excluded: neither is left by the join (home is * parked, the joined team is kept), so their live billing cannot be stranded. diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java index e12888edb3..b9c6f22305 100644 --- a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java @@ -1,6 +1,7 @@ package stirling.software.saas.accountlink; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -23,8 +24,7 @@ import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamMembershipRepository; -import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest; -import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse; +import stirling.software.saas.accountlink.AccountLinkController.InstanceRow; import stirling.software.saas.util.AuthenticationUtils; /** @@ -44,20 +44,27 @@ class AccountLinkControllerTest { @BeforeEach void setUp() { - controller = new AccountLinkController(service, memberRepo, userRepository); + // Real resolver over the mocked repositories: the leader ladder moved into + // LeaderTeamResolver, and these tests are still asserting that ladder's behaviour + // through the controller. + controller = + new AccountLinkController( + service, new LeaderTeamResolver(memberRepo, userRepository)); auth = new AnonymousAuthenticationToken( "k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER"))); } + // The leader ladder used to be asserted through POST /register, which has been removed along + // with the JWT relay. It is exercised through /instances instead: same resolver, same rungs. + @Test - void register_unauthenticated_returns401() { + void list_unauthenticated_returns401() { try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) .thenThrow(new SecurityException("not authenticated")); - ResponseEntity resp = - controller.register(new RegisterRequest("host"), auth); + ResponseEntity> resp = controller.list(auth); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); verifyNoInteractions(service); @@ -65,14 +72,14 @@ class AccountLinkControllerTest { } @Test - void register_noMembership_returns403() { + void list_noMembership_returns403() { User user = mockUser(42L); try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) .thenReturn(user); when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of()); - ResponseEntity resp = controller.register(null, auth); + ResponseEntity> resp = controller.list(auth); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); verifyNoInteractions(service); @@ -80,7 +87,7 @@ class AccountLinkControllerTest { } @Test - void register_nonLeader_returns403() { + void list_nonLeader_returns403() { User user = mockUser(42L); TeamMembership member = membership(7L, TeamRole.MEMBER); try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { @@ -88,7 +95,7 @@ class AccountLinkControllerTest { .thenReturn(user); when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(member)); - ResponseEntity resp = controller.register(null, auth); + ResponseEntity> resp = controller.list(auth); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); verifyNoInteractions(service); @@ -96,27 +103,20 @@ class AccountLinkControllerTest { } @Test - void register_leader_mintsCredentialForCallerTeam() { + void list_leader_readsOnlyTheCallersTeam() { User user = mockUser(42L); TeamMembership leader = membership(7L, TeamRole.LEADER); - when(service.register(7L, 42L, "host")) - .thenReturn( - new AccountLinkService.RegisteredInstance(99L, "dev-x", "sec-x", "host")); + when(service.list(7L)).thenReturn(List.of()); try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) { mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) .thenReturn(user); when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader)); - ResponseEntity resp = - controller.register(new RegisterRequest("host"), auth); + ResponseEntity> resp = controller.list(auth); - assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); - RegisterResponse body = resp.getBody(); - assertThat(body).isNotNull(); - // Team comes from the caller's membership and is surfaced in the response. - assertThat(body.teamId()).isEqualTo(7L); - assertThat(body.instanceId()).isEqualTo(99L); - assertThat(body.deviceSecret()).isEqualTo("sec-x"); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + // The team comes from the caller's membership, never from the request. + verify(service).list(7L); } } diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectControllerTest.java new file mode 100644 index 0000000000..d58c4c4098 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectControllerTest.java @@ -0,0 +1,129 @@ +package stirling.software.saas.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockHttpServletRequest; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.saas.accountlink.ConnectController.CreateBody; +import stirling.software.saas.accountlink.ConnectController.CreateResponse; + +/** + * The authorize URL the instance is told to send its admin to. Everything else on this controller + * delegates; this is the only decision it makes on its own. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConnectControllerTest { + + private static final CreateBody BODY = + new CreateBody("prod-1", "https://pdf.example.com/account-link/callback", "n", "s"); + + @Mock private ConnectRequestService service; + @Mock private LeaderTeamResolver leaderTeams; + @Mock private AccountLinkService accountLinkService; + + private ApplicationProperties applicationProperties; + private ConnectController controller; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + controller = + new ConnectController( + service, leaderTeams, accountLinkService, applicationProperties); + when(service.create(anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn(ConnectRequestService.CreateResult.ok("req-1", 1800)); + } + + private String authorizeUrl(MockHttpServletRequest request) { + Object body = controller.request(BODY, request).getBody(); + assertThat(body).isInstanceOf(CreateResponse.class); + return ((CreateResponse) body).authorizeUrl(); + } + + private static MockHttpServletRequest request(String scheme, String host, int port) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setScheme(scheme); + request.setServerName(host); + request.setServerPort(port); + return request; + } + + @Test + void prefersTheConfiguredFrontendUrl() { + applicationProperties.getSystem().setFrontendUrl("https://app.example.com/app/"); + + // Trailing slash trimmed, base path kept, and the API's own origin ignored. + assertThat(authorizeUrl(request("https", "api.example.com", 443))) + .isEqualTo("https://app.example.com/app/link?request=req-1"); + } + + @Test + void fallsBackToTheOriginTheApiWasReachedOn() { + assertThat(authorizeUrl(request("https", "api.example.com", 443))) + .isEqualTo("https://api.example.com/link?request=req-1"); + } + + @Test + void keepsANonDefaultPortAndTheContextPath() { + MockHttpServletRequest request = request("http", "localhost", 8081); + request.setContextPath("/stirling"); + + assertThat(authorizeUrl(request)) + .isEqualTo("http://localhost:8081/stirling/link?request=req-1"); + } + + @Test + void honoursTheForwardedSchemeAndHost() { + MockHttpServletRequest request = request("http", "10.0.0.5", 8080); + request.addHeader("X-Forwarded-Proto", "https"); + request.addHeader("X-Forwarded-Host", "api.example.com"); + + assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1"); + } + + @Test + void takesOnlyTheFirstForwardedHop() { + MockHttpServletRequest request = request("http", "10.0.0.5", 8080); + request.addHeader("X-Forwarded-Proto", "https, http"); + request.addHeader("X-Forwarded-Host", "api.example.com, evil.example.com"); + + assertThat(authorizeUrl(request)).isEqualTo("https://api.example.com/link?request=req-1"); + } + + @Test + void percentEncodesTheRequestId() { + when(service.create(anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn(ConnectRequestService.CreateResult.ok("a b&c", 1800)); + + assertThat(authorizeUrl(request("https", "api.example.com", 443))) + .isEqualTo("https://api.example.com/link?request=a+b%26c"); + } + + @Test + void aBodylessRequestIsRejectedBeforeAnythingIsRecorded() { + assertThat(controller.request(null, request("https", "api.example.com", 443)).getBody()) + .isEqualTo(java.util.Map.of("error", "BAD_REQUEST")); + } + + @Test + void offeringNoCredentialTakesTheFirstLinkPath() { + authorizeUrl(request("https", "api.example.com", 443)); + + // createReauth is the credentialled path; a first link must not reach it. + org.mockito.Mockito.verify(service, org.mockito.Mockito.never()) + .createReauth(anyString(), anyString(), anyString(), anyString(), any(), isNull()); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectRequestServiceTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectRequestServiceTest.java new file mode 100644 index 0000000000..f75c96becb --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/ConnectRequestServiceTest.java @@ -0,0 +1,358 @@ +package stirling.software.saas.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import stirling.software.saas.accountlink.ConnectRequestService.ClaimOutcome; +import stirling.software.saas.accountlink.ConnectRequestService.CreateRejection; + +/** + * Unit tests for the connect handshake's security properties, which are the reason this flow is + * safe rather than an open redirect: the callback is validated once and then read back from + * storage, the claim secret authenticates the collection, and one approval mints exactly one + * credential. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConnectRequestServiceTest { + + private static final String CALLBACK = "https://pdf.example.com/account-link/callback"; + private static final String NONCE = "nonce-value"; + private static final String CLAIM_SECRET = "claim-secret-value"; + + @Mock private ConnectRequestRepository repo; + @Mock private AccountLinkService accountLinkService; + + private ConnectRequestService service; + + @BeforeEach + void setUp() { + service = new ConnectRequestService(repo, accountLinkService); + } + + @Test + void create_storesTheValidatedCallbackAndItsOrigin() { + ConnectRequestService.CreateResult result = + service.create("prod-1", CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1"); + + assertThat(result.isRejected()).isFalse(); + assertThat(result.requestId()).isNotBlank(); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectRequest.class); + verify(repo).save(saved.capture()); + ConnectRequest row = saved.getValue(); + assertThat(row.getCallbackUrl()).isEqualTo(CALLBACK); + assertThat(row.getCallbackOrigin()).isEqualTo("https://pdf.example.com"); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING); + assertThat(row.getName()).isEqualTo("prod-1"); + // The claim secret is only ever stored as a hash. + assertThat(row.getClaimSecretHash()).isNotEqualTo(CLAIM_SECRET).hasSize(64); + } + + @Test + void create_keepsANonDefaultPortInTheOrigin() { + service.create( + null, "http://pdf.internal:8080/account-link/callback", NONCE, CLAIM_SECRET, null); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectRequest.class); + verify(repo).save(saved.capture()); + assertThat(saved.getValue().getCallbackOrigin()).isEqualTo("http://pdf.internal:8080"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "/account-link/callback", // not absolute + "ftp://pdf.example.com/cb", // wrong scheme + "javascript:alert(1)", // not a hierarchical http(s) URL + "https://user:pw@pdf.example.com/cb", // credentials in the URL + "https://pdf.example.com/cb#already", // would collide with our fragment + "https:///cb" // no host + }) + void create_refusesCallbacksWeWouldNotWantToRedirectTo(String callback) { + ConnectRequestService.CreateResult result = + service.create(null, callback, NONCE, CLAIM_SECRET, null); + + assertThat(result.rejection()).isEqualTo(CreateRejection.BAD_CALLBACK); + verify(repo, never()).save(any()); + } + + @Test + void create_refusesAMissingNonce() { + assertThat(service.create(null, CALLBACK, " ", CLAIM_SECRET, null).rejection()) + .isEqualTo(CreateRejection.BAD_NONCE); + verify(repo, never()).save(any()); + } + + @Test + void create_namesTheSecretWhenTheSecretIsWhatIsMissing() { + assertThat(service.create(null, CALLBACK, NONCE, " ", null).rejection()) + .isEqualTo(CreateRejection.BAD_SECRET); + verify(repo, never()).save(any()); + } + + @Test + void create_isCappedPerSourceAddress() { + when(repo.countByRequesterIpAndCreatedAtAfter(anyString(), any())) + .thenReturn((long) ConnectRequestService.MAX_REQUESTS_PER_IP); + + ConnectRequestService.CreateResult result = + service.create(null, CALLBACK, NONCE, CLAIM_SECRET, "10.0.0.1"); + + assertThat(result.rejection()).isEqualTo(CreateRejection.RATE_LIMITED); + verify(repo, never()).save(any()); + } + + @Test + void lookup_flagsPlaintextTransportSoTheApproverCanSeeIt() { + ConnectRequest row = pending(); + row.setCallbackOrigin("http://pdf.internal:8080"); + when(repo.findByRequestId("req")).thenReturn(Optional.of(row)); + + assertThat(service.lookup("req")).get().extracting("insecureTransport").isEqualTo(true); + } + + @Test + void lookup_hidesAnExpiredHandshake() { + ConnectRequest row = pending(); + row.setExpiresAt(LocalDateTime.now().minusMinutes(1)); + when(repo.findByRequestId("req")).thenReturn(Optional.of(row)); + + assertThat(service.lookup("req")).isEmpty(); + } + + @Test + void approve_bindsTheTeamAndReturnsTheStoredCallback() { + ConnectRequest row = pending(); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + ConnectRequestService.ApproveResult result = service.approve("req", 7L, 42L); + + assertThat(result.isRejected()).isFalse(); + // The destination comes from the row, never from the caller. + assertThat(result.target().callbackUrl()).isEqualTo(CALLBACK); + assertThat(result.target().nonce()).isEqualTo(NONCE); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED); + assertThat(row.getTeamId()).isEqualTo(7L); + assertThat(row.getApprovedByUserId()).isEqualTo(42L); + // Approval on its own must not mint anything. + verifyNoInteractions(accountLinkService); + } + + @Test + void approve_isSingleUse() { + ConnectRequest row = pending(); + row.setStatus(ConnectRequest.Status.APPROVED); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue(); + } + + @Test + void approve_refusesAnExpiredHandshake() { + ConnectRequest row = pending(); + row.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.approve("req", 7L, 42L).isRejected()).isTrue(); + } + + @Test + void createReauth_pinsTheTeamItWasToldByTheCredential() { + ConnectRequestService.CreateResult result = + service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, 7L); + + assertThat(result.isRejected()).isFalse(); + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectRequest.class); + verify(repo).save(saved.capture()); + assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.REAUTH); + assertThat(saved.getValue().getTeamId()).isEqualTo(7L); + } + + @Test + void createReauth_withoutAnAuthenticatedInstanceIsRefused() { + // The controller passes null when the offered device credential did not authenticate. + assertThat( + service.createReauth(null, CALLBACK, NONCE, CLAIM_SECRET, null, null) + .rejection()) + .isEqualTo(CreateRejection.NOT_LINKED); + verify(repo, never()).save(any()); + } + + @Test + void create_leavesTheTeamOpenForAFirstLink() { + service.create("n", CALLBACK, NONCE, CLAIM_SECRET, null); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ConnectRequest.class); + verify(repo).save(saved.capture()); + assertThat(saved.getValue().getMode()).isEqualTo(ConnectRequest.Mode.LINK); + // Approval is what decides the team on a first link. + assertThat(saved.getValue().getTeamId()).isNull(); + } + + @Test + void approve_refusesAnApproverFromADifferentTeam() { + ConnectRequest row = reauthPinnedTo(7L); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + ConnectRequestService.ApproveResult result = service.approve("req", 99L, 42L); + + // This is the "signed in to the wrong account" case, and it must not silently rebind. + assertThat(result.rejection()).isEqualTo(ConnectRequestService.ApproveRejection.WRONG_TEAM); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.PENDING); + assertThat(row.getTeamId()).isEqualTo(7L); + } + + @Test + void approve_acceptsTheTeamTheServerAlreadyBelongsTo() { + ConnectRequest row = reauthPinnedTo(7L); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.approve("req", 7L, 42L).isRejected()).isFalse(); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED); + } + + @Test + void claim_onAReauthConfirmsWithoutMintingASecondCredential() { + ConnectRequest row = reauthPinnedTo(7L); + row.setStatus(ConnectRequest.Status.APPROVED); + row.setApprovedByUserId(42L); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET); + + assertThat(result.outcome()).isEqualTo(ClaimOutcome.CONFIRMED); + assertThat(result.deviceId()).isNull(); + assertThat(result.deviceSecret()).isNull(); + assertThat(result.teamId()).isEqualTo(7L); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED); + // A second credential would orphan the one the instance already holds. + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_mintsOnceForAnApprovedHandshake() { + ConnectRequest row = approved(); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + when(accountLinkService.register(anyLong(), anyLong(), any())) + .thenReturn( + new AccountLinkService.RegisteredInstance(9L, "dev-id", "dev-secret", "n")); + + ConnectRequestService.ClaimResult result = service.claim("req", CLAIM_SECRET); + + assertThat(result.outcome()).isEqualTo(ClaimOutcome.GRANTED); + assertThat(result.deviceId()).isEqualTo("dev-id"); + assertThat(result.deviceSecret()).isEqualTo("dev-secret"); + assertThat(result.teamId()).isEqualTo(7L); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.CONSUMED); + verify(accountLinkService).register(7L, 42L, "n"); + } + + @Test + void claim_refusesASecondCollection() { + ConnectRequest row = approved(); + row.setStatus(ConnectRequest.Status.CONSUMED); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_withTheWrongSecretMintsNothing() { + ConnectRequest row = approved(); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.claim("req", "not-the-secret").outcome()) + .isEqualTo(ClaimOutcome.REJECTED); + assertThat(row.getStatus()).isEqualTo(ConnectRequest.Status.APPROVED); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_beforeApprovalTellsTheInstanceToWait() { + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(pending())); + + assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.PENDING); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_afterDenialIsTerminal() { + ConnectRequest row = pending(); + row.setStatus(ConnectRequest.Status.DENIED); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_onAnExpiredHandshakeMintsNothing() { + ConnectRequest row = approved(); + row.setExpiresAt(LocalDateTime.now().minusSeconds(1)); + when(repo.findByRequestIdForUpdate("req")).thenReturn(Optional.of(row)); + + assertThat(service.claim("req", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED); + verifyNoInteractions(accountLinkService); + } + + @Test + void claim_forAnUnknownIdLooksTheSameAsABadSecret() { + when(repo.findByRequestIdForUpdate("nope")).thenReturn(Optional.empty()); + + assertThat(service.claim("nope", CLAIM_SECRET).outcome()).isEqualTo(ClaimOutcome.REJECTED); + } + + // --------------------------------------------------------------------------------------- + + private static ConnectRequest pending() { + ConnectRequest row = new ConnectRequest(); + row.setRequestId("req"); + row.setName("n"); + row.setCallbackUrl(CALLBACK); + row.setCallbackOrigin("https://pdf.example.com"); + row.setNonce(NONCE); + row.setClaimSecretHash(AccountLinkService.sha256Hex(CLAIM_SECRET)); + row.setStatus(ConnectRequest.Status.PENDING); + row.setExpiresAt(LocalDateTime.now().plusMinutes(10)); + return row; + } + + /** A re-authentication whose team came from the instance's credential, not from a browser. */ + private static ConnectRequest reauthPinnedTo(Long teamId) { + ConnectRequest row = pending(); + row.setMode(ConnectRequest.Mode.REAUTH); + row.setTeamId(teamId); + return row; + } + + private static ConnectRequest approved() { + ConnectRequest row = pending(); + row.setStatus(ConnectRequest.Status.APPROVED); + row.setTeamId(7L); + row.setApprovedByUserId(42L); + row.setApprovedAt(LocalDateTime.now()); + return row; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java index da719e7ba5..4727d78463 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java @@ -14,6 +14,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.Environment; +import org.springframework.mock.env.MockEnvironment; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; @@ -48,13 +50,19 @@ class SupabaseSecurityConfigMoreTest { apiKeyAuthenticationService; private SupabaseSecurityConfig config(ApplicationProperties props) { + return config(props, new MockEnvironment()); + } + + /** Loopback CORS origins are only added outside production, so the environment decides. */ + private SupabaseSecurityConfig config(ApplicationProperties props, Environment environment) { return new SupabaseSecurityConfig( userService, teamService, supabaseUserService, saasTeamService, props, - apiKeyAuthenticationService); + apiKeyAuthenticationService, + environment); } @Nested @@ -204,6 +212,50 @@ class SupabaseSecurityConfigMoreTest { .hasSize(1); } + @Test + @DisplayName("production does not allow loopback on arbitrary ports") + void productionHasNoLoopbackWildcard() { + CorsConfiguration cfg = + cors(config(new ApplicationProperties()).corsConfigurationSource()); + + assertThat(cfg.getAllowedOriginPatterns()) + .doesNotContain("http://localhost:[*]", "http://127.0.0.1:[*]"); + } + + @Test + @DisplayName("non-production allows loopback on any port so dev servers can move") + void devAllowsAnyLoopbackPort() { + // Several dev servers run side by side and their ports change; pinning a list turns + // every new local environment into an opaque CORS failure. + MockEnvironment dev = new MockEnvironment(); + dev.setActiveProfiles("saas", "dev"); + + CorsConfiguration cfg = + cors(config(new ApplicationProperties(), dev).corsConfigurationSource()); + + assertThat(cfg.getAllowedOriginPatterns()) + .contains("http://localhost:[*]", "http://127.0.0.1:[*]") + // Still credentialed, which is the reason the pattern form matters. + .contains("https://stirling.com"); + assertThat(cfg.getAllowCredentials()).isTrue(); + } + + @Test + @DisplayName("an operator origin list is respected verbatim even in dev") + void operatorOverrideSuppressesLoopbackWildcard() { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().setCorsAllowedOrigins(List.of("https://custom.example.com")); + MockEnvironment dev = new MockEnvironment(); + dev.setActiveProfiles("saas", "dev"); + + CorsConfiguration cfg = cors(config(props, dev).corsConfigurationSource()); + + // An operator who set the list meant it; we do not widen it behind their back. + assertThat(cfg.getAllowedOriginPatterns()) + .contains("https://custom.example.com") + .doesNotContain("http://localhost:[*]"); + } + @Test @DisplayName("operator override replaces the default origin list") void operatorOverrideUsed() { diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 540836032f..4af1be3359 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3240,7 +3240,7 @@ enterEmailConfirm = "To confirm deletion, please type your email address ({{emai guestDescription = "You are signed in as a guest. Consider upgrading your account above." label = "Overview" manageAccountPreferences = "Manage your account preferences" -signedInAs = "Signed in as" +signedInAs = "Account" title = "Account Settings" [config.account.profilePicture] @@ -3351,6 +3351,39 @@ integration = "Integration Configuration" security = "Security Configuration" system = "System Configuration" +[connect] +loading = "Checking this request." +redirecting = "Returning you to your server." + +[connect.confirm] +acknowledge = "I recognise this address and want to connect it to my team" +approve = "Connect server" +deny = "Decline" +lead = "A Stirling server is asking to connect to your team. Check the address below is yours before you approve." +originLabel = "Address" +signedInAs = "Signed in as" +switchAccount = "Use a different account" +title = "Connect this server?" +unknownAccount = "an unknown account" + +[connect.confirm.insecure] +body = "This address does not use HTTPS, so your sign-in will be sent over an unencrypted connection. Only approve it on a network you trust." +label = "Not an encrypted address" + +[connect.declined] +body = "Nothing was connected. You can close this page." +title = "Request declined" + +[connect.error] +failed = "That did not go through. Only a team owner can connect a server." + +[connect.meta] +title = "Connect a server" + +[connect.notFound] +body = "This connection request is not valid. It may have expired, or already been used. Start another one from your server." +title = "Request not valid" + [convert] autoRotate = "Auto Rotate" autoRotateDescription = "Automatically rotate images to better fit the PDF page" @@ -6487,6 +6520,34 @@ after = "to enable account linking against the hosted Stirling account. In dev y before = "Set" title = "SaaS login not configured" +[portal.accountLink.connect.callback] +continue = "Continue" +linkedNotSignedIn = "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in." +modalTitle = "Connecting this server" +retry = "Try again" +signedInAnyway = "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete." +working = "Finishing the connection." + +[portal.accountLink.connect.callback.expired] +body = "Connection requests are short lived. Start another one." +title = "Request expired" + +[portal.accountLink.connect.callback.linked] +body = "This server is connected to your Stirling account." +title = "Server connected" + +[portal.accountLink.connect.callback.malformed] +body = "This page was opened without a valid connection response. Start the connection from settings." +title = "Could not read the response" + +[portal.accountLink.connect.callback.rejected] +body = "This request was declined or has already been used. Start another one if that was not intended." +title = "Connection not completed" + +[portal.accountLink.connect.callback.unfinished] +body = "Stirling did not confirm the connection. This is usually temporary." +title = "Not finished yet" + [portal.accountLink.gate] action = "Link account" description = "Link this org's Stirling account to use billable features." @@ -6520,17 +6581,24 @@ minutesAgo_other = "{{count}}m ago" never = "never" [portal.accountLink.modal] -linkSubtitle = "Sign in to the account this server should bill against." -linkTitle = "Link your Stirling account" -reauthSubtitle = "Your session expired — sign back in to your Stirling account. Your instance stays linked." +cancel = "Cancel" +continueLink = "Continue to Stirling" +continueReauth = "Sign in again" +linkSubtitle = "Connect this server to the Stirling account it should bill against." +linkTitle = "Connect your Stirling account" +noAuthorizeUrl = "Stirling did not return somewhere to continue. Try again in a moment." +reauthSubtitle = "Your Stirling session expired. Sign in again to keep seeing usage and billing. This server stays connected either way." reauthTitle = "Sign in again" -simulateSignIn = "Simulate sign-in (dev)" +startFailed = "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again." +step1 = "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on." +step2 = "You check this server's address and approve it. A team owner has to do this the first time." +step3 = "Stirling brings you straight back here and finishes up." [portal.accountLink.modal.loginNotConfigured] -after = "to enable in-app linking against the hosted Stirling account." +after = "so this server can finish the connection when you come back." and = "and" before = "Set" -title = "SaaS login not configured" +title = "Stirling connection not configured" [portal.accountLink.panel] instancesSub = "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access." diff --git a/frontend/editor/src/portal/PortalProviders.tsx b/frontend/editor/src/portal/PortalProviders.tsx index 35c9fc7e88..a82f1c7405 100644 --- a/frontend/editor/src/portal/PortalProviders.tsx +++ b/frontend/editor/src/portal/PortalProviders.tsx @@ -1,52 +1,24 @@ import { TierProvider } from "@portal/contexts/TierContext"; -import { LinkProvider, useLink } from "@portal/contexts/LinkContext"; +import { LinkProvider } from "@portal/contexts/LinkContext"; import { UIProvider, useUI } from "@portal/contexts/UIContext"; -import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin"; import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; -import { - AccountLinkProvider, - useAccountLinkContext, -} from "@portal/contexts/AccountLinkContext"; +import { AccountLinkProvider } from "@portal/contexts/AccountLinkContext"; +import { ConnectCallbackHost } from "@portal/components/account-link/ConnectCallbackHost"; import { PortalChrome } from "@portal/components/PortalChrome"; -/** - * The one and only account-link login modal. Mounted at the app root (never - * nested in another overlay) and driven by UIContext, so any "Link account" CTA - * — sidebar, billing prompt, feature gate, Settings panel — opens this exact - * instance. Linking is finished by the shared {@link useAccountLinkContext} - * orchestration. - */ +/** The one and only account-link modal. */ function LinkModalHost() { const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); - const { markSaasSessionChanged } = useLink(); - const link = useAccountLinkContext(); - // "reauth" only refreshes the browser SaaS session for attended reads — the - // sign-in already applied it to the Supabase client, so we just signal a - // refetch. It must NOT call completeLink (that re-registers → duplicate row). - const onLinked = - linkModalMode === "reauth" - ? () => markSaasSessionChanged() - : (session: SupabaseLoginSession) => link.completeLink(session); return ( ); } -/** - * Self-hosted provider stack. The account-link layer (LinkProvider + - * AccountLinkProvider + the login modal) wraps the shared chrome; the tier is - * derived from the link/subscription state (see usePlanTier). TierProvider sits - * inside LinkProvider because the self-hosted usePlanTier reads useLink. - * - * The SaaS build shadows this file to drop the account-link layer entirely — the - * signed-in account IS the SaaS account, so there is nothing to link and the - * tier comes from the wallet. - */ +/** Self-hosted provider stack. */ export function PortalProviders() { return ( @@ -55,6 +27,7 @@ export function PortalProviders() { + diff --git a/frontend/editor/src/portal/api/link.test.ts b/frontend/editor/src/portal/api/link.test.ts index e03d5bf25b..9c05952549 100644 --- a/frontend/editor/src/portal/api/link.test.ts +++ b/frontend/editor/src/portal/api/link.test.ts @@ -34,7 +34,6 @@ import { fetchInstances, fetchLocalUsage, fetchStatus, - linkInstance, revokeInstance, unlinkInstance, } from "@portal/api/link"; @@ -55,21 +54,14 @@ describe("api/link — local backend (this instance)", () => { expect(status.linked).toBe(false); }); - it("links this instance via the local endpoint, never returning a secret", async () => { - const status = await linkInstance({ - supabaseJwt: "jwt_abc", - name: "node-1", - }); - expect(status.linked).toBe(true); - expect(status.name).toBe("node-1"); - // Contract: the device secret is stored server-side, never sent to the portal. + it("never exposes the device credential in a status read", async () => { + // Contract: the device secret is stored server-side and the portal never sees it. + const status = await fetchStatus(); expect(status).not.toHaveProperty("deviceSecret"); expect(status).not.toHaveProperty("deviceId"); - expect(await (await fetchStatus()).linked).toBe(true); }); it("unlinks this instance", async () => { - await linkInstance({ supabaseJwt: "jwt_abc" }); // unlink returns 204 (no body); the status is read back separately. await unlinkInstance(); expect((await fetchStatus()).linked).toBe(false); @@ -84,18 +76,6 @@ describe("api/link — local backend (this instance)", () => { ); expect(usage.totalUnsyncedUnits).toBeGreaterThanOrEqual(0); }); - - it("forwards the SaaS JWT in the link body", async () => { - let seenBody: unknown = null; - server.events.on("request:start", async ({ request }) => { - if (request.method === "POST" && request.url.endsWith("/link")) { - seenBody = await request.clone().json(); - } - }); - await linkInstance({ supabaseJwt: "jwt_xyz", name: "n" }); - expect(seenBody).toMatchObject({ supabaseJwt: "jwt_xyz" }); - server.events.removeAllListeners(); - }); }); describe("api/link — SaaS backend (team-wide)", () => { diff --git a/frontend/editor/src/portal/api/link.ts b/frontend/editor/src/portal/api/link.ts index 696ccbe5b2..c3684b3dd2 100644 --- a/frontend/editor/src/portal/api/link.ts +++ b/frontend/editor/src/portal/api/link.ts @@ -1,13 +1,5 @@ import { apiClient } from "@portal/api/http"; -/** Body for POST /api/v1/account-link/link — the SaaS JWT + optional name. */ -export interface LinkInstanceRequest { - /** Admin's SaaS session JWT, obtained via the hosted-login popup. */ - supabaseJwt: string; - /** Optional label for this instance. */ - name?: string; -} - /** Link status for this instance (GET /api/v1/account-link/status). */ export interface LinkStatus { linked: boolean; @@ -15,12 +7,7 @@ export interface LinkStatus { name: string | null; } -/** - * Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage). - * The portal adds this on top of the SaaS-synced spend so "current usage" - * includes work done since the last daily sync. Per-category unsynced units for - * the current period; all zero when metering is off or nothing is pending. - */ +/** Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage). */ export interface LocalUsage { /** ISO timestamp of the current period start; null when unknown (not yet synced). */ periodStart: string | null; @@ -42,93 +29,88 @@ export interface LinkedInstanceRow { revoked: boolean; } -/** - * Account-link client (combined-billing "Mode A"). Two distinct surfaces: - * - * THIS instance — apiClient.local (Spring admin bearer auto-attached): - * - POST /api/v1/account-link/link — hand the local backend the admin's - * SaaS JWT in the body. It registers - * with SaaS + stores the device - * secret SERVER-SIDE; the portal - * NEVER receives or renders it. - * - GET /api/v1/account-link/status — Linked / Not-linked for this - * instance. - * - POST /api/v1/account-link/unlink — drop this instance's link (local - * backend best-effort tells SaaS). - * - * TEAM-WIDE management — apiClient.saas (admin's Supabase JWT auto-attached - * from the in-app account-link login): - * - GET /api/v1/account-link/instances — every linked instance - * - POST /api/v1/account-link/instances/{id}/revoke - * - * The team-wide endpoints are served by the hosted SaaS Java backend (the - * local backend has no such routes), so they go through apiClient.saas. In - * Storybook/tests, wildcard MSW handlers match both the local and absolute - * SaaS URLs. - */ +/** Account-link client (combined billing). */ const BASE = "/api/v1/account-link"; -/** - * Link THIS instance. The local backend takes the SaaS JWT, registers with - * SaaS, and persists the device secret itself; the response carries only the - * resulting link status. No secret is returned. - */ -export async function linkInstance( - req: LinkInstanceRequest, -): Promise { - return apiClient.local.json(`${BASE}/link`, { - method: "POST", - body: req, - }); -} - /** Linked / Not-linked for this instance. */ export async function fetchStatus(): Promise { return apiClient.local.json(`${BASE}/status`); } /** - * Locally-accrued usage not yet reported to SaaS — the portal adds this on top - * of the SaaS-synced spend so "current usage" includes work done since the last - * daily sync. Local-backend call; returns zeros when metering is off. + * Locally-accrued usage not yet reported to SaaS — the portal adds this on top of the SaaS-synced spend so "current usage" includes work done since the last daily sync. */ export async function fetchLocalUsage(): Promise { return apiClient.local.json(`${BASE}/usage`); } -/** - * Drop this instance's link. The local backend best-effort tells SaaS to - * revoke before clearing the credential locally, then returns 204 — there's no - * body, so the caller sets the known unlinked status itself. - */ +/** Drop this instance's link. */ export async function unlinkInstance(): Promise { await apiClient.local.json(`${BASE}/unlink`, { method: "POST" }); } -/** - * Nudge the local backend to sync + refresh its cached entitlement now. Called - * right after a checkout completes so the instance's request-time gate reflects - * the new subscription immediately instead of waiting out its entitlement-cache - * TTL. Best-effort — the caller swallows failures (metering off → 409, or the - * local backend unreachable); the scheduled sync / TTL refresh is the backstop. - */ +/** Nudge the local backend to sync + refresh its cached entitlement now. */ export async function triggerLocalSync(): Promise { await apiClient.local.json(`${BASE}/sync-now`, { method: "POST" }); } +/** Where a browser-mediated connect handshake has got to. */ +export type ConnectPhase = + | "NONE" + | "PENDING" + | "LINKED" + | "EXPIRED" + | "REJECTED" + | "UNAVAILABLE"; + +export interface ConnectStatus { + phase: ConnectPhase; + /** Approval page to send the admin to. */ + authorizeUrl: string | null; + secondsRemaining: number | null; + teamId: number | null; +} + +const CONNECT = `${BASE}/connect`; + +/** Open a handshake and get the approval URL to send the admin to. */ +export async function startConnect( + name?: string, + callbackUrl?: string, +): Promise { + return apiClient.local.json(`${CONNECT}/start`, { + method: "POST", + body: { name, callbackUrl }, + }); +} + +/** Re-establish the SaaS session for a server that is already linked. */ +export async function startReauth( + callbackUrl?: string, +): Promise { + return apiClient.local.json(`${CONNECT}/reauth`, { + method: "POST", + body: { callbackUrl }, + }); +} + +/** Finish a handshake using the nonce the approval page put in the callback fragment. */ +export async function completeConnect(nonce: string): Promise { + return apiClient.local.json(`${CONNECT}/complete`, { + method: "POST", + body: { nonce }, + }); +} + /** - * Every linked instance for the team — SaaS-direct call with the admin's - * Supabase JWT (no longer takes an accessToken parameter; the saas client - * resolves the live session itself). + * Every linked instance for the team — SaaS-direct call with the admin's Supabase JWT (no longer takes an accessToken parameter; the saas client resolves the live session itself). */ export async function fetchInstances(): Promise { return apiClient.saas.json(`${BASE}/instances`); } -/** - * Revoke a linked instance — SaaS-direct call with the admin's Supabase JWT. - */ +/** Revoke a linked instance — SaaS-direct call with the admin's Supabase JWT. */ export async function revokeInstance(instanceId: number): Promise { await apiClient.saas.json(`${BASE}/instances/${instanceId}/revoke`, { method: "POST", diff --git a/frontend/editor/src/portal/auth/saasSupabase.ts b/frontend/editor/src/portal/auth/saasSupabase.ts index 1005d63a63..45598c369b 100644 --- a/frontend/editor/src/portal/auth/saasSupabase.ts +++ b/frontend/editor/src/portal/auth/saasSupabase.ts @@ -22,11 +22,12 @@ const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY; export const isSaasSupabaseConfigured = Boolean(url && key); -/** OAuth providers the hosted SaaS login offers (mirrors the SaaS editor login). */ -export const SAAS_OAUTH_PROVIDERS = ["google", "github", "apple", "azure"]; - -/** sessionStorage marker set before an SSO redirect so the return can finish the link. */ -export const PENDING_LINK_KEY = "stirling-account-link-pending"; +/* + * SAAS_OAUTH_PROVIDERS and PENDING_LINK_KEY are gone. They served an in-portal SSO sign-in that + * could not work: the provider only redirects to allow-listed URLs, so a customer's origin was + * never returned to and the admin was left on stirling.com. Provider choice now happens on our own + * origin during the connect handshake, where the redirect can actually complete. + */ let configured = false; diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx new file mode 100644 index 0000000000..fab501da4a --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Modal } from "@app/ui"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { withBasePath } from "@app/constants/app"; +import { + completeConnect, + startConnect, + type ConnectPhase, +} from "@portal/api/link"; +import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; +import { useAccountLinkContext } from "@portal/contexts/AccountLinkContext"; +import { + ConnectCallbackView, + type ConnectCallbackState, +} from "@portal/components/account-link/ConnectCallbackView"; +import "@portal/views/ConnectCallback.css"; + +/** What the callback route hands over, read from the URL fragment before stripping it. */ +export interface AccountLinkReturn { + type: string | null; + nonce: string | null; + accessToken: string | null; + refreshToken: string | null; +} + +interface LocationState { + accountLinkReturn?: AccountLinkReturn; +} + +/** + * Finishes the handshake and reports the outcome, over the portal the admin + * started from. + * + * Mounted alongside the other portal-wide modal rather than being its own route: + * the result is a step in a task, so the page behind it should still be there. + */ +export function ConnectCallbackHost() { + const location = useLocation(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const { refresh } = useAccountLinkContext(); + const handover = (location.state as LocationState | null)?.accountLinkReturn; + + const [state, setState] = useState(null); + const [sessionRestored, setSessionRestored] = useState(false); + const nonceRef = useRef(null); + const startedRef = useRef(false); + + const finish = useCallback( + async (nonce: string) => { + setState("working"); + try { + const outcome = toViewState((await completeConnect(nonce)).phase); + setState(outcome); + // The portal read its status on mount, before this existed. Without this + // the page behind the modal still says unlinked until a reload. + if (outcome === "linked") await refresh(); + } catch { + // Could not reach our own backend. The handshake is still open, so this + // is worth another attempt rather than a restart. + setState("retry"); + } + }, + [refresh], + ); + + useEffect(() => { + if (!handover || startedRef.current) return; + startedRef.current = true; + + const { type, nonce, accessToken, refreshToken } = handover; + if (type !== "link" || !nonce) { + setState("malformed"); + return; + } + nonceRef.current = nonce; + + void (async () => { + if (accessToken && refreshToken) { + try { + const supabase = ensureSaasSupabase(); + // Logged, not swallowed: silently this resurfaces later as "session + // expired" on the usage page, with nothing tying it back here. + if (!supabase) { + console.warn( + "[account-link] no Supabase client: VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY are not set for this build", + ); + } else { + const { error } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }); + if (error) { + console.warn("[account-link] setSession failed:", error.message); + } else { + setSessionRestored(true); + } + } + } catch (e) { + console.warn("[account-link] session hand-off threw:", e); + } + } else { + console.warn( + "[account-link] callback carried no tokens; the approval page had no session to pass", + ); + } + await finish(nonce); + })(); + }, [handover, finish]); + + /** + * Retry means different things either side of a still-valid handshake: finish the one we have, or open a new one when it is past saving. + */ + const onRetry = useCallback(() => { + if (state === "retry" && nonceRef.current) { + void finish(nonceRef.current); + return; + } + setState("working"); + // Same callback the modal sends. Without it the backend falls back to the bare + // origin, which drops the app's base path and lands the return on nothing. + void startConnect( + window.location.hostname, + new URL( + withBasePath("/account-link/callback"), + window.location.origin, + ).toString(), + ) + .then((status) => { + if (status.authorizeUrl) { + window.location.assign(status.authorizeUrl); + } else { + setState("rejected"); + } + }) + .catch(() => setState("retry")); + }, [state, finish]); + + // Drops the handover with it, so a back navigation does not reopen the result. + const done = useCallback(() => { + setState(null); + navigate(PORTAL_BASENAME, { replace: true }); + }, [navigate]); + + if (!state) return null; + + return ( + + + + ); +} + +/** + * PENDING and UNAVAILABLE collapse into one "try again" state: both mean the handshake is intact but unfinished, which is the same thing to do about it. + */ +function toViewState(phase: ConnectPhase): ConnectCallbackState { + switch (phase) { + case "LINKED": + return "linked"; + case "EXPIRED": + return "expired"; + case "PENDING": + case "UNAVAILABLE": + return "retry"; + default: + return "rejected"; + } +} diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx new file mode 100644 index 0000000000..4a22592e45 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx @@ -0,0 +1,162 @@ +import { useTranslation } from "react-i18next"; +import { Banner, Button, Spinner } from "@app/ui"; + +/** Outcomes of returning from the approval page. */ +export type ConnectCallbackState = + | "working" + | "linked" + | "retry" + | "expired" + | "rejected" + | "malformed"; + +export interface ConnectCallbackViewProps { + state: ConnectCallbackState; + /** True once the SaaS session landed, regardless of how the link itself went. */ + sessionRestored: boolean; + onRetry: () => void; + onDone: () => void; +} + +/** Presentation for the account-link callback. */ +export function ConnectCallbackView({ + state, + sessionRestored, + onRetry, + onDone, +}: ConnectCallbackViewProps) { + const { t } = useTranslation(); + + if (state === "working") { + return ( +

+ +

+ {t( + "portal.accountLink.connect.callback.working", + "Finishing the connection.", + )} +

+
+ ); + } + + if (state === "linked") { + return ( +
+ + {t( + "portal.accountLink.connect.callback.linked.body", + "This server is connected to your Stirling account.", + )} + + {/* The inverse of the failure note below: the link took but the sign-in did + not, which otherwise only shows up later as "session expired" on a page + that gives no hint the two are related. */} + {sessionRestored ? null : ( +

+ {t( + "portal.accountLink.connect.callback.linkedNotSignedIn", + "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in.", + )} +

+ )} + +
+ ); + } + + const { tone, title, body, retryable } = failure(state, t); + return ( +
+ + {body} + + {/* The SaaS sign-in and the server link are separate outcomes. Say so when + one worked and the other did not, or the admin re-runs the whole thing + to fix a problem that is already half solved. */} + {sessionRestored ? ( +

+ {t( + "portal.accountLink.connect.callback.signedInAnyway", + "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete.", + )} +

+ ) : null} + +
+ ); +} + +type Translate = ReturnType["t"]; + +function failure(state: ConnectCallbackState, t: Translate) { + switch (state) { + case "expired": + return { + tone: "warning" as const, + title: t( + "portal.accountLink.connect.callback.expired.title", + "Request expired", + ), + body: t( + "portal.accountLink.connect.callback.expired.body", + "Connection requests are short lived. Start another one.", + ), + retryable: true, + }; + case "rejected": + return { + tone: "warning" as const, + title: t( + "portal.accountLink.connect.callback.rejected.title", + "Connection not completed", + ), + body: t( + "portal.accountLink.connect.callback.rejected.body", + "This request was declined or has already been used. Start another one if that was not intended.", + ), + retryable: true, + }; + case "malformed": + return { + tone: "danger" as const, + title: t( + "portal.accountLink.connect.callback.malformed.title", + "Could not read the response", + ), + body: t( + "portal.accountLink.connect.callback.malformed.body", + "This page was opened without a valid connection response. Start the connection from settings.", + ), + retryable: false, + }; + default: + return { + tone: "warning" as const, + // Not "retry.*": that key is the button label, and TOML cannot hold a + // value and a table under the same name. + title: t( + "portal.accountLink.connect.callback.unfinished.title", + "Not finished yet", + ), + body: t( + "portal.accountLink.connect.callback.unfinished.body", + "Stirling did not confirm the connection. This is usually temporary.", + ), + retryable: true, + }; + } +} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountCard.stories.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountCard.stories.tsx index af949e9dc9..f20f777280 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountCard.stories.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountCard.stories.tsx @@ -9,8 +9,9 @@ const base: UseAccountLink = { status: { linked: false, name: null }, phase: "idle", error: null, - completeLink: async () => {}, + unlink: async () => {}, + refresh: async () => {}, }; const meta: Meta = { diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.css b/frontend/editor/src/portal/components/account-link/LinkAccountModal.css new file mode 100644 index 0000000000..bc96172f0a --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.css @@ -0,0 +1,26 @@ +/* Connect-account modal. Imported by the component rather than relying on the + account-link view's stylesheet: this modal is mounted at the app root, so it + renders on pages that never import that view. */ + +.portal-link__modal-body { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.portal-link__steps { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0; + padding-left: 1.25rem; + font-size: 0.875rem; + line-height: 1.5; + color: var(--c-text-muted); +} + +.portal-link__modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx index 8228e3b1bd..b7ba6647bf 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx @@ -8,16 +8,19 @@ const meta: Meta = { args: { open: true, onClose: () => {}, - onLinked: async () => {}, }, }; export default meta; type Story = StoryObj; -/** Default "link" mode — sign in to register this instance against a Stirling account. */ +/** + * "link" mode — explains the trip to Stirling and starts the handshake. There is no + * sign-in form: a sign-in started on a self-hosted origin cannot complete, because + * the provider will not redirect back to a hostname it does not know. + */ export const Default: Story = {}; -/** "reauth" mode — an already-linked instance's session expired and needs a fresh sign-in. */ +/** "reauth" mode — the server stays linked; only the browser session is renewed. */ export const Reauth: Story = { args: { mode: "reauth" }, }; diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx new file mode 100644 index 0000000000..018d435b31 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +/** The modal every "link account" CTA in the portal opens. */ +const { startConnect, startReauth } = vi.hoisted(() => ({ + startConnect: vi.fn(), + startReauth: vi.fn(), +})); + +vi.mock("@portal/api/link", () => ({ startConnect, startReauth })); +vi.mock("@portal/auth/saasSupabase", () => ({ + isSaasSupabaseConfigured: true, +})); + +import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; + +const AUTHORIZE = "http://localhost:5174/link?request=req-1"; + +function renderModal(mode?: "link" | "reauth") { + return render( + + {}} mode={mode} /> + , + ); +} + +/** Clicks the primary action (the secondary one is Cancel). */ +function clickContinue(getAllByRole: (role: string) => HTMLElement[]) { + const buttons = getAllByRole("button"); + act(() => buttons[buttons.length - 1].click()); +} + +describe("LinkAccountModal", () => { + let assign: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + startConnect.mockResolvedValue({ + phase: "PENDING", + authorizeUrl: AUTHORIZE, + secondsRemaining: 900, + teamId: null, + }); + startReauth.mockResolvedValue({ + phase: "PENDING", + authorizeUrl: AUTHORIZE, + secondsRemaining: 900, + teamId: null, + }); + assign = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { + origin: "http://localhost:5173", + hostname: "localhost", + assign, + }, + }); + }); + + it("offers no sign-in form, because a sign-in started here cannot complete", () => { + const { container } = renderModal(); + + // The provider buttons this modal used to carry sent the admin to Stirling and + // abandoned them there. Nothing should collect credentials on this origin. + expect(container.querySelector("input[type=password]")).toBeNull(); + expect(container.querySelector("input[type=email]")).toBeNull(); + }); + + it("starts a link handshake and hands the browser to Stirling", async () => { + const { getAllByRole } = renderModal(); + + clickContinue(getAllByRole); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + // Callback built from this page's own origin, which the backend then checks + // against the request's Origin header. + expect(startConnect).toHaveBeenCalledWith( + "localhost", + "http://localhost:5173/account-link/callback", + ); + await waitFor(() => expect(assign).toHaveBeenCalledWith(AUTHORIZE)); + expect(startReauth).not.toHaveBeenCalled(); + }); + + it("uses the reauth endpoint when only the session needs renewing", async () => { + const { getAllByRole } = renderModal("reauth"); + + clickContinue(getAllByRole); + + // A different endpoint on purpose: reauth presents the device credential so + // Stirling pins the handshake to the team that already owns this server. + await waitFor(() => + expect(startReauth).toHaveBeenCalledWith( + "http://localhost:5173/account-link/callback", + ), + ); + expect(startConnect).not.toHaveBeenCalled(); + await waitFor(() => expect(assign).toHaveBeenCalledWith(AUTHORIZE)); + }); + + it("stays put and explains itself when the handshake cannot start", async () => { + startConnect.mockRejectedValue(new Error("offline")); + + const { getAllByRole } = renderModal(); + clickContinue(getAllByRole); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + expect(assign).not.toHaveBeenCalled(); + }); + + it("does not navigate when there is nothing to navigate to", async () => { + // Already linked: the backend reports status without an authorize URL. + startConnect.mockResolvedValue({ + phase: "LINKED", + authorizeUrl: null, + secondsRemaining: null, + teamId: 7, + }); + + const { getAllByRole } = renderModal(); + clickContinue(getAllByRole); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + expect(assign).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx index e46504d309..8bd47a9eb4 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx @@ -1,63 +1,60 @@ -import { useEffect } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { Banner, Button, Modal } from "@app/ui"; -import SupabaseLoginForm from "@app/auth/ui/SupabaseLoginForm"; -import { - useSupabaseLogin, - type SupabaseLoginSession, -} from "@app/auth/ui/useSupabaseLogin"; -import "@app/auth/ui/auth-theme.css"; -import { - ensureSaasSupabase, - isSaasSupabaseConfigured, - PENDING_LINK_KEY, - SAAS_OAUTH_PROVIDERS, -} from "@portal/auth/saasSupabase"; +import { withBasePath } from "@app/constants/app"; +import { startConnect, startReauth } from "@portal/api/link"; +import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; +import "@portal/components/account-link/LinkAccountModal.css"; interface Props { open: boolean; onClose: () => void; /** - * "link" registers this instance against the signed-in account; "reauth" only - * refreshes an expired SaaS session (the instance is already linked). The mode - * is persisted across the OAuth redirect so the SSO-return handler doesn't - * re-register on a reauth. + * "link" connects this server to a team for the first time; "reauth" only re-establishes the browser's Stirling session for a server that is already linked. */ mode?: "link" | "reauth"; - /** Called with the SaaS session after a successful sign-in. */ - onLinked: (session: SupabaseLoginSession) => void | Promise; } -/** - * In-app account-link login. Signs the admin in to their Stirling (SaaS) account - * via the shared Supabase login (SSO + email/password), then hands the resulting - * session to the caller to register this instance. No popup; the device secret - * never reaches the browser. SSO redirects away and is finished by useAccountLink - * on return. - */ -export function LinkAccountModal({ - open, - onClose, - mode = "link", - onLinked, -}: Props) { +/** Sends the admin off to Stirling to connect this server. */ +export function LinkAccountModal({ open, onClose, mode = "link" }: Props) { const { t } = useTranslation(); - useEffect(() => { - if (open) ensureSaasSupabase(); - }, [open]); - const reauth = mode === "reauth"; - const login = useSupabaseLogin({ - providers: SAAS_OAUTH_PROVIDERS, - // Return to the current page after SSO; the SSO-return handler in - // useAccountLink reads the persisted mode so it links vs. only refreshes. - redirectTo: window.location.href, - onBeforeOAuth: () => sessionStorage.setItem(PENDING_LINK_KEY, mode), - onSuccess: async (session) => { - await onLinked(session); - onClose(); - }, - }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const begin = useCallback(async () => { + setBusy(true); + setError(null); + try { + const callbackUrl = new URL( + withBasePath("/account-link/callback"), + window.location.origin, + ).toString(); + const status = reauth + ? await startReauth(callbackUrl) + : await startConnect(window.location.hostname, callbackUrl); + if (status.authorizeUrl) { + window.location.assign(status.authorizeUrl); + return; + } + // Already linked, or a handshake we cannot act on. Nothing to navigate to. + setError( + t( + "portal.accountLink.modal.noAuthorizeUrl", + "Stirling did not return somewhere to continue. Try again in a moment.", + ), + ); + } catch { + setError( + t( + "portal.accountLink.modal.startFailed", + "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again.", + ), + ); + } finally { + setBusy(false); + } + }, [reauth, t]); return ( - {isSaasSupabaseConfigured ? ( - - ) : ( -
+
+
    +
  1. + {t( + "portal.accountLink.modal.step1", + "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on.", + )} +
  2. +
  3. + {t( + "portal.accountLink.modal.step2", + "You check this server's address and approve it. A team owner has to do this the first time.", + )} +
  4. +
  5. + {t( + "portal.accountLink.modal.step3", + "Stirling brings you straight back here and finishes up.", + )} +
  6. +
+ + {!isSaasSupabaseConfigured && ( {t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "} @@ -101,25 +117,27 @@ export function LinkAccountModal({ VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "} {t( "portal.accountLink.modal.loginNotConfigured.after", - "to enable in-app linking against the hosted Stirling account.", + "so this server can finish the connection when you come back.", )} - {import.meta.env.DEV && ( - - )} + )} + + {error && {error}} + +
+ +
- )} +
); } diff --git a/frontend/editor/src/portal/contexts/AccountLinkContext.tsx b/frontend/editor/src/portal/contexts/AccountLinkContext.tsx index d3f4bf3678..0ab5fc4da7 100644 --- a/frontend/editor/src/portal/contexts/AccountLinkContext.tsx +++ b/frontend/editor/src/portal/contexts/AccountLinkContext.tsx @@ -5,15 +5,7 @@ import { } from "@portal/hooks/useAccountLink"; /** - * Single app-wide {@link useAccountLink} instance. The link flow is orchestrated - * in exactly one place so that: - * - status is fetched once on mount (not per consumer), and - * - the SSO-return effect fires once — two instances would both call - * {@link UseAccountLink.completeLink} on return and re-register the device - * credential, leaving a duplicate linked_instance row. - * - * Consumers (the top-level link modal host, the Settings account-link panel, - * the link card) read this shared instance instead of calling the hook again. + * Single app-wide {@link useAccountLink} instance, so status is fetched once on mount rather than per consumer. */ const AccountLinkContext = createContext(null); diff --git a/frontend/editor/src/portal/contexts/LinkContext.tsx b/frontend/editor/src/portal/contexts/LinkContext.tsx index d088647586..7fd7cfe536 100644 --- a/frontend/editor/src/portal/contexts/LinkContext.tsx +++ b/frontend/editor/src/portal/contexts/LinkContext.tsx @@ -8,7 +8,7 @@ import { } from "react"; /** - * The "linked" dimension of the account-link surface (combined-billing "Mode A"), + * The "linked" dimension of the account-link surface (combined billing), * a sibling to TierContext. It answers one question the rest of the portal asks: * has this self-hosted org linked its SaaS account, and if so, is it on the free * grant or actively subscribed? @@ -59,13 +59,6 @@ interface LinkContextValue { isLinked: boolean; /** Convenience for `LINK_INFO[linkState].unlocked` — billable features usable. */ featuresUnlocked: boolean; - /** - * Bumps whenever the browser's SaaS session changes (e.g. a re-sign-in after - * expiry). Attended SaaS reads (the wallet) key off this to refetch with the - * fresh token without re-establishing the instance link. - */ - saasSessionNonce: number; - markSaasSessionChanged: () => void; } const LinkContext = createContext(null); @@ -78,11 +71,6 @@ export function LinkProvider({ initialState?: LinkState; }) { const [linkState, setLinkState] = useState(initialState); - const [saasSessionNonce, setSaasSessionNonce] = useState(0); - const markSaasSessionChanged = useCallback( - () => setSaasSessionNonce((n) => n + 1), - [], - ); const value = useMemo(() => { const unlocked = LINK_INFO[linkState].unlocked; return { @@ -90,10 +78,8 @@ export function LinkProvider({ setLinkState, isLinked: linkState !== "unlinked", featuresUnlocked: unlocked, - saasSessionNonce, - markSaasSessionChanged, }; - }, [linkState, saasSessionNonce, markSaasSessionChanged]); + }, [linkState]); return {children}; } diff --git a/frontend/editor/src/portal/hooks/useAccountLink.test.tsx b/frontend/editor/src/portal/hooks/useAccountLink.test.tsx deleted file mode 100644 index b327322778..0000000000 --- a/frontend/editor/src/portal/hooks/useAccountLink.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, render, waitFor } from "@testing-library/react"; -import { LinkProvider } from "@portal/contexts/LinkContext"; - -/** - * The SSO-return path is mode-aware: a "reauth" return must only refresh the - * session, NOT re-register the instance (re-registering mints a duplicate device - * credential). This is the exact regression that slipped through once, so it gets - * a dedicated guard. - */ -const { linkInstance, fetchStatus, unlinkInstance, getSession } = vi.hoisted( - () => ({ - linkInstance: vi.fn(), - fetchStatus: vi.fn(), - unlinkInstance: vi.fn(), - getSession: vi.fn(), - }), -); - -vi.mock("@portal/api/link", () => ({ - linkInstance, - fetchStatus, - unlinkInstance, -})); -vi.mock("@portal/auth/saasSupabase", () => ({ - PENDING_LINK_KEY: "stirling_pending_link", - isSaasSupabaseConfigured: true, - SAAS_OAUTH_PROVIDERS: [], - ensureSaasSupabase: () => ({ auth: { getSession } }), -})); - -import { useAccountLink } from "@portal/hooks/useAccountLink"; -import { PENDING_LINK_KEY } from "@portal/auth/saasSupabase"; - -function Probe() { - useAccountLink(); - return null; -} - -const renderHook = () => - render( - - - , - ); - -beforeEach(() => { - linkInstance.mockReset().mockResolvedValue({ linked: true, name: null }); - fetchStatus.mockReset().mockResolvedValue({ linked: true, name: null }); - unlinkInstance.mockReset(); - getSession.mockReset().mockResolvedValue({ - data: { session: { access_token: "tok" } }, - }); - sessionStorage.clear(); -}); -afterEach(() => sessionStorage.clear()); - -describe("useAccountLink — SSO return", () => { - it("reauth mode refreshes the session WITHOUT re-registering", async () => { - sessionStorage.setItem(PENDING_LINK_KEY, "reauth"); - renderHook(); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - expect(linkInstance).not.toHaveBeenCalled(); - }); - - it("link mode registers the instance with the returned token", async () => { - sessionStorage.setItem(PENDING_LINK_KEY, "link"); - renderHook(); - await waitFor(() => expect(linkInstance).toHaveBeenCalledTimes(1)); - expect(linkInstance.mock.calls[0][0].supabaseJwt).toBe("tok"); - }); -}); diff --git a/frontend/editor/src/portal/hooks/useAccountLink.ts b/frontend/editor/src/portal/hooks/useAccountLink.ts index dd4894f6d6..3e47df5220 100644 --- a/frontend/editor/src/portal/hooks/useAccountLink.ts +++ b/frontend/editor/src/portal/hooks/useAccountLink.ts @@ -1,33 +1,9 @@ import { useCallback, useEffect, useState } from "react"; -import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin"; -import { - ensureSaasSupabase, - isSaasSupabaseConfigured, - PENDING_LINK_KEY, -} from "@portal/auth/saasSupabase"; -import { - fetchStatus, - linkInstance, - unlinkInstance, - type LinkStatus, -} from "@portal/api/link"; -import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext"; +import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; +import { fetchStatus, unlinkInstance, type LinkStatus } from "@portal/api/link"; +import { useApplyLinkFacts } from "@portal/contexts/LinkContext"; -/** - * Orchestrates the account-link flow for THIS instance: - * - * 1. The admin signs in to their Stirling account IN-APP (LinkAccountModal → - * shared Supabase login), minting a short-term SaaS JWT. - * 2. {@link completeLink} POSTs that JWT to the LOCAL backend (api/link.ts), - * which registers with SaaS and stores the device secret server-side. - * 3. The resulting Linked / Not-linked status is read back. - * - * Email/password resolves inline (the modal calls completeLink). SSO redirects - * the browser to the provider and back; the returned session is finished here on - * mount (see the pending-link effect). The device secret is never received or - * rendered. Subscription state is resolved separately from the wallet, so a fresh - * link marks the org linked-free. - */ +/** Reads and clears THIS instance's link status. */ export type LinkPhase = "idle" | "linking" | "error"; @@ -38,84 +14,32 @@ export interface UseAccountLink { status: LinkStatus | null; phase: LinkPhase; error: string | null; - /** Finish linking THIS instance with a SaaS session minted by the login modal. */ - completeLink: (session: SupabaseLoginSession, name?: string) => Promise; /** Unlink this instance. */ unlink: () => Promise; + /** Re-read the status, for when something outside this hook changed it. */ + refresh: () => Promise; } export function useAccountLink(): UseAccountLink { const applyLinkFacts = useApplyLinkFacts(); - const { markSaasSessionChanged } = useLink(); const [status, setStatus] = useState(null); const [phase, setPhase] = useState("idle"); const [error, setError] = useState(null); - const completeLink = useCallback( - async (session: SupabaseLoginSession, name?: string) => { - setPhase("linking"); - setError(null); - try { - const next = await linkInstance({ - supabaseJwt: session.access_token, - name, - }); - setStatus(next); - setPhase("idle"); - if (next.linked) applyLinkFacts(true, false); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - setPhase("error"); - } - }, - [applyLinkFacts], - ); - - // Read the current link status on mount. - useEffect(() => { - let cancelled = false; - void fetchStatus() - .then((s) => { - if (!cancelled) { - setStatus(s); - // A linked instance is at least linked-free; subscription comes from the wallet. - if (s.linked) applyLinkFacts(true, false); - } - }) - .catch(() => { - // Status endpoint absent (flag off) / unreachable → leave status null, - // which renders as "Not linked". Don't surface an error or leak an - // unhandled rejection for the expected flag-off case. - if (!cancelled) setStatus({ linked: false, name: null }); - }); - return () => { - cancelled = true; - }; + const refresh = useCallback(async () => { + try { + const s = await fetchStatus(); + setStatus(s); + // A linked instance is at least linked-free; subscription comes from the wallet. + if (s.linked) applyLinkFacts(true, false); + } catch { + setStatus({ linked: false, name: null }); + } }, [applyLinkFacts]); - // SSO return: an SSO sign-in we kicked off has redirected back and the SaaS - // session is now in the shared Supabase client. The pending marker carries the - // mode: "reauth" only refreshes attended reads (the instance is already linked - // — re-registering would mint a duplicate credential); anything else links. useEffect(() => { - const supabase = ensureSaasSupabase(); - const pending = sessionStorage.getItem(PENDING_LINK_KEY); - if (!supabase || pending === null) return; - let cancelled = false; - void supabase.auth.getSession().then(({ data }) => { - sessionStorage.removeItem(PENDING_LINK_KEY); - const token = data.session?.access_token; - if (!token || cancelled) return; - if (pending === "reauth") { - markSaasSessionChanged(); - } else { - void completeLink({ access_token: token }); - } - }); - return () => { - cancelled = true; - }; - }, [completeLink, markSaasSessionChanged]); + void refresh(); + }, [refresh]); const unlink = useCallback(async () => { setPhase("linking"); @@ -136,7 +60,7 @@ export function useAccountLink(): UseAccountLink { status, phase, error, - completeLink, unlink, + refresh, }; } diff --git a/frontend/editor/src/portal/mocks/handlers/link.ts b/frontend/editor/src/portal/mocks/handlers/link.ts index 1724ce375f..bd7e6844f2 100644 --- a/frontend/editor/src/portal/mocks/handlers/link.ts +++ b/frontend/editor/src/portal/mocks/handlers/link.ts @@ -1,5 +1,4 @@ import { http, HttpResponse, delay } from "msw"; -import type { LinkInstanceRequest } from "@portal/api/link"; import { getLocalStatus, getLocalUsage, @@ -12,14 +11,13 @@ import { /** * Account-link MSW handlers. Two surfaces: * - * - LOCAL backend (this instance): link / status / unlink. `link` mutates the - * in-memory store and flips local status so the surface behaves like a real - * backend within a session. The device secret stays server-side — never - * returned over the wire, matching the real contract. + * - LOCAL backend (this instance): the connect handshake, status and unlink. + * `connect/complete` mutates the in-memory store and flips local status so the + * surface behaves like a real backend within a session. The device secret stays + * server-side — never returned over the wire, matching the real contract. * - SaaS backend (team-wide): instances / revoke. * - * Mirrors the real AccountLinkController paths so MSW can be dropped with no code - * change. + * Mirrors the real controller paths so MSW can be dropped with no code change. */ export const linkHandlers = [ http.get("/api/v1/account-link/status", async () => { @@ -27,15 +25,38 @@ export const linkHandlers = [ return HttpResponse.json(getLocalStatus()); }), - http.post("/api/v1/account-link/link", async ({ request }) => { + // Opening a handshake hands back where to send the admin. The real backend gets + // that URL from SaaS rather than composing it, so the mock returns one too. + http.post("*/api/v1/account-link/connect/start", async () => { await delay(120); - let name: string | undefined; - try { - name = ((await request.json()) as LinkInstanceRequest)?.name; - } catch { - // empty body — name stays undefined - } - return HttpResponse.json(linkLocal(name), { status: 201 }); + return HttpResponse.json({ + phase: "PENDING", + authorizeUrl: "https://app.stirling.test/link?request=mock-request", + secondsRemaining: 900, + teamId: null, + }); + }), + + http.post("*/api/v1/account-link/connect/reauth", async () => { + await delay(120); + return HttpResponse.json({ + phase: "PENDING", + authorizeUrl: "https://app.stirling.test/link?request=mock-reauth", + secondsRemaining: 900, + teamId: null, + }); + }), + + // The callback's completion step. Flips the store to linked, as a real claim would. + http.post("*/api/v1/account-link/connect/complete", async () => { + await delay(120); + linkLocal("mock-server"); + return HttpResponse.json({ + phase: "LINKED", + authorizeUrl: null, + secondsRemaining: null, + teamId: 7, + }); }), http.get("/api/v1/account-link/usage", async () => { diff --git a/frontend/editor/src/portal/mocks/link.ts b/frontend/editor/src/portal/mocks/link.ts index 92b4b90f2c..eb29ef5ba4 100644 --- a/frontend/editor/src/portal/mocks/link.ts +++ b/frontend/editor/src/portal/mocks/link.ts @@ -2,7 +2,7 @@ * Account-link fixtures. Types live in api/link.ts (the backend contract); * this module only builds fake data for Storybook and tests. * - * "Mode A" combined billing: a self-hosted instance links the org's SaaS account + * Combined billing: a self-hosted instance links the org's SaaS account * so its unattended calls bill against the org wallet. Two surfaces: * * - THIS instance: the local backend (`POST /api/v1/account-link/link`, diff --git a/frontend/editor/src/portal/views/ConnectCallback.css b/frontend/editor/src/portal/views/ConnectCallback.css new file mode 100644 index 0000000000..35e9b09ef7 --- /dev/null +++ b/frontend/editor/src/portal/views/ConnectCallback.css @@ -0,0 +1,32 @@ +/* Account-link callback. A transient page the admin passes through, so it is + centred and says one thing rather than trying to be a settings screen. */ + +.portal-connect-callback { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + max-width: 30rem; + margin: 4rem auto; + padding: 0 1rem; + text-align: center; +} + +.portal-connect-callback > * { + width: 100%; +} + +/* The button is the one thing that should not stretch to the banner's width. */ +.portal-connect-callback button { + width: auto; +} + +.portal-connect-callback p { + margin: 0; + font-size: 0.875rem; + color: var(--c-text-muted); +} + +.portal-connect-callback__note { + font-size: 0.8125rem; +} diff --git a/frontend/editor/src/portal/views/ConnectCallback.test.tsx b/frontend/editor/src/portal/views/ConnectCallback.test.tsx new file mode 100644 index 0000000000..bdadee03e4 --- /dev/null +++ b/frontend/editor/src/portal/views/ConnectCallback.test.tsx @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { MantineProvider } from "@mantine/core"; + +/** + * The callback handles a live session token in a URL fragment, so the behaviour worth pinning is what it does with it: strip it immediately, refuse anything it cannot verify, and keep the two outcomes (SaaS sign-in, server link) independent of each other. + */ +const { completeConnect, startConnect, setSession, refresh } = vi.hoisted( + () => ({ + completeConnect: vi.fn(), + startConnect: vi.fn(), + setSession: vi.fn(), + refresh: vi.fn(), + }), +); + +vi.mock("@portal/api/link", () => ({ completeConnect, startConnect })); +vi.mock("@portal/auth/saasSupabase", () => ({ + ensureSaasSupabase: () => ({ auth: { setSession } }), +})); +vi.mock("@portal/contexts/AccountLinkContext", () => ({ + useAccountLinkContext: () => ({ refresh }), +})); + +import ConnectCallback from "@portal/views/ConnectCallback"; +import { ConnectCallbackHost } from "@portal/components/account-link/ConnectCallbackHost"; + +const NONCE = "the-nonce"; + +function landOn(fragment: string) { + window.history.replaceState(null, "", `/account-link/callback${fragment}`); +} + +/** + * Route and host together: the route reads the fragment, the portal renders the + * outcome. Exercising them apart would test the hand-off rather than the flow. + */ +function renderFlow() { + return render( + + + + + } /> + } /> + + + , + ); +} + +describe("account-link callback", () => { + beforeEach(() => { + vi.clearAllMocks(); + completeConnect.mockResolvedValue({ + phase: "LINKED", + authorizeUrl: null, + secondsRemaining: null, + teamId: 7, + }); + setSession.mockResolvedValue({ error: null }); + }); + + it("removes the token-bearing fragment from the URL", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + renderFlow(); + + // Synchronous, before any await: the fragment must not survive long enough + // to be read from the address bar or land in a history entry. + expect(window.location.hash).toBe(""); + await waitFor(() => expect(completeConnect).toHaveBeenCalled()); + }); + + it("lands on the portal rather than leaving the result on a bare page", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + const { getByTestId } = renderFlow(); + + await waitFor(() => expect(getByTestId("portal")).toBeTruthy()); + }); + + it("re-reads the link status, so the page behind agrees with the modal", async () => { + landOn(`#type=link&nonce=${NONCE}`); + + renderFlow(); + + await waitFor(() => expect(refresh).toHaveBeenCalled()); + }); + + it("deposits the session and then finishes the link with the nonce", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + renderFlow(); + + await waitFor(() => + expect(setSession).toHaveBeenCalledWith({ + access_token: "at", + refresh_token: "rt", + }), + ); + await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); + }); + + it("finishes the link even when the session hand-off fails", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + setSession.mockRejectedValue(new Error("nope")); + + renderFlow(); + + // The two outcomes are independent: a failed sign-in must not strand the + // server unlinked. + await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); + }); + + it("links without a session when the fragment carries no tokens", async () => { + landOn(`#type=link&nonce=${NONCE}`); + + renderFlow(); + + await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); + expect(setSession).not.toHaveBeenCalled(); + }); + + it("refuses a fragment with no nonce", async () => { + landOn("#type=link&access_token=at&refresh_token=rt"); + + renderFlow(); + + await waitFor(() => expect(window.location.hash).toBe("")); + expect(completeConnect).not.toHaveBeenCalled(); + expect(setSession).not.toHaveBeenCalled(); + }); + + it("refuses a fragment that is not a link response", async () => { + landOn(`#type=something-else&nonce=${NONCE}&access_token=at`); + + renderFlow(); + + await waitFor(() => expect(window.location.hash).toBe("")); + expect(completeConnect).not.toHaveBeenCalled(); + }); + + it("refuses a bare page load", async () => { + landOn(""); + + renderFlow(); + + expect(completeConnect).not.toHaveBeenCalled(); + expect(setSession).not.toHaveBeenCalled(); + }); + + it("offers a retry rather than a restart while the handshake is still open", async () => { + landOn(`#type=link&nonce=${NONCE}`); + completeConnect.mockResolvedValue({ + phase: "UNAVAILABLE", + authorizeUrl: null, + secondsRemaining: null, + teamId: null, + }); + + const { getAllByRole } = renderFlow(); + + await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(1)); + // Last button, not the only one: the modal shell contributes a close button. + const buttons = getAllByRole("button"); + act(() => buttons[buttons.length - 1].click()); + + // Retries the existing handshake; starting a new one would waste the + // approval a human just gave. + await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(2)); + expect(startConnect).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/views/ConnectCallback.tsx b/frontend/editor/src/portal/views/ConnectCallback.tsx new file mode 100644 index 0000000000..2fecd5244e --- /dev/null +++ b/frontend/editor/src/portal/views/ConnectCallback.tsx @@ -0,0 +1,41 @@ +import { useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import type { AccountLinkReturn } from "@portal/components/account-link/ConnectCallbackHost"; + +/** + * Return leg of the account-link handshake. Stirling redirects here with the + * admin's session in the URL fragment. + * + * This route only reads the fragment and hands it to the portal, which owns the + * rest. Rendering the outcome here would put it on an empty page; the portal is + * where the admin started, so that is where the result belongs. + */ +export default function ConnectCallback() { + const navigate = useNavigate(); + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + const params = new URLSearchParams(window.location.hash.replace(/^#/, "")); + // Before anything else: the fragment carries a live session token. + window.history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}`, + ); + + const accountLinkReturn: AccountLinkReturn = { + type: params.get("type"), + nonce: params.get("nonce"), + accessToken: params.get("access_token"), + refreshToken: params.get("refresh_token"), + }; + // Router state, not the URL: the tokens are live and must not be re-shareable. + navigate(PORTAL_BASENAME, { replace: true, state: { accountLinkReturn } }); + }, [navigate]); + + return null; +} diff --git a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx index 7f60fbdd3b..22ce46f03c 100644 --- a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx +++ b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx @@ -3,13 +3,6 @@ import type { ReactElement } from "react"; import { Route } from "react-router-dom"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; -// The portal ships as a lazy chunk of the editor. It's included in dev (so it's -// always available to work on) and in production builds made with -// VITE_INCLUDE_PORTAL=true (set by -PbuildWithPortal in the JAR, and by the deploy -// GHA when the portal or AI layers change). Vite replaces the env with a literal at -// build time, so when it's off the dynamic import below is tree-shaken out and the -// portal chunk isn't emitted. PortalApp stays module-level so it isn't recreated on -// each render. const includePortal = import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; @@ -21,18 +14,28 @@ const PortalApp = includePortal : null; /** - * The portal mounts as an admin-only route-set at PORTAL_BASENAME (/processor/*). - * Access is gated inside PortalApp (its own AuthProvider + AuthGate, plus server - * enforcement), so this just wires the lazy route into the editor's router when - * the portal is included in this build. + * Return leg of the account-link handshake, which Stirling redirects to with the admin's session in the URL fragment. */ +const ConnectCallback = includePortal + ? lazy(async () => { + const m = await import("@portal/views/ConnectCallback"); + return { default: m.default }; + }) + : null; + +/** The portal mounts as an admin-only route-set at PORTAL_BASENAME (/processor/*). */ export function getAdminRouteExtensions(): ReactElement[] { - if (!PortalApp) return []; + if (!PortalApp || !ConnectCallback) return []; return [ } />, + } + />, ]; } diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 67dce869fd..2e091ebf05 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -11,10 +11,12 @@ import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import OnboardingTour from "@app/components/onboarding/OnboardingTour"; import Landing from "@app/routes/Landing"; import Login from "@app/routes/Login"; +import { ResumePendingConnect } from "@app/routes/ResumePendingConnect"; import Signup from "@app/routes/Signup"; import AuthCallback from "@app/routes/AuthCallback"; import ResetPassword from "@app/routes/ResetPassword"; import OAuthConsent from "@app/routes/OAuthConsent"; +import ConnectApprove from "@app/routes/ConnectApprove"; import ShareLinkPage from "@app/routes/ShareLinkPage"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; @@ -110,12 +112,17 @@ export default function App() { > + } /> } /> } /> } /> } /> + {/* Human half of the self-hosted account-link handshake. It + lives on this origin because a customer hostname can + never be in the provider's redirect allow-list. */} + } /> {/* Shared-file links. Team invites are NOT routed here: on SaaS they are accepted in-app via the Supabase team invitation banner, not the Spring password-based diff --git a/frontend/editor/src/saas/routes/AuthCallback.tsx b/frontend/editor/src/saas/routes/AuthCallback.tsx index 8fbe925ff2..b69619b85d 100644 --- a/frontend/editor/src/saas/routes/AuthCallback.tsx +++ b/frontend/editor/src/saas/routes/AuthCallback.tsx @@ -4,6 +4,7 @@ import { resolveLandingPath } from "@app/utils/loginLanding"; import { supabase } from "@app/auth/supabase"; import { Button } from "@app/ui/Button"; import { withBasePath } from "@app/constants/app"; +import { readPendingConnect } from "@app/routes/pendingConnect"; import { AuthShell } from "@app/auth/ui/AuthShell"; import ErrorMessage from "@app/auth/ui/ErrorMessage"; import { Spinner } from "@app/ui/Spinner"; @@ -133,10 +134,20 @@ export default function AuthCallback() { // URL can't bounce the user off-origin after sign-in. // No explicit destination: land team leads on the processor and everyone // else on the editor. + // Explicit `next` first, so a sign-in started for another reason is not + // hijacked by a remembered connect request. + const explicitNext = url.searchParams.get("next"); + const pendingConnect = readPendingConnect(); const destination = - next.startsWith("/") && !next.startsWith("//") - ? next - : await resolveLandingPath(); + explicitNext && + explicitNext.startsWith("/") && + !explicitNext.startsWith("//") + ? explicitNext + : pendingConnect + ? `/link?request=${encodeURIComponent(pendingConnect)}` + : next.startsWith("/") && !next.startsWith("//") + ? next + : await resolveLandingPath(); console.log("[Auth Callback Debug] Redirecting to:", destination); setTimeout(() => navigate(destination, { replace: true }), 1500); diff --git a/frontend/editor/src/saas/routes/ConnectApprove.tsx b/frontend/editor/src/saas/routes/ConnectApprove.tsx new file mode 100644 index 0000000000..6374738713 --- /dev/null +++ b/frontend/editor/src/saas/routes/ConnectApprove.tsx @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import apiClient from "@app/services/apiClient"; +import { useAuth } from "@app/auth/UseSession"; +import { useTranslation } from "@app/hooks/useTranslation"; +import { useDocumentMeta } from "@app/hooks/useDocumentMeta"; +import { withBasePath } from "@app/constants/app"; +import { + clearPendingConnect, + rememberPendingConnect, +} from "@app/routes/pendingConnect"; +import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg"; +import AuthLayout from "@app/routes/authShared/AuthLayout"; +import { + ConnectApproveView, + type ApprovePhase, + type PendingConnect, +} from "@app/routes/ConnectApproveView"; +import "@app/routes/authShared/saas-auth.css"; +import "@app/routes/connect.css"; + +interface ApproveResponse { + callbackUrl: string; + nonce: string; +} + +/** Wider than the view renders: only PENDING is still actionable. */ +interface ConnectLookup extends PendingConnect { + status: "PENDING" | "APPROVED" | "DENIED" | "CONSUMED"; +} + +/** Approve a self-hosted server's request to connect to a team. */ +export default function ConnectApprove() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { session, user, loading, signOut } = useAuth(); + const [params] = useSearchParams(); + const requestId = params.get("request"); + + const [phase, setPhase] = useState("loading"); + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const lookedUpRef = useRef(false); + + useDocumentMeta({ title: t("connect.meta.title", "Connect a server") }); + + // On arrival, not only when signed out: an approver who is already signed in can + // still be sent away to re-authenticate, and needs the same way back. + useEffect(() => { + if (requestId) rememberPendingConnect(requestId); + }, [requestId]); + + useEffect(() => { + if (loading || session) return; + // No basename: every consumer of `next` reaches it through navigate(), which + // applies the basename itself, so carrying it here yields /app/app/link. + const next = `/link${requestId ? `?request=${encodeURIComponent(requestId)}` : ""}`; + navigate(`/login?next=${encodeURIComponent(next)}`, { replace: true }); + }, [loading, session, requestId, navigate]); + + useEffect(() => { + if (loading || !session || lookedUpRef.current) return; + lookedUpRef.current = true; + if (!requestId) { + setPhase("notFound"); + return; + } + void (async () => { + try { + const res = await apiClient.get( + `/api/v1/account-link/connect/${encodeURIComponent(requestId)}`, + ); + // Approving a settled request fails server-side, so offering the form again + // would only produce a dead end. + if (res.data.status !== "PENDING") { + clearPendingConnect(); + setPhase(res.data.status === "DENIED" ? "declined" : "notFound"); + return; + } + setPending(res.data); + setPhase("confirm"); + } catch { + clearPendingConnect(); + setPhase("notFound"); + } + })(); + }, [loading, session, requestId]); + + const onDecide = useCallback( + async (approve: boolean) => { + if (!requestId) return; + setBusy(true); + setError(null); + const path = `/api/v1/account-link/connect/${encodeURIComponent(requestId)}`; + try { + if (!approve) { + await apiClient.post(`${path}/deny`); + clearPendingConnect(); + setPhase("declined"); + return; + } + const res = await apiClient.post(`${path}/approve`); + clearPendingConnect(); + setPhase("redirecting"); + window.location.replace(returnUrl(res.data, session)); + } catch { + setError( + t( + "connect.error.failed", + "That did not go through. Only a team owner can connect a server.", + ), + ); + } finally { + setBusy(false); + } + }, + [requestId, session, t], + ); + + /** + * Sign out, then let the signed-out effect above send them to login with the request preserved. + */ + const onSwitchAccount = useCallback(() => { + void signOut(); + }, [signOut]); + + if (loading || !session) return null; + + return ( + + {/* Same header as the sibling auth pages: an admin arriving from another + screen should be able to tell at a glance they are on our site and not + somewhere that merely looks like it. */} +
+ Stirling PDF + Stirling PDF +
+ + void onDecide(approve)} + onSwitchAccount={onSwitchAccount} + /> +
+ ); +} + +/** The callback with the session appended as a fragment. */ +function returnUrl( + approval: ApproveResponse, + session: { access_token?: string; refresh_token?: string } | null, +): string { + const fragment = new URLSearchParams({ type: "link", nonce: approval.nonce }); + if (session?.access_token && session?.refresh_token) { + fragment.set("access_token", session.access_token); + fragment.set("refresh_token", session.refresh_token); + } + return `${approval.callbackUrl}#${fragment.toString()}`; +} diff --git a/frontend/editor/src/saas/routes/ConnectApproveView.tsx b/frontend/editor/src/saas/routes/ConnectApproveView.tsx new file mode 100644 index 0000000000..26a2fe1150 --- /dev/null +++ b/frontend/editor/src/saas/routes/ConnectApproveView.tsx @@ -0,0 +1,182 @@ +import { useState } from "react"; +import { useTranslation } from "@app/hooks/useTranslation"; +import { Banner, Button, Checkbox, Spinner } from "@app/ui"; +import { LocalIcon } from "@app/components/shared/LocalIcon"; +import { Tooltip } from "@app/components/shared/Tooltip"; + +export type ApprovePhase = + | "loading" + | "confirm" + | "redirecting" + | "declined" + | "notFound"; + +/** What the approver is being asked to connect. */ +export interface PendingConnect { + requestId: string; + callbackOrigin: string; + insecureTransport: boolean; +} + +export interface ConnectApproveViewProps { + phase: ApprovePhase; + pending: PendingConnect | null; + /** Email of the account the server would be connected to. */ + signedInEmail: string | null; + busy: boolean; + error: string | null; + onDecide: (approve: boolean) => void; + /** Sign out and come back here, keeping the request so it survives the detour. */ + onSwitchAccount: () => void; +} + +/** Presentation for the connect approval page. */ +export function ConnectApproveView({ + phase, + pending, + signedInEmail, + busy, + error, + onDecide, + onSwitchAccount, +}: ConnectApproveViewProps) { + const { t } = useTranslation(); + // Gates the primary action: anyone can create a request, so the approver reading + // the address is the only thing between one and a linked team. + const [acknowledged, setAcknowledged] = useState(false); + + if (phase === "loading" || phase === "redirecting") { + return ( +
+ +

+ {phase === "redirecting" + ? t("connect.redirecting", "Returning you to your server.") + : t("connect.loading", "Checking this request.")} +

+
+ ); + } + + if (phase === "notFound") { + return ( +
+ + {t( + "connect.notFound.body", + "This connection request is not valid. It may have expired, or already been used. Start another one from your server.", + )} + +
+ ); + } + + if (phase === "declined") { + return ( +
+ + {t( + "connect.declined.body", + "Nothing was connected. You can close this page.", + )} + +
+ ); + } + + return ( +
+

+ {t("connect.confirm.title", "Connect this server?")} +

+

+ {t( + "connect.confirm.lead", + "A Stirling server is asking to connect to your team. Check the address below is yours before you approve.", + )} +

+ + {/* One panel, because the account and the address are two halves of the same + decision: right server, wrong account is still wrong. */} +
+
{t("connect.confirm.signedInAs", "Account")}
+
+ {signedInEmail ?? + t("connect.confirm.unknownAccount", "an unknown account")} + +
+ {/* The reported name is deliberately not shown. The requester chooses it on an + unauthenticated endpoint, so it is the field an attacker would set to look + familiar, and its honest value is the hostname already in the address. It + still labels the server in the linked-instances list, after the decision. */} +
+ {t("connect.confirm.originLabel", "Address")} + {pending?.insecureTransport ? ( + + + + + + ) : null} +
+
{pending?.callbackOrigin}
+
+ + {error ? {error} : null} + + setAcknowledged(e.currentTarget.checked)} + label={t( + "connect.confirm.acknowledge", + "I recognise this address and want to connect it to my team", + )} + /> + +
+ + +
+
+ ); +} diff --git a/frontend/editor/src/saas/routes/ResumePendingConnect.tsx b/frontend/editor/src/saas/routes/ResumePendingConnect.tsx new file mode 100644 index 0000000000..37950047bd --- /dev/null +++ b/frontend/editor/src/saas/routes/ResumePendingConnect.tsx @@ -0,0 +1,38 @@ +import { useEffect, useRef } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "@app/auth/UseSession"; +import { readPendingConnect } from "@app/routes/pendingConnect"; + +/** + * Sends a newly signed-in visitor back to the approval page they were pulled away + * from. + * + * Mounted app-wide, not only in the auth callback: a confirmation email can land the + * visitor anywhere in the app with a session, and only the ones below resolve the + * request themselves. + */ +export function ResumePendingConnect() { + const { session, loading } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const handled = useRef(false); + + useEffect(() => { + if (loading || !session || handled.current) return; + if ( + location.pathname === "/link" || + location.pathname === "/auth/callback" + ) { + return; + } + handled.current = true; + const requestId = readPendingConnect(); + if (requestId) { + navigate(`/link?request=${encodeURIComponent(requestId)}`, { + replace: true, + }); + } + }, [loading, session, location.pathname, navigate]); + + return null; +} diff --git a/frontend/editor/src/saas/routes/connect.css b/frontend/editor/src/saas/routes/connect.css new file mode 100644 index 0000000000..5a709abfbe --- /dev/null +++ b/frontend/editor/src/saas/routes/connect.css @@ -0,0 +1,93 @@ +/* Connect-approval page. The origin is the thing the approver has to actually + read, so it gets the visual weight and everything else stays quiet. */ + +.saas-connect { + display: flex; + flex-direction: column; + gap: 1rem; + text-align: left; +} + +.saas-connect__title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--c-text); +} + +.saas-connect__lead { + margin: 0; + font-size: 0.875rem; + color: var(--c-text-muted); +} + +/* Sits inside the facts panel rather than beside the email: at this width a + right-aligned action wraps onto its own line and reads as a third field. */ +.saas-connect__switch { + display: block; + margin-top: 0.125rem; + padding: 0; + border: 0; + background: none; + font: inherit; + font-size: 0.8125rem; + color: var(--c-accent-text); + cursor: pointer; +} + +.saas-connect__switch:hover:not(:disabled) { + text-decoration: underline; +} + +.saas-connect__switch:disabled { + color: var(--c-text-muted); + cursor: default; +} + +.saas-connect__facts { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.375rem 1rem; + margin: 0; + padding: 0.875rem; + background: var(--c-surface-sunken); + border: 1px solid var(--c-border); + border-radius: 0.375rem; + font-size: 0.875rem; +} + +.saas-connect__facts dt { + margin: 0; + color: var(--c-text-muted); +} + +.saas-connect__facts dd { + margin: 0; + color: var(--c-text); + overflow-wrap: anywhere; +} + +/* Monospaced so a lookalike hostname is harder to skim past. */ +.saas-connect__origin { + font-family: var(--font-mono, ui-monospace, monospace); + font-weight: 600; +} + +.saas-connect__origin-label { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.saas-connect__insecure { + display: inline-flex; + flex: none; + color: var(--c-warning); + cursor: help; +} + +.saas-connect__actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} diff --git a/frontend/editor/src/saas/routes/pendingConnect.ts b/frontend/editor/src/saas/routes/pendingConnect.ts new file mode 100644 index 0000000000..c44af277be --- /dev/null +++ b/frontend/editor/src/saas/routes/pendingConnect.ts @@ -0,0 +1,61 @@ +/** + * Remembers that the visitor arrived wanting to connect a server, so a sign-in + * detour can return them to the approval page. + * + * localStorage, not sessionStorage: the confirmation email opens a new tab, and + * sessionStorage is per-tab — empty exactly when it is needed. + * + * Only the request id, which is already in the URL and carries no secret. This + * decides where the approver lands, never whether the link happens. + * + * Reading does not consume it: the request may be open in another tab, or the page + * closed and reopened, or the reader mounted twice. Only a recorded decision, or a + * request that is settled or gone, retires it. + */ +const KEY = "stirling-pending-connect"; + +/** Matches the server's request lifetime, so a stale intent cannot hijack a later sign-in. */ +const TTL_MS = 30 * 60 * 1000; + +interface Stored { + requestId: string; + at: number; +} + +export function rememberPendingConnect(requestId: string): void { + try { + const value: Stored = { requestId, at: Date.now() }; + window.localStorage.setItem(KEY, JSON.stringify(value)); + } catch { + // Private browsing or a full quota; nothing to fall back to. + } +} + +/** Drops the intent without reading it, once it has been acted on. */ +export function clearPendingConnect(): void { + try { + window.localStorage.removeItem(KEY); + } catch { + // Unwritable store; nothing to remove. + } +} + +/** The pending request, or null when absent or expired. Leaves it in place. */ +export function readPendingConnect(): string | null { + try { + const raw = window.localStorage.getItem(KEY); + if (!raw) return null; + const value = JSON.parse(raw) as Stored; + if (typeof value?.requestId !== "string" || typeof value?.at !== "number") { + clearPendingConnect(); + return null; + } + if (Date.now() - value.at > TTL_MS) { + clearPendingConnect(); + return null; + } + return value.requestId; + } catch { + return null; + } +} diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 96ce29c480..22b8ce4e24 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -259,6 +259,16 @@ export default defineConfig(async ({ mode, command }) => { }; return { + // Per-mode: the default is one shared node_modules/.vite, so two dev servers in + // different modes re-optimize over each other and the browser 504s on a stale dep + // hash. Anchored to frontend/ because a relative path resolves against the vite + // root (editor/) and would create a second node_modules there. + cacheDir: resolve( + import.meta.dirname, + "..", + "node_modules", + `.vite-${effectiveMode}`, + ), define: { __DEV_WORKTREE_LABEL__: JSON.stringify(devWorktreeLabel), }, From 897c72e9d900a8f5366a77f343e3bd4cdf427d72 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:55:31 +0000 Subject: [PATCH 06/57] Fix tool panel scrolling so the action button stays reachable (#7688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes After ui rework all scrolling in all tool panels stopped working This fixes this to allow tool panels to be scrollabe again ## What was wrong PDF/UA is the only convert target whose settings panel overflows the tool rail. Measured at 1920×1080: overflow was 0px for pdfa, pdfx, png, docx, epub, and 158px for pdfua. Its action button sat at bottom: 1220 in a 1080px viewport — 140px below the fold — and the info alert was clipped mid-sentence. The panel could be scrolled, but nothing said so (Mantine's scrollbar auto-hides). Normally the app would scroll the button into view for you. It didn't, because both mechanisms built to do that were dead ## Cause: Two separate mechanisms, both broken since the same commit (0a50e765b7, frontend editor restructure, 2026-05-22): 1. ReviewToolStep - shared by all 47 tools. It looked for its scroll container with: stepRef.current.closest('[style*="overflow: auto"]') Mantine's ScrollArea viewport sets inline overflow: scroll, not auto. I measured it live - closest() returns null, and document.querySelectorAll('[style*="overflow: auto"]') finds exactly 1 element anywhere in the page, and it isn't an ancestor of the panel. So the lookup silently found nothing and the scrollTo never ran, for every tool. 2. Convert.tsx - Convert only. It declared scrollContainerRef and a scrollToBottom() wired to two useEffects, but the ref was never attached to any element - createToolFlow() builds the JSX and no ref is passed through. Always null, so both effects were no-ops. Nothing else in the codebase has this pattern - I grepped for other closest('[style*="overflow…"]') lookups and other scrollToBottom/scrollContainerRef uses and both came back empty. ## The fix createToolFlow.module.css (new) + createToolFlow.tsx:156 — the execute button gets a position: sticky; bottom: 0 footer, the house pattern already used by FormFill.module.css. Applied only when the review step isn't visible, so it can never float over results. Sticky is inert when content fits, so the other 46 tools are untouched. ReviewToolStep.tsx:21 — real findScrollParent() walk replacing the broken selector, scrolling by the minimum delta needed and only the panel itself (never scrollIntoView(), which drags every ancestor). Also added the missing clearTimeout cleanup. Convert.tsx — deleted the dead ref and its two effects. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../tools/shared/ReviewToolStep.tsx | 66 ++++++++++++++----- .../tools/shared/createToolFlow.module.css | 22 +++++++ .../tools/shared/createToolFlow.tsx | 11 +++- frontend/editor/src/core/tools/Convert.tsx | 23 ------- 4 files changed, 79 insertions(+), 43 deletions(-) create mode 100644 frontend/editor/src/core/components/tools/shared/createToolFlow.module.css diff --git a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx index ef596c2a0d..8f86faff17 100644 --- a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx @@ -14,6 +14,25 @@ import { saveOperationResults } from "@app/services/operationResultsSaveService" import { useFileActions, useFileSelectors } from "@app/contexts/FileContext"; import i18n from "@app/i18n"; +/** + * Nearest scrolling ancestor - in the right rail that is the tool panel's + * ScrollArea viewport, whose overflow is `scroll`, not `auto`. + */ +function findScrollParent(element: HTMLElement): HTMLElement | null { + let node = element.parentElement; + while (node) { + const { overflowY } = getComputedStyle(node); + if ( + /(auto|scroll|overlay)/.test(overflowY) && + node.scrollHeight > node.clientHeight + ) { + return node; + } + node = node.parentElement; + } + return null; +} + export interface ReviewToolStepProps { isVisible: boolean; operation: ToolOperationHook; @@ -81,26 +100,37 @@ function ReviewStepContent({ } }; - // Auto-scroll to bottom when content appears + // Reveal the results when they appear, or the download button lands below the + // fold behind a tall settings step and reads as missing. useEffect(() => { - if ( - stepRef.current && - (previewFiles.length > 0 || - operation.downloadUrl || - operation.errorMessage) - ) { - const scrollableContainer = stepRef.current.closest( - '[style*="overflow: auto"]', - ) as HTMLElement; - if (scrollableContainer) { - setTimeout(() => { - scrollableContainer.scrollTo({ - top: scrollableContainer.scrollHeight, - behavior: "smooth", - }); - }, 100); // Small delay to ensure content is rendered + const hasContent = + previewFiles.length > 0 || + operation.downloadUrl || + operation.errorMessage; + if (!stepRef.current || !hasContent) return; + + // Small delay so the step has been laid out before it is measured. + const timer = setTimeout(() => { + const step = stepRef.current; + const scroller = step && findScrollParent(step); + if (!step || !scroller) return; + + const stepRect = step.getBoundingClientRect(); + const viewRect = scroller.getBoundingClientRect(); + // Move the least that brings the step into view, and only ever the panel + // itself - scrollIntoView() drags every ancestor and unpins the header. + const delta = Math.min( + stepRect.top - viewRect.top, + stepRect.bottom - viewRect.bottom, + ); + if (delta > 1) { + scroller.scrollTo({ + top: scroller.scrollTop + delta, + behavior: "smooth", + }); } - } + }, 100); + return () => clearTimeout(timer); }, [previewFiles.length, operation.downloadUrl, operation.errorMessage]); return ( diff --git a/frontend/editor/src/core/components/tools/shared/createToolFlow.module.css b/frontend/editor/src/core/components/tools/shared/createToolFlow.module.css new file mode 100644 index 0000000000..bc7abb630d --- /dev/null +++ b/frontend/editor/src/core/components/tools/shared/createToolFlow.module.css @@ -0,0 +1,22 @@ +/* The tool panel scrolls as a single column, so a tall settings step (PDF/UA is + the worst offender) pushes the primary action below the fold with nothing to + say it is there. Pinning keeps it reachable; when the flow already fits, + sticky is inert and nothing moves. */ +.executeFooter { + position: sticky; + bottom: 0; + z-index: 2; + background: var(--c-surface, var(--mantine-color-body)); + display: flex; + flex-direction: column; + gap: var(--mantine-spacing-sm); + /* Bleed across the flow's own padding so content cannot scroll through the + gutters beside the button. The margin cancels the padding, so an unpinned + footer still sits exactly where it did. */ + margin-inline: calc(var(--mantine-spacing-sm) * -1); + padding-inline: var(--mantine-spacing-sm); + /* Paint-only skirt covering the strip below the button once pinned; a padding + here would change the resting layout. */ + box-shadow: 0 var(--mantine-spacing-sm) 0 0 + var(--c-surface, var(--mantine-color-body)); +} diff --git a/frontend/editor/src/core/components/tools/shared/createToolFlow.tsx b/frontend/editor/src/core/components/tools/shared/createToolFlow.tsx index aafa9b3ea1..4b03de0a4b 100644 --- a/frontend/editor/src/core/components/tools/shared/createToolFlow.tsx +++ b/frontend/editor/src/core/components/tools/shared/createToolFlow.tsx @@ -13,6 +13,7 @@ import { import { StirlingFile } from "@app/types/fileContext"; import type { TooltipTip } from "@app/types/tips"; import type { ExecuteDisabledReason } from "@app/hooks/tools/shared/toolOperationTypes"; +import classes from "@app/components/tools/shared/createToolFlow.module.css"; export interface FilesStepConfig { selectedFiles: StirlingFile[]; @@ -152,8 +153,14 @@ export function createToolFlow( : eb.paramsValid === false ? "invalidParams" : null; + // Pin the action only while it is the last thing in the flow; with a + // review below it, a sticky footer would float over the results. return ( - <> +
( data-tour="run-button" /> {config.belowExecuteButton} - +
); })()} diff --git a/frontend/editor/src/core/tools/Convert.tsx b/frontend/editor/src/core/tools/Convert.tsx index eb9a4a02c5..733a21b6e4 100644 --- a/frontend/editor/src/core/tools/Convert.tsx +++ b/frontend/editor/src/core/tools/Convert.tsx @@ -36,7 +36,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }); setSelectedFiles(matching.map((file) => file.fileId)); }; - const scrollContainerRef = useRef(null); const convertParams = useConvertParameters(); const convertOperation = useConvertOperation(convertParams.parameters); @@ -48,16 +47,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const skipNextSelectionResetRef = useRef(false); const previousSelectionRef = useRef(""); - const scrollToBottom = () => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - top: scrollContainerRef.current.scrollHeight, - behavior: "smooth", - }); - } - }; - - const hasFiles = selectedFiles.length > 0; const hasResults = convertOperation.files.length > 0 || convertOperation.downloadUrl !== null || @@ -115,18 +104,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { convertParams.parameters.toExtension, ]); - useEffect(() => { - if (hasFiles) { - setTimeout(scrollToBottom, 100); - } - }, [hasFiles]); - - useEffect(() => { - if (hasResults) { - setTimeout(scrollToBottom, 100); - } - }, [hasResults]); - const handleConvert = async () => { try { await convertOperation.executeOperation( From a215c30068503c825729ed7aa8a9ba9ebb384314 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:10:56 +0000 Subject: [PATCH 07/57] Add the missing en-US translations for classification labels (#7692) Our classification labels were rendering their hardcoded English names because the en-US locale file had no `classification` section at all, so this adds the missing keys (labels and category names). Also wires the category names through i18n, since those had no `t()` call, and adds a test so a new label can't ship without its key. --- .../public/locales/en-US/translation.toml | 287 ++++++++++++++++++ .../policies/ClassificationLabelsSection.tsx | 2 +- .../ClassificationCategoryManager.tsx | 4 +- .../shared/fileSidebarGroupingLogic.ts | 2 +- .../data/classificationLabels.i18n.test.ts | 58 ++++ 5 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 frontend/editor/src/proprietary/data/classificationLabels.i18n.test.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 4af1be3359..8271d66dfc 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3001,6 +3001,293 @@ summary_one = "Ran 1 tool" summary_other = "Ran {{count}} tools" unknownTool = "Unknown tool" +[classification.families] +correspondence = "Correspondence" +education = "Education" +engineering = "Engineering" +finance = "Financial" +forms = "Forms" +government = "Government" +health = "Medical" +hr = "HR" +legal = "Legal" +operations = "Operations" +projects = "Projects" +property = "Property" +reports = "Reports" +sales = "Marketing" +travel = "Travel" + +[classification.labels] +academic-record = "Academic record" +action-plan = "Action plan" +addendum = "Addendum" +advertisement = "Advertisement" +affidavit = "Affidavit" +agenda = "Agenda" +amendment = "Amendment" +analytics-report = "Analytics report" +announcement = "Announcement" +annual-report = "Annual report" +api-documentation = "API documentation" +application-form = "Application form" +appraisal-report = "Appraisal report" +architecture-document = "Architecture document" +articles-of-incorporation = "Articles of incorporation" +assignment-brief = "Assignment brief" +audit-report = "Audit report" +balance-sheet = "Balance sheet" +bank-statement = "Bank statement" +benefits-summary = "Benefits summary" +bill-of-lading = "Bill of lading" +bill-of-materials = "Bill of materials" +blueprint = "Blueprint" +board-report = "Board report" +board-resolution = "Board resolution" +booking-confirmation = "Booking confirmation" +brochure = "Brochure" +budget = "Budget" +business-plan = "Business plan" +business-proposal = "Business proposal" +bylaws = "Bylaws" +campaign-brief = "Campaign brief" +case-study = "Case study" +cash-flow-statement = "Cash flow statement" +catalog = "Catalog" +cease-and-desist = "Cease and desist" +certificate = "Certificate" +certificate-of-completion = "Certificate of completion" +change-log = "Change log" +checklist = "Checklist" +claim-form = "Claim form" +closing-statement = "Closing statement" +complaint-letter = "Complaint letter" +compliance-document = "Compliance document" +confirmation-letter = "Confirmation letter" +consent-form = "Consent form" +contract = "Contract" +course-syllabus = "Course syllabus" +court-filing = "Court filing" +cover-letter = "Cover letter" +credit-note = "Credit note" +customs-declaration = "Customs declaration" +customs-form = "Customs form" +cv = "CV" +datasheet = "Datasheet" +debit-note = "Debit note" +deed = "Deed" +delivery-note = "Delivery note" +demand-letter = "Demand letter" +design-document = "Design document" +diploma = "Diploma" +discharge-summary = "Discharge summary" +dissertation = "Dissertation" +donation-receipt = "Donation receipt" +dunning-letter = "Dunning letter" +email-thread = "Email thread" +employee-handbook = "Employee handbook" +employment-contract = "Employment contract" +estimate = "Estimate" +event-agenda = "Event agenda" +event-program = "Event program" +eviction-notice = "Eviction notice" +exam-paper = "Exam paper" +expense-report = "Expense report" +expense-summary = "Expense summary" +explanation-of-benefits = "Explanation of benefits" +fact-sheet = "Fact sheet" +faq-document = "FAQ document" +feasibility-study = "Feasibility study" +feedback-form = "Feedback form" +financial-forecast = "Financial forecast" +financial-statement = "Financial statement" +floor-plan = "Floor plan" +flyer = "Flyer" +form = "Form" +franchise-agreement = "Franchise agreement" +freight-document = "Freight document" +gift-certificate = "Gift certificate" +glossary = "Glossary" +government-notice = "Government notice" +grade-report = "Grade report" +grant-agreement = "Grant agreement" +grant-application = "Grant application" +hoa-document = "HOA document" +home-inspection-report = "Home inspection report" +hr-memo = "HR memo" +hr-policy = "HR policy" +immigration-document = "Immigration document" +immunization-record = "Immunization record" +incident-report = "Incident report" +income-statement = "Income statement" +index = "Index" +inspection-report = "Inspection report" +insurance-certificate = "Insurance certificate" +insurance-claim = "Insurance claim" +insurance-policy = "Insurance policy" +intake-form = "Intake form" +inventory-list = "Inventory list" +investment-summary = "Investment summary" +invitation = "Invitation" +invoice = "Invoice" +itinerary = "Itinerary" +job-application = "Job application" +job-description = "Job description" +lab-report = "Lab report" +lease-agreement = "Lease agreement" +leave-request = "Leave request" +legal-brief = "Legal brief" +legal-filing = "Legal filing" +legal-notice = "Legal notice" +legal-opinion = "Legal opinion" +lesson-plan = "Lesson plan" +letter = "Letter" +letter-of-intent = "Letter of intent" +license = "License" +license-agreement = "License agreement" +loan-agreement = "Loan agreement" +loan-document = "Loan document" +maintenance-log = "Maintenance log" +manual = "Manual" +market-research = "Market research" +marketing-plan = "Marketing plan" +media-kit = "Media kit" +medical-invoice = "Medical invoice" +medical-report = "Medical report" +meeting-agenda = "Meeting agenda" +meeting-minutes = "Meeting minutes" +meeting-notes = "Meeting notes" +membership-document = "Membership document" +memo = "Memo" +memorandum-of-understanding = "Memorandum of understanding" +mortgage-document = "Mortgage document" +nda = "NDA" +newsletter = "Newsletter" +non-compete-agreement = "Non-compete agreement" +notice = "Notice" +offer-letter = "Offer letter" +onboarding-document = "Onboarding document" +order-confirmation = "Order confirmation" +order-form = "Order form" +organization-chart = "Organization chart" +packing-slip = "Packing slip" +partnership-agreement = "Partnership agreement" +patent = "Patent" +pathology-report = "Pathology report" +payment-reminder = "Payment reminder" +payroll-document = "Payroll document" +payslip = "Payslip" +performance-review = "Performance review" +permit = "Permit" +petition = "Petition" +pitch-deck = "Pitch deck" +power-of-attorney = "Power of attorney" +prescription = "Prescription" +presentation = "Presentation" +press-release = "Press release" +price-list = "Price list" +pricing-sheet = "Pricing sheet" +privacy-policy = "Privacy policy" +product-sheet = "Product sheet" +proforma-invoice = "Proforma invoice" +progress-report = "Progress report" +project-charter = "Project charter" +project-plan = "Project plan" +promotional-material = "Promotional material" +property-listing = "Property listing" +proposal = "Proposal" +public-notice = "Public notice" +purchase-agreement = "Purchase agreement" +purchase-order = "Purchase order" +quality-report = "Quality report" +quarterly-report = "Quarterly report" +questionnaire = "Questionnaire" +quick-start-guide = "Quick start guide" +quote = "Quote" +radiology-report = "Radiology report" +receipt = "Receipt" +recommendation-letter = "Recommendation letter" +reference-letter = "Reference letter" +referral-letter = "Referral letter" +registration-confirmation = "Registration confirmation" +registration-form = "Registration form" +regulatory-filing = "Regulatory filing" +release-notes = "Release notes" +remittance-advice = "Remittance advice" +rental-agreement = "Rental agreement" +report = "Report" +request-for-proposal = "Request for proposal" +request-for-quotation = "Request for quotation" +requirements-document = "Requirements document" +research-abstract = "Research abstract" +research-paper = "Research paper" +reservation = "Reservation" +resignation-letter = "Resignation letter" +resume = "Resume" +retrospective = "Retrospective" +return-authorization = "Return authorization" +risk-assessment = "Risk assessment" +roadmap = "Roadmap" +safety-data-sheet = "Safety data sheet" +safety-procedure = "Safety procedure" +sales-proposal = "Sales proposal" +sales-report = "Sales report" +schematic = "Schematic" +scope-of-work = "Scope of work" +service-agreement = "Service agreement" +service-report = "Service report" +settlement-agreement = "Settlement agreement" +shareholder-agreement = "Shareholder agreement" +shipping-confirmation = "Shipping confirmation" +specification = "Specification" +sponsorship-agreement = "Sponsorship agreement" +standard-operating-procedure = "Standard operating procedure" +statement-of-account = "Statement of account" +statement-of-work = "Statement of work" +status-report = "Status report" +stock-report = "Stock report" +study-guide = "Study guide" +subpoena = "Subpoena" +subscription-confirmation = "Subscription confirmation" +supply-order = "Supply order" +survey-form = "Survey form" +survey-results = "Survey results" +sustainability-report = "Sustainability report" +table-of-contents = "Table of contents" +tax-form = "Tax form" +tax-return = "Tax return" +tax-statement = "Tax statement" +technical-drawing = "Technical drawing" +technical-specification = "Technical specification" +tenancy-agreement = "Tenancy agreement" +tender-document = "Tender document" +termination-letter = "Termination letter" +terms-and-conditions = "Terms and conditions" +terms-of-service = "Terms of service" +test-plan = "Test plan" +test-report = "Test report" +thesis = "Thesis" +ticket = "Ticket" +timeline = "Timeline" +timesheet = "Timesheet" +title-document = "Title document" +training-material = "Training material" +transcript = "Transcript" +travel-itinerary = "Travel itinerary" +trust-document = "Trust document" +user-guide = "User guide" +utility-bill = "Utility bill" +vendor-agreement = "Vendor agreement" +visa-document = "Visa document" +waiver = "Waiver" +warehouse-receipt = "Warehouse receipt" +warranty-document = "Warranty document" +waybill = "Waybill" +white-paper = "White paper" +will = "Will" +work-instruction = "Work instruction" +work-order = "Work order" + [cloudBadge] tooltip = "This operation will use your cloud credits" diff --git a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx index 7a21cae2b7..4b3fae7652 100644 --- a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx +++ b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.tsx @@ -61,7 +61,7 @@ export function ClassificationLabelsSection() { )} - {family.name} + {t(`classification.families.${family.id}`, family.name)} } diff --git a/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx index 143e344f82..bc29f74bae 100644 --- a/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx +++ b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.tsx @@ -40,7 +40,9 @@ export function ClassificationCategoryManager({ - {category.name} + + {t(`classification.families.${category.id}`, category.name)} + {count !== undefined && ( {count} )} diff --git a/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts b/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts index 5b4b30ce33..d8b06f12f4 100644 --- a/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts +++ b/frontend/editor/src/proprietary/components/shared/fileSidebarGroupingLogic.ts @@ -56,7 +56,7 @@ export function buildLabelGroups( if (members.length === 0) continue; visible.push({ id: `category:${category.id}`, - label: category.name, + label: t(`classification.families.${category.id}`, category.name), icon: category.icon, stubs: members, defaultExpanded: false, diff --git a/frontend/editor/src/proprietary/data/classificationLabels.i18n.test.ts b/frontend/editor/src/proprietary/data/classificationLabels.i18n.test.ts new file mode 100644 index 0000000000..842a7127e7 --- /dev/null +++ b/frontend/editor/src/proprietary/data/classificationLabels.i18n.test.ts @@ -0,0 +1,58 @@ +import fs from "fs"; +import path from "path"; +import { describe, it, expect } from "vitest"; +import { parse } from "smol-toml"; +import { + DEFAULT_CLASSIFICATION_LABELS, + LABEL_FAMILIES, +} from "@app/data/classificationLabels"; + +// Label and family names are rendered through `classification.labels.` / +// `classification.families.`, with the built-in English name as the fallback. +// A fallback renders fine but is never translated (and logs a missingKey), so a +// label added to classificationLabels.json without its en-US key silently ships +// English to every locale. This guards that the en-US vocabulary stays complete. + +const EN_US_TRANSLATIONS = path.join( + __dirname, + "../../../public/locales/en-US/translation.toml", +); + +function readSection(section: string): Record { + const parsed = parse(fs.readFileSync(EN_US_TRANSLATIONS, "utf8")) as { + classification?: Record>; + }; + return parsed.classification?.[section] ?? {}; +} + +describe("classification vocabulary (en-US coverage)", () => { + const labels = readSection("labels"); + const families = readSection("families"); + + it("has an en-US key for every label id, matching its built-in name", () => { + for (const label of DEFAULT_CLASSIFICATION_LABELS) { + expect( + labels[label.id], + `en-US missing classification.labels.${label.id}`, + ).toBe(label.name); + } + }); + + it("has an en-US key for every family id, matching its built-in name", () => { + for (const family of LABEL_FAMILIES) { + expect( + families[family.id], + `en-US missing classification.families.${family.id}`, + ).toBe(family.name); + } + }); + + it("has no en-US keys for labels or families that no longer exist", () => { + const labelIds = new Set(DEFAULT_CLASSIFICATION_LABELS.map((l) => l.id)); + const familyIds = new Set(LABEL_FAMILIES.map((f) => f.id)); + expect(Object.keys(labels).filter((id) => !labelIds.has(id))).toEqual([]); + expect(Object.keys(families).filter((id) => !familyIds.has(id))).toEqual( + [], + ); + }); +}); From 5b5e9220697a3d80cadde04df5e4ec4e5fac79cf Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:57:15 +0000 Subject: [PATCH 08/57] Make the upgrade banner neutral instead of gradient purple (#7696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What The `promo` banner tone was a full-bleed `indigo-500 → purple-500` gradient with white text and a black drop-shadow on the CTA. It was the only saturated fill in the app, and against the warm neutral palette it read as a foreign object above the workbench. The bar is now app chrome: | | Before | After | |---|---|---| | Background | 135° indigo→purple gradient | `--c-bg-raised` | | Border | `transparent` | `--c-border-subtle` hairline | | Icon | white glyph, no container | neutral glyph in a `--c-surface-sunken` chip | | Text | forced white | `--c-text` / `--c-text-muted` | | CTA | `premium` accent (violet gradient) | `default` accent (same primary button as the rest of the app) | Before Screenshot 2026-08-27 at 4 49 00 PM After Screenshot 2026-08-27 at 4 48 30 PM Only caller is the friendly variant of `UpgradeBanner` (self-hosted, under the free-tier user limit). ## Notes - **No new theme tokens.** Every value is an existing `--c-*` semantic token, so light and dark both follow automatically with no per-theme overrides. - The `premium` accent itself is untouched, so the upgrade CTAs in `OfflineActivationCard` and `PairingPanel` are unaffected. - `--c-hue-indigo` / `--c-hue-purple` are still used by `SaaSOnboardingSlides`, `PaygFree` and `UpgradeModal`, so no tokens are orphaned. - Deleted comments describe rules that no longer exist (the gradient, the white-on-gradient text overrides, the CTA shadow). No new comments added. ## Verification - `task frontend:check:all` passes (typecheck, oxlint, all four theme linters, stylelint, format, tests, build, storybook build). - `task frontend:storybook:a11y:changed` passes light and dark: 7 AppBanner stories, 0 violations. Both a11y baselines are empty, so this is zero known violations rather than a baselined pass. - Checked in Storybook under **Shared / AppBanner → All Top Bars**, which renders every top bar the app can show side by side, in both themes. --- .../src/core/components/shared/AppBanner.css | 31 ++++++++----------- .../src/core/components/shared/AppBanner.tsx | 2 +- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/frontend/editor/src/core/components/shared/AppBanner.css b/frontend/editor/src/core/components/shared/AppBanner.css index b3f3f9343a..8151fe916a 100644 --- a/frontend/editor/src/core/components/shared/AppBanner.css +++ b/frontend/editor/src/core/components/shared/AppBanner.css @@ -24,17 +24,10 @@ --app-banner-icon: var(--c-accent-fg, var(--c-primary)); } -/* The one bar meant to pop, so it takes the feature gradient rather than a tint. - Fixed hues by design — it doesn't follow the chosen accent. */ .app-banner--promo { - --app-banner-bg: linear-gradient( - 135deg, - var(--c-hue-indigo) 0%, - var(--c-hue-purple) 100% - ); - --app-banner-border: transparent; - --app-banner-icon: var(--color-text-on-accent); - color: var(--color-text-on-accent); + --app-banner-bg: var(--c-bg-raised); + --app-banner-border: var(--c-border-subtle); + --app-banner-icon: var(--c-text-muted); } .app-banner--warning { @@ -86,16 +79,18 @@ font-size: 0.75rem; } -/* On the gradient everything is white; muted grey would disappear. */ -.app-banner--promo .app-banner__message, -.app-banner--promo .app-banner__actions .sui-btn--tertiary, -.app-banner--promo .app-banner__actions .sui-ai { - color: var(--color-text-on-accent); +.app-banner--promo .app-banner__icon { + width: 1.75rem; + height: 1.75rem; + justify-content: center; + border-radius: var(--radius-md); + background: var(--c-surface-sunken); + box-shadow: inset 0 0 0 1px var(--c-border-subtle); } -/* Lifts the premium CTA off the gradient it sits on. */ -.app-banner--promo .app-banner__actions .sui-btn--primary { - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); +.app-banner--promo.app-banner--compact .app-banner__icon { + width: 1.5rem; + height: 1.5rem; } .app-banner__actions { diff --git a/frontend/editor/src/core/components/shared/AppBanner.tsx b/frontend/editor/src/core/components/shared/AppBanner.tsx index ee0ba03741..79ff268dd2 100644 --- a/frontend/editor/src/core/components/shared/AppBanner.tsx +++ b/frontend/editor/src/core/components/shared/AppBanner.tsx @@ -11,7 +11,7 @@ export type AppBannerTone = "info" | "promo" | "warning" | "danger"; /** Tone decides the button too, so the CTA can't drift from the bar it sits on. */ const TONE_BUTTON = { info: { variant: "secondary", accent: "default" }, - promo: { variant: "primary", accent: "premium" }, + promo: { variant: "primary", accent: "default" }, warning: { variant: "primary", accent: "warning" }, danger: { variant: "primary", accent: "danger" }, } as const; From cbe3ef8f69240eb4ddce063b3f85c9fafe7a9bda Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 27 Aug 2026 16:59:58 +0000 Subject: [PATCH 09/57] fix: validate frontend dependency installation (#7625) # Description of Changes The current check only verifies the existence of the `node_modules` directory. After an incomplete or corrupted installation, this can lead to the task being incorrectly marked as complete. `npm ls --depth=0` instead checks whether the direct frontend dependencies are actually installed and consistent. This reliably detects and automatically repairs corrupted installations. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/labeler-config-srvaroa.yml | 1 + .taskfiles/frontend.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index bd1947d649..ea9613ea20 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -67,6 +67,7 @@ labels: - 'frontend/**' - 'frontend/.*' - 'frontend/**/.*' + - '.taskfiles/frontend.yml' - label: 'Tauri' files: diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 7867315037..9727538961 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -23,7 +23,7 @@ tasks: - package-lock.json - package.json status: - - test -d node_modules + - npm ls --depth=0 env: CI: '{{ .CI | default "false" }}' From 4e46ba3b5a98cd4dd73ef4b898ba78d7536a0678 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 27 Aug 2026 17:16:24 +0000 Subject: [PATCH 10/57] chore: prevent duplicate Dependabot Gradle PRs (#7657) ## Description of Changes - Removed overlapping Gradle subdirectory entries from .github/dependabot.yml. - Dependabot now monitors the root Gradle project through /. - Prevents duplicate pull requests for dependencies declared in Gradle subprojects. Closes: Not applicable --- ## Checklist ### General - [ ] I have read the Contribution Guidelines - [ ] I have read the Stirling-PDF Developer Guide (if applicable) - [x] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant documentation (if applicable) - [ ] I have read the translation tag documentation (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos are attached ### Testing (if applicable) - [ ] I have tested my changes locally --- .github/dependabot.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 883c4f7f46..4ed61e37ec 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,11 +8,6 @@ updates: - package-ecosystem: "gradle" # See documentation for possible values directories: - "/" # Location of package manifests - - "/app/common" - - "/app/core" - - "/app/proprietary" - - "/app/saas" - - "/buildSrc" schedule: interval: "weekly" cooldown: From 97c0ccf58214fe502c0f93ac859d264dc58c3c00 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:31:54 +0200 Subject: [PATCH 11/57] refactor(deps): optimize dependency footprints, and add lazy initialization with platform-specific JPDFium bundling (#7620) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- app/common/build.gradle | 39 +++++++++++++++---- app/core/build.gradle | 16 +++++++- .../api/security/RedactController.java | 4 ++ .../api/security/TextRedactionService.java | 4 ++ build.gradle | 10 ++++- 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/app/common/build.gradle b/app/common/build.gradle index 8af68bcb76..8ac1dfe0a9 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -3,6 +3,10 @@ bootRun { enabled = false } dependencies { + // Security-hardening utilities (zip-slip, SSRF, filename sanitization, command injection). + // Declared as api here so core + proprietary (which depend on common) get it transitively, + // keeping it off modules that don't need it (e.g. saas). + api 'io.github.pixee:java-security-toolkit:1.2.3' api "com.google.guava:guava:${guavaVersion}" api 'org.springframework.boot:spring-boot-starter-webmvc' api 'org.springframework.boot:spring-boot-starter-aspectj' @@ -22,7 +26,10 @@ dependencies { api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" // Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage) api 'org.simplejavamail:simple-java-mail:9.3.2' - api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support + // MSG file support; exclude commons-math3 (only HSSF/formula needs it, MSG parsing doesn't) + api('org.simplejavamail:outlook-module:9.3.2') { + exclude group: 'org.apache.commons', module: 'commons-math3' + } api 'jakarta.mail:jakarta.mail-api:2.1.5' runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5' @@ -36,12 +43,30 @@ dependencies { api "com.stirling:jpdfium:${jpdfiumVersion}" - // -PjpdfiumPlatforms=all|none| - // 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform). - def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim() - def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'] + // -PjpdfiumPlatforms=auto|all|none| (windows-arm64 natives not published yet) + def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'auto').toString().trim() + def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64'] def jpdfiumPlatforms - if (jpdfiumPlatformsProp == 'all') { + if (jpdfiumPlatformsProp == 'auto') { + def osName = System.getProperty('os.name').toLowerCase() + def osArch = System.getProperty('os.arch').toLowerCase() + def isArm64 = osArch.contains('aarch64') || osArch.contains('arm64') + if (osName.contains('linux')) { + jpdfiumPlatforms = isArm64 ? ['linux-arm64'] : ['linux-x64'] + } else if (osName.contains('mac')) { + jpdfiumPlatforms = isArm64 ? ['darwin-arm64'] : ['darwin-x64'] + } else if (osName.contains('win')) { + if (isArm64) { + logger.lifecycle("JPDFium natives are not available for windows-arm64; set -PjpdfiumPlatforms=none to skip bundling natives.") + jpdfiumPlatforms = [] + } else { + jpdfiumPlatforms = ['windows-x64'] + } + } else { + // Fallback: bundle all platforms when host can't be determined + jpdfiumPlatforms = jpdfiumAllPlatforms + } + } else if (jpdfiumPlatformsProp == 'all') { jpdfiumPlatforms = jpdfiumAllPlatforms } else if (jpdfiumPlatformsProp == 'none') { jpdfiumPlatforms = [] @@ -51,7 +76,7 @@ dependencies { def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) } if (jpdfiumInvalid) { throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " + - "Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.") + "Valid: ${jpdfiumAllPlatforms.join(', ')}, 'auto', 'all' or 'none'.") } logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}") jpdfiumPlatforms.each { platform -> diff --git a/app/core/build.gradle b/app/core/build.gradle index 76828445a3..0e1533b6e8 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -62,8 +62,16 @@ dependencies { // CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7) implementation "com.google.code.gson:gson:${gsonVersion}" implementation 'org.apache.pdfbox:jbig2-imageio:3.0.5' - implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv - implementation 'org.apache.poi:poi-ooxml:5.5.1' + // OpenCSV: Stirling-PDF only uses CSVWriter, not the opencsv-bean module. + // Exclude commons-beanutils + commons-collections. + implementation('com.opencsv:opencsv:5.12.0') { + exclude group: 'commons-beanutils', module: 'commons-beanutils' + exclude group: 'commons-collections', module: 'commons-collections' + } + // POI: only XSSF (modern Excel) is used, not HSSF/FormulaEvaluator which need commons-math3. + implementation('org.apache.poi:poi-ooxml:5.5.1') { + exclude group: 'org.apache.commons', module: 'commons-math3' + } // Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom) // Replaces batik-all which included unused codec, svggen, transcoder, script modules @@ -129,6 +137,10 @@ bootJar { exclude 'META-INF/*.RSA' exclude 'META-INF/*.EC' + // Exclude source maps from production JAR, dev-only debugging artifacts, not needed at runtime + exclude 'static/pdfjs-legacy/**/*.map' + exclude 'static/**/*.map' + manifest { attributes( 'Implementation-Title': 'Stirling-PDF', diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java index 7a186235cd..1c694da2b0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java @@ -221,6 +221,10 @@ public class RedactController { .normalizeFonts(false) .fixToUnicode(false) .glyphAware(true) + .ligatureAware(true) + .bidiAware(true) + .graphemeSafe(true) + .sanitizeStructure(false) // WIP/Experimental API .redactMetadata(true) .build(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java index 9cf4e6c700..c0b74f5428 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java @@ -110,6 +110,10 @@ class TextRedactionService { .fixToUnicode(false) .repairWidths(false) .glyphAware(true) + .ligatureAware(true) + .bidiAware(true) + .graphemeSafe(true) + .sanitizeStructure(false) .build(); try (PdfDocument checkDoc = PdfDocument.open(tempIn.toPath())) { diff --git a/build.gradle b/build.gradle index 6382e75c12..ffaba65377 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,7 @@ ext { bucket4jVersion = "8.19.0" archunitVersion = "1.4.2" batikVersion = "1.19" - jpdfiumVersion = "1.0.4" + jpdfiumVersion = "1.1.3" jwtVersion = "0.13.0" awsSdkVersion = "2.51.3" jschVersion = "2.28.6" @@ -265,7 +265,6 @@ subprojects { dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'io.github.pixee:java-security-toolkit:1.2.3' //tmp for security bumps implementation "ch.qos.logback:logback-core:$logback" @@ -543,6 +542,13 @@ subprojects { } } + // Lazy initialization defers bean creation until first use, + // reducing dev-mode RSS significantly (heap drops ~40-60%). + // Enable with: ./gradlew bootRun -PlazyInit=true + if (rootProject.findProperty('lazyInit') == 'true') { + runtimeArgs.add("-Dspring.main.lazy-initialization=true") + logger.lifecycle("Lazy initialization enabled (-PlazyInit=true)") + } jvmArgs = runtimeArgs } } From 51835a7b5e8ac2f058aa7cee4e29906c94823afc Mon Sep 17 00:00:00 2001 From: jayakrishna <71440165+NGU-152002@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:04:43 +0530 Subject: [PATCH 12/57] fix: clean up Add Stamp image preview blob URLs (#6779) Co-authored-by: James Brunton --- .../core/components/tools/addStamp/StampSetupSettings.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx index 4c7ee96c5b..25e2bc2243 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx +++ b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx @@ -19,6 +19,7 @@ import { AddStampParameters } from "@app/components/tools/addStamp/useAddStampPa import ButtonSelector from "@app/components/shared/ButtonSelector"; import styles from "@app/components/tools/addStamp/StampPreview.module.css"; import { getDefaultFontSizeForAlphabet } from "@app/components/tools/addStamp/StampPreviewUtils"; +import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; const STAMP_TEMPLATES = [ @@ -209,6 +210,9 @@ const StampSetupSettings = ({ filename, }: StampSetupSettingsProps) => { const { t } = useTranslation(); + const stampImageWithUrl = useFileWithUrl( + parameters.stampType === "image" ? (parameters.stampImage ?? null) : null, + ); return ( @@ -679,10 +683,10 @@ const StampSetupSettings = ({ > {t("chooseFile", "Choose File")} - {parameters.stampImage && ( + {parameters.stampImage && stampImageWithUrl && ( Selected stamp image From d4b1862654b4ad92414dc0a0ce25f8370bb459b5 Mon Sep 17 00:00:00 2001 From: Andrei Blaj <7049755+andreiblaj@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:40:26 +0300 Subject: [PATCH 13/57] feat(ocr): add rotatePages option for automatic page orientation correction (#6697) Co-authored-by: Andrei Blaj Signed-off-by: Andrei Blaj --- .../software/SPDF/controller/api/misc/OCRController.java | 7 +++++++ .../SPDF/model/api/misc/ProcessPdfWithOcrRequest.java | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java index 4803184a33..e10b86a866 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java @@ -114,6 +114,7 @@ public class OCRController { List selectedLanguages = request.getLanguages(); boolean sidecar = request.isSidecar(); Boolean deskew = request.isDeskew(); + Boolean rotatePages = request.isRotatePages(); Boolean clean = request.isClean(); Boolean cleanFinal = request.isCleanFinal(); String ocrType = request.getOcrType(); @@ -154,6 +155,7 @@ public class OCRController { selectedLanguages, sidecar, deskew, + rotatePages, clean, cleanFinal, ocrType, @@ -236,6 +238,7 @@ public class OCRController { List selectedLanguages, Boolean sidecar, Boolean deskew, + Boolean rotatePages, Boolean clean, Boolean cleanFinal, String ocrType, @@ -268,6 +271,10 @@ public class OCRController { if (deskew != null && deskew) { command.add("--deskew"); } + if (rotatePages != null && rotatePages) { + // Tesseract OSD-based automatic page orientation correction (90/180/270) + command.add("--rotate-pages"); + } if (clean != null && clean) { command.add("--clean"); } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java index 2955d7160f..daa6930412 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java @@ -25,6 +25,11 @@ public class ProcessPdfWithOcrRequest extends PDFFile { @Schema(description = "Deskew the input file if set to true") private boolean deskew; + @Schema( + description = + "Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true") + private boolean rotatePages; + @Schema(description = "Clean the input file if set to true") private boolean clean; From be130282098c01d15591fc20af3532c136c11172 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 27 Aug 2026 19:41:40 +0200 Subject: [PATCH 14/57] chore(logging): enable gzipped log rotation and adjust test logging (#7648) --- app/core/.gitignore | 1 + app/core/src/main/resources/logback.xml | 10 ++++++---- build.gradle | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/core/.gitignore b/app/core/.gitignore index 7d9dd62931..c207c6c09a 100644 --- a/app/core/.gitignore +++ b/app/core/.gitignore @@ -106,6 +106,7 @@ SwaggerDoc.json # Log file *.log +*.log.gz # BlueJ files *.ctxt diff --git a/app/core/src/main/resources/logback.xml b/app/core/src/main/resources/logback.xml index c0779735ae..ebdeeda64b 100644 --- a/app/core/src/main/resources/logback.xml +++ b/app/core/src/main/resources/logback.xml @@ -16,8 +16,9 @@ %d %p %c{1} [%thread] %m%n - ${LOG_PATH}/auth-%d{yyyy-MM-dd}.log - 1 + ${LOG_PATH}/auth-%d{yyyy-MM-dd}.log.gz + 7 + 64MB @@ -28,8 +29,9 @@ %d %p %c{1} [%thread] %m%n - ${LOG_PATH}/info-%d{yyyy-MM-dd}.log - 1 + ${LOG_PATH}/info-%d{yyyy-MM-dd}.log.gz + 7 + 256MB diff --git a/build.gradle b/build.gradle index ffaba65377..fdc59f3680 100644 --- a/build.gradle +++ b/build.gradle @@ -306,7 +306,7 @@ subprojects { systemProperty 'apple.awt.UIElement', 'true' testLogging { - events "started", "failed" + events "skipped", "failed" showExceptions = true showCauses = true showStackTraces = true From f71b0247dafba7d266dcc3d0f8e4dcffdfbbbda3 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:15:39 +0100 Subject: [PATCH 15/57] Quick access bar and old school sidebars (#7695) --- .../public/locales/en-US/translation.toml | 19 +- frontend/editor/src/core/App.tsx | 28 +-- .../fileManager/FileSourceButtons.tsx | 2 +- .../components/filesPage/FolderTreePanel.tsx | 2 +- .../filesPage/filesPageReturnRoute.ts | 2 +- .../src/core/components/layout/AppFrame.css | 18 ++ .../src/core/components/layout/AppFrame.tsx | 22 ++ .../core/components/layout/NoAppChrome.tsx | 8 + .../components/layout/Workbench.module.css | 6 +- .../src/core/components/layout/Workbench.tsx | 30 ++- .../core/components/layout/WorkspaceFrame.css | 16 ++ .../notifications/NotificationBell.css | 8 +- .../notifications/NotificationBell.tsx | 119 ++--------- .../notifications/NotificationPanel.tsx | 135 ++++++++++++ .../src/core/components/shared/AppSwitch.tsx | 7 +- .../core/components/shared/AppSwitcher.tsx | 22 -- .../core/components/shared/BrandSwitcher.css | 15 -- .../shared/BrandSwitcher.stories.tsx | 16 -- .../core/components/shared/BrandSwitcher.tsx | 57 ----- .../src/core/components/shared/BrandTile.tsx | 29 +++ .../core/components/shared/FileSidebar.css | 25 ++- .../core/components/shared/FileSidebar.tsx | 60 +++--- .../core/components/shared/SidebarHeader.tsx | 33 +++ .../components/shared/SidebarToggleButton.tsx | 36 ++++ .../src/core/components/shared/Tooltip.tsx | 47 ++-- .../core/components/shared/WorkbenchBar.css | 22 +- .../core/components/shared/WorkbenchBar.tsx | 20 +- .../components/shared/navFooter/NavFooter.tsx | 87 ++++---- .../shared/quickNav/QuickNavBrand.tsx | 28 +++ .../shared/quickNav/QuickNavHostBridge.tsx | 85 ++++++++ .../shared/quickNav/QuickNavRail.css | 173 +++++++++++++++ .../shared/quickNav/QuickNavRailAccount.css | 29 +++ .../shared/quickNav/QuickNavRailAccount.tsx | 45 ++++ .../shared/quickNav/QuickNavRailBase.test.tsx | 123 +++++++++++ .../shared/quickNav/QuickNavRailBase.tsx | 101 +++++++++ .../shared/quickNav/QuickNavRailContainer.css | 38 ++++ .../shared/quickNav/QuickNavRailContainer.tsx | 83 ++++++++ .../shared/quickNav/QuickNavRailHost.tsx | 178 ++++++++++++++++ .../QuickNavRailNotifications.test.tsx | 88 ++++++++ .../quickNav/QuickNavRailNotifications.tsx | 51 +++++ .../quickNav/useQuickNavToolReasons.test.tsx | 139 ++++++++++++ .../shared/quickNav/useQuickNavToolReasons.ts | 131 ++++++++++++ .../core/components/tools/RightSidebar.tsx | 7 +- .../src/core/components/tools/ToolPanel.css | 8 +- .../viewer/useViewerWorkbenchBarButtons.tsx | 55 ++--- .../contexts/QuickNavHostContext.test.tsx | 128 +++++++++++ .../src/core/contexts/QuickNavHostContext.tsx | 201 ++++++++++++++++++ .../src/core/contexts/SidebarContext.tsx | 5 + .../src/core/contexts/ToolWorkflowContext.tsx | 2 +- frontend/editor/src/core/pages/HomePage.tsx | 191 ++++++++++++++--- frontend/editor/src/core/routes/hasPortal.ts | 2 + .../live/viewer-sidebar-add-buttons.spec.ts | 3 +- .../viewer-sidebar-add-buttons.spec.ts | 3 +- .../stubbed/workbench-session-restore.spec.ts | 10 +- frontend/editor/src/core/theme/colors.css | 2 +- frontend/editor/src/core/theme/dimensions.css | 7 +- frontend/editor/src/core/ui/NavSurface.tsx | 4 +- .../src/core/utils/homePageNavigation.ts | 9 +- .../src/core/utils/pendingReaderMode.ts | 13 ++ .../src/core/utils/viewTransition.test.ts | 63 ++++++ .../editor/src/core/utils/viewTransition.ts | 17 +- .../desktop/components/shared/AppSwitcher.tsx | 18 -- .../editor/src/desktop/routes/hasPortal.ts | 2 + .../editor/src/portal/components/AppShell.tsx | 22 +- .../portal/components/EditorStatusCard.tsx | 26 +-- .../src/portal/components/PortalSearchBar.css | 3 +- .../editor/src/portal/components/Sidebar.css | 105 +++++---- .../editor/src/portal/components/Sidebar.tsx | 47 +--- frontend/editor/src/proprietary/App.tsx | 80 ++++--- .../components/shared/AppSwitcher.tsx | 35 --- .../proprietary/data/processorEntitySearch.ts | 10 +- .../proprietary/data/processorSearchIndex.ts | 10 +- .../editor/src/proprietary/routes/Landing.tsx | 4 + .../routes/adminRouteExtensions.tsx | 8 +- .../src/proprietary/routes/hasPortal.ts | 3 + frontend/editor/src/saas/App.tsx | 97 +++++---- 76 files changed, 2665 insertions(+), 718 deletions(-) create mode 100644 frontend/editor/src/core/components/layout/AppFrame.css create mode 100644 frontend/editor/src/core/components/layout/AppFrame.tsx create mode 100644 frontend/editor/src/core/components/layout/NoAppChrome.tsx create mode 100644 frontend/editor/src/core/components/layout/WorkspaceFrame.css create mode 100644 frontend/editor/src/core/components/notifications/NotificationPanel.tsx delete mode 100644 frontend/editor/src/core/components/shared/AppSwitcher.tsx delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.css delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.tsx create mode 100644 frontend/editor/src/core/components/shared/BrandTile.tsx create mode 100644 frontend/editor/src/core/components/shared/SidebarHeader.tsx create mode 100644 frontend/editor/src/core/components/shared/SidebarToggleButton.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavBrand.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx create mode 100644 frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts create mode 100644 frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx create mode 100644 frontend/editor/src/core/contexts/QuickNavHostContext.tsx create mode 100644 frontend/editor/src/core/routes/hasPortal.ts create mode 100644 frontend/editor/src/core/utils/pendingReaderMode.ts create mode 100644 frontend/editor/src/core/utils/viewTransition.test.ts delete mode 100644 frontend/editor/src/desktop/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/desktop/routes/hasPortal.ts delete mode 100644 frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/proprietary/routes/hasPortal.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 8271d66dfc..378157818c 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4141,7 +4141,6 @@ mobileShort = "Mobile" mobileUpload = "Mobile Upload" mobileUploadNotAvailable = "Mobile upload not enabled" moreOptions = "More options" -myFiles = "My Files" nextFile = "Next file" noFiles = "No files available" noFilesFound = "No files found matching your search" @@ -4242,9 +4241,9 @@ duplicateFailed = "Could not duplicate file" expand = "Expand sidebar" googleDrive = "Google Drive" googleDriveDisabled = "Google Drive is not configured" -leaveMyFiles = "Leave My Files" +leaveMyFiles = "Leave File library" library = "PDF Library" -myFiles = "My Files" +myFiles = "File library" noFiles = "No files yet" openFileManager = "Browse all files & folders" openFromComputer = "Open from computer" @@ -4287,7 +4286,7 @@ addToWorkspaceCount = "Add {{count}} to workspace" allFiles = "All files" back = "Back" backToFolder = "Back to {{folder}}" -backToMyFiles = "Back to My Files" +backToMyFiles = "Back to File library" breadcrumbs = "Folder path" bulkActions = "Actions" cancel = "Cancel" @@ -4339,7 +4338,6 @@ localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)." moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)." moveTo = "Move to…" -myFiles = "My Files" newFolder = "New folder" newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on." newFolderTabUnavailable = "Switch to All or Cloud to create folders." @@ -9173,7 +9171,6 @@ appEditor = "Editor" appProcessor = "Processor" linkAccount = "Link Stirling account" primaryNav = "Primary navigation" -switchApp = "Switch app" [portal.shell.topbar] closeNav = "Close navigation" @@ -9652,6 +9649,16 @@ automate = "Automate" config = "Config" files = "Files" +[quickNav] +editor = "Editor" +home = "Stirling" +invite = "Invite" +landmark = "Quick navigation" +noProcessorAccess = "Ask an admin for processor access" +notifications = "Notifications" +processor = "Processor" +reader = "Reader" + [read] tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse" diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index 81db0564b8..c51c1fb85d 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy } from "react"; import { Routes, Route } from "react-router-dom"; import { AppProviders } from "@app/components/AppProviders"; +import { AppFrame } from "@app/components/layout/AppFrame"; import { AppLayout } from "@app/components/AppLayout"; import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import { ThemeProvider } from "@app/components/shared/ThemeProvider"; @@ -53,18 +54,21 @@ export default function App() { } /> - {/* All other routes need AppProviders for backend integration */} - - - - - - - } - /> + {/* The app, under a shared frame so the rail renders once outside it. */} + }> + {/* All other routes need AppProviders for backend integration */} + + + + + + + } + /> +
); diff --git a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx index 6ac3e3c91e..20ebaa191e 100644 --- a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx +++ b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx @@ -173,7 +173,7 @@ const FileSourceButtons: React.FC = ({ mb="xs" style={{ paddingLeft: "1rem" }} > - {t("fileManager.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")} {buttons} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx index eb7f19170c..b56915b757 100644 --- a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx +++ b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx @@ -121,7 +121,7 @@ export function FolderTreePanel({ active }: FolderTreePanelProps) {
- {t("filesPage.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")}
diff --git a/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts b/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts index 5e6292d62f..69a1bc1c53 100644 --- a/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts +++ b/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts @@ -1,6 +1,6 @@ /** * Stores the route the user came from when they open files into the - * workbench from My Files. Lets the workbench show a "Back to My Files" + * workbench from the file library. Lets the workbench show a "Back to File library" * affordance and return to the exact folder they were browsing. * * Persisted in sessionStorage so a hard reload keeps the return path diff --git a/frontend/editor/src/core/components/layout/AppFrame.css b/frontend/editor/src/core/components/layout/AppFrame.css new file mode 100644 index 0000000000..0cc20b30c6 --- /dev/null +++ b/frontend/editor/src/core/components/layout/AppFrame.css @@ -0,0 +1,18 @@ +/* ========== APP FRAME ========== */ +/* The rail's column, then whichever app is mounted, so a switch changes only the app. */ +.app-frame { + display: flex; + height: 100vh; + height: 100dvh; /* track mobile browser chrome */ + overflow: hidden; + background-color: var(--c-bg); +} + +/* min-width: 0 so the app shrinks instead of forcing the frame past the window. */ +.app-frame__content { + flex: 1; + min-width: 0; + height: 100%; +} + +/* The rail hides itself below the mobile breakpoint - see QuickNavRailContainer.css. */ diff --git a/frontend/editor/src/core/components/layout/AppFrame.tsx b/frontend/editor/src/core/components/layout/AppFrame.tsx new file mode 100644 index 0000000000..38fa92ecba --- /dev/null +++ b/frontend/editor/src/core/components/layout/AppFrame.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; +import { Outlet } from "react-router-dom"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; +import { QuickNavHostProvider } from "@app/contexts/QuickNavHostContext"; +import { QuickNavRailHost } from "@app/components/shared/quickNav/QuickNavRailHost"; +import "@app/components/layout/AppFrame.css"; + +/** The rail renders once outside both apps; Suspense sits inside it, not above. */ +export function AppFrame() { + return ( + +
+ +
+ }> + + +
+
+
+ ); +} diff --git a/frontend/editor/src/core/components/layout/NoAppChrome.tsx b/frontend/editor/src/core/components/layout/NoAppChrome.tsx new file mode 100644 index 0000000000..04416c02f1 --- /dev/null +++ b/frontend/editor/src/core/components/layout/NoAppChrome.tsx @@ -0,0 +1,8 @@ +import { Outlet } from "react-router-dom"; +import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext"; + +/** Pages that aren't the app: inside the frame for its providers, but with no rail. */ +export function NoAppChrome() { + useSuppressQuickNavRail(); + return ; +} diff --git a/frontend/editor/src/core/components/layout/Workbench.module.css b/frontend/editor/src/core/components/layout/Workbench.module.css index dd2b4a12bd..22d6fdc43c 100644 --- a/frontend/editor/src/core/components/layout/Workbench.module.css +++ b/frontend/editor/src/core/components/layout/Workbench.module.css @@ -12,10 +12,8 @@ .workbenchBarReopenTab { position: absolute; top: 100%; - /* Right-align with the retract handle inside the bar: the bar's right - margin (--nav-gutter) + 1px border + 8px bar padding + the handle's own - 6px inset. */ - right: calc(var(--nav-gutter) + 15px); + /* Aligns with the retract handle: 8px bar padding plus its own 6px inset. */ + right: 14px; display: flex; align-items: center; justify-content: center; diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx index 903c552fd2..0cac20e574 100644 --- a/frontend/editor/src/core/components/layout/Workbench.tsx +++ b/frontend/editor/src/core/components/layout/Workbench.tsx @@ -1,4 +1,4 @@ -import { useState, Suspense, lazy } from "react"; +import { useState, useEffect, useRef, Suspense, lazy } from "react"; import { useTranslation } from "react-i18next"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { Box, Loader, Center, Stack, Text } from "@mantine/core"; @@ -15,6 +15,7 @@ import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useSigningOverlay } from "@app/contexts/SigningOverlayContext"; import { useCookieConsent } from "@app/hooks/useCookieConsent"; +import { useIsPhone } from "@app/hooks/useIsMobile"; import styles from "@app/components/layout/Workbench.module.css"; import WorkbenchBar from "@app/components/shared/WorkbenchBar"; @@ -58,10 +59,13 @@ export default function Workbench() { setPageEditorFunctions, setSidebarsVisible, customWorkbenchViews, + readerMode, } = useToolWorkflow(); const { handleToolSelect } = useToolWorkflow(); const { overlay: signingOverlay } = useSigningOverlay(); + // Below this width the rail, and the bell it carries, is gone. + const isPhone = useIsPhone(); // Get navigation state - this is the source of truth const { selectedTool: selectedToolId } = useNavigationState(); @@ -92,8 +96,20 @@ export default function Workbench() { !isBaseWorkbench(currentView) || // Shared signing drives the viewer from the sidebar with no file in context. (currentView === "viewer" && !!signingOverlay?.file); - const showWorkbenchBar = topControlsAvailable && hasWorkbenchContent; - const showFloatingSearch = topControlsAvailable && !hasWorkbenchContent; + // Reading hides the bar; the rail's Reader entry is the way back. + const showWorkbenchBar = + topControlsAvailable && hasWorkbenchContent && !readerMode; + const showFloatingSearch = + topControlsAvailable && !hasWorkbenchContent && !readerMode; + + // On the transition, so reading sets the toolbar's start state without locking it. + const prevReaderModeRef = useRef(readerMode); + useEffect(() => { + if (readerMode !== prevReaderModeRef.current) { + setViewerToolbarCollapsed(readerMode); + prevReaderModeRef.current = readerMode; + } + }, [readerMode]); const handlePreviewClose = () => { setPreviewFile(null); @@ -126,7 +142,7 @@ export default function Workbench() { } } - // The "My Files" workbench is available regardless of whether files are + // The file-library workbench is available regardless of whether files are // currently loaded into the workbench - it lives on top of the IDB store. if (currentView === "myFiles") { return ; @@ -249,10 +265,8 @@ export default function Workbench() { data-tour="workbench" style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }} > - {/* The bell normally rides in the workbench bar. Wherever that bar is not shown - My Files, - an empty workbench, a custom view without top controls - it gets its own corner, rather - than those being the places a user cannot see that something of theirs failed. */} - {!showWorkbenchBar && ( + {/* Phone only: above that the rail carries the bell, and here no bar does. */} + {isPhone && !showWorkbenchBar && (
diff --git a/frontend/editor/src/core/components/layout/WorkspaceFrame.css b/frontend/editor/src/core/components/layout/WorkspaceFrame.css new file mode 100644 index 0000000000..05cabe95f9 --- /dev/null +++ b/frontend/editor/src/core/components/layout/WorkspaceFrame.css @@ -0,0 +1,16 @@ +/* ========== WORKSPACE FRAME ========== */ +/* Rail and sidebar side by side, full height. Shared by both apps. */ +.workspace-frame { + display: flex; + height: 100%; + flex-shrink: 0; + background-color: var(--c-bg); +} + +/* On mobile the sidebar is a fixed drawer, so the frame stops laying out. */ +@media (max-width: 48rem) { + .workspace-frame { + display: block; + height: auto; + } +} diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.css b/frontend/editor/src/core/components/notifications/NotificationBell.css index 9352f6bef8..46367a0b2b 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.css +++ b/frontend/editor/src/core/components/notifications/NotificationBell.css @@ -10,7 +10,7 @@ position: relative; padding: var(--sp-2, 0.5rem); border: none; - border-radius: var(--radius-md, 0.375rem); + border-radius: var(--radius-md); background: transparent; color: var(--c-text-muted); cursor: pointer; @@ -48,6 +48,12 @@ box-shadow: 0 10px 30px rgb(0 0 0 / 25%); } +/* The rail's bell is at the foot of a full-height column, so its panel rises beside it. */ +.notification-bell__panel--rail { + inset-inline-start: calc(var(--nav-rail-w) + var(--nav-gutter)); + inset-block-end: var(--nav-gutter); +} + .notification-bell__heading { margin: 0 0 var(--sp-2, 0.5rem); font-size: 0.875rem; diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.tsx b/frontend/editor/src/core/components/notifications/NotificationBell.tsx index f2ac1e0a3a..def06eed8d 100644 --- a/frontend/editor/src/core/components/notifications/NotificationBell.tsx +++ b/frontend/editor/src/core/components/notifications/NotificationBell.tsx @@ -1,27 +1,15 @@ -import { - Fragment, - useEffect, - useId, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BellIcon, Button } from "@app/ui"; -import DividerWithText from "@app/components/shared/DividerWithText"; import { useNotifications } from "@app/hooks/useNotifications"; import { useNotificationActions } from "@app/components/notifications/notificationActions"; -import { NotificationItem } from "@app/components/notifications/NotificationItem"; +import { NotificationPanel } from "@app/components/notifications/NotificationPanel"; import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; import "@app/components/notifications/NotificationBell.css"; -/** - * Renders whatever the server sends without knowing which subsystem produced it or what its actions - * mean, so a new source or failure kind needs no change here. In core because both shells mount it. - */ +/** For the narrow layouts where the rail, which carries the bell, is off screen. */ export function NotificationBell() { - // A build with no notifications API gets no bell at all, rather than one that polls a - // nonexistent endpoint forever to show nothing. + // No API means no bell at all, rather than one polling an endpoint that isn't there. const available = useNotificationsAvailable(); if (!available) return null; return ; @@ -29,14 +17,10 @@ export function NotificationBell() { function MountedNotificationBell() { const { t } = useTranslation(); - const { notifications, unreadCount, documentStateFor, markAllSeen } = - useNotifications(); + const { unreadCount } = useNotifications(); const registry = useNotificationActions(); const [open, setOpen] = useState(false); const container = useRef(null); - const headingId = useId(); - // Where the new ones stop, frozen when the panel opens (opening marks everything read). - const [firstSeenId, setFirstSeenId] = useState(null); // Viewport-fixed, because the workbench bar clips its own overflow. const [anchor, setAnchor] = useState<{ top: number; right: number } | null>( null, @@ -61,54 +45,17 @@ function MountedNotificationBell() { }; }, [open]); - // Opening marks them read, not closing: waiting would leave the badge lit while they read. - const toggle = () => { - setOpen((wasOpen) => { - if (!wasOpen) { - // Before marking, or there is nothing left to read. - setFirstSeenId(notifications[unreadCount]?.id ?? null); - markAllSeen(); - } - return !wasOpen; - }); - }; - - /** - * How many count as new. No boundary id means all of them were; one that has since left the list - * leaves nothing to divide on, so it reads as none rather than guessing at a row. - */ - const boundaryIndex = firstSeenId - ? notifications.findIndex((notification) => notification.id === firstSeenId) - : notifications.length; - const dividedAt = Math.max(0, boundaryIndex); - - useEffect(() => { - if (!open) return; - const closeOnOutside = (event: MouseEvent) => { - const target = event.target as HTMLElement; - if (!container.current?.contains(target)) setOpen(false); - }; - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - document.addEventListener("mousedown", closeOnOutside); - document.addEventListener("keydown", closeOnEscape); - return () => { - document.removeEventListener("mousedown", closeOnOutside); - document.removeEventListener("keydown", closeOnEscape); - }; - }, [open]); - return (
{open && ( -
setOpen(false)} + registry={registry} style={anchor ? { top: anchor.top, right: anchor.right } : undefined} - > -

- {t("notifications.title", "Notifications")} -

- - {notifications.length === 0 ? ( -

- {t("notifications.empty", "Nothing to report.")} -

- ) : ( -
    - {notifications.map((notification, index) => ( - - {index === 0 && dividedAt > 0 && ( -
  • - -
  • - )} - {/* Only with something on both sides: a lone "Earlier" over everything says - nothing the empty badge has not. */} - {index === dividedAt && dividedAt > 0 && ( -
  • - -
  • - )} - setOpen(false)} - /> -
    - ))} -
- )} -
+ /> )}
); diff --git a/frontend/editor/src/core/components/notifications/NotificationPanel.tsx b/frontend/editor/src/core/components/notifications/NotificationPanel.tsx new file mode 100644 index 0000000000..f3ff0c621e --- /dev/null +++ b/frontend/editor/src/core/components/notifications/NotificationPanel.tsx @@ -0,0 +1,135 @@ +import { Fragment, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import DividerWithText from "@app/components/shared/DividerWithText"; +import { useNotifications } from "@app/hooks/useNotifications"; +import type { ClientActionRegistry } from "@app/components/notifications/notificationActions"; +import { NotificationItem } from "@app/components/notifications/NotificationItem"; +import "@app/components/notifications/NotificationBell.css"; + +/** Named so a trigger in another tree can point at it with aria-controls. */ +export const NOTIFICATIONS_PANEL_ID = "quick-nav-notifications-panel"; + +export interface NotificationPanelProps { + onClose: () => void; + id?: string; + /** Passed in: its document handover has to run whether the panel is open or not. */ + registry: ClientActionRegistry; + style?: React.CSSProperties; + className?: string; +} + +/** Mounted only while open, since mounting is what marks everything read. */ +export function NotificationPanel({ + onClose, + registry, + id, + style, + className, +}: NotificationPanelProps) { + const { t } = useTranslation(); + const { notifications, unreadCount, documentStateFor, markAllSeen } = + useNotifications(); + const panel = useRef(null); + const headingId = useId(); + // Frozen on open, since opening marks them all read. + const [firstSeenId, setFirstSeenId] = useState(null); + + // On mount, not on close: waiting leaves the badge lit while they read. + const marked = useRef(false); + useEffect(() => { + if (marked.current) return; + marked.current = true; + // Before marking, or there is nothing left to divide on. + setFirstSeenId(notifications[unreadCount]?.id ?? null); + markAllSeen(); + }, [notifications, unreadCount, markAllSeen]); + + // No boundary means all were new; one that has left the list means none. + const boundaryIndex = firstSeenId + ? notifications.findIndex((notification) => notification.id === firstSeenId) + : notifications.length; + const dividedAt = Math.max(0, boundaryIndex); + + // Focus goes back to the opener only if it is still inside the panel on close. + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + panel.current?.focus(); + return () => { + if (panel.current?.contains(document.activeElement)) opener?.focus(); + }; + }, []); + + useEffect(() => { + const closeOnOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (panel.current?.contains(target)) return; + // A trigger closes this itself; counting it as outside would reopen it. + if (target.closest?.("[data-notifications-trigger]")) return; + onClose(); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [onClose]); + + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/AppSwitch.tsx b/frontend/editor/src/core/components/shared/AppSwitch.tsx index 71d35ed5ad..fbbe85ccdb 100644 --- a/frontend/editor/src/core/components/shared/AppSwitch.tsx +++ b/frontend/editor/src/core/components/shared/AppSwitch.tsx @@ -11,12 +11,7 @@ interface AppSwitchMenuItemsProps { onSwitch: (app: AppSwitchTarget) => void; } -/** - * The editor / processor items for the app-switch menu. Rendered inside the - * BrandSwitcher's logo dropdown, which both apps use as their switcher. The - * mark is the shared , which recolours itself from the theme - * tokens, so no colour-scheme prop needs threading down here. - */ +/** The editor / processor items for an app-switch menu. */ export function AppSwitchMenuItems({ current, onSwitch, diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx deleted file mode 100644 index aaf55148ef..0000000000 --- a/frontend/editor/src/core/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Logo } from "@app/ui/Logo"; - -export interface AppSwitcherProps { - /** Icon-only brand mark for the collapsed rail. */ - collapsed?: boolean; -} - -/** - * Sidebar brand header. Core has no admin portal to switch to, so it just - * shows the Stirling logo. Builds that bundle the portal (proprietary/saas) - * shadow this with a version whose logo doubles as the editor⇄processor - * switcher. - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - return ( - - ); -} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.css b/frontend/editor/src/core/components/shared/BrandSwitcher.css deleted file mode 100644 index dc1659ea15..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.css +++ /dev/null @@ -1,15 +0,0 @@ -/* Logo + app-switch dropdown, shared between the editor and the processor. - The logo itself is the trigger (its mark morphs into a chevron on hover). */ -.sui-brand-switcher { - display: flex; - align-items: center; - flex: 1; - min-width: 0; -} - -/* Tighten the ghost-button padding so the lockup sits flush like a plain logo, - and negative-margin it back so the hover surface still extends past the text. */ -.sui-brand-switcher__trigger.sui-btn { - --button-padding-x: 0.375rem; - margin-inline: -0.375rem; -} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx deleted file mode 100644 index 92deb518b2..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; - -const meta: Meta = { - title: "Brand/BrandSwitcher", - component: BrandSwitcher, - parameters: { layout: "centered" }, - args: { current: "processor", onSwitch: () => {} }, - argTypes: { - current: { control: "inline-radio", options: ["editor", "processor"] }, - }, -}; -export default meta; -type Story = StoryObj; - -export const Playground: Story = {}; diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.tsx deleted file mode 100644 index 474173fee2..0000000000 --- a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button, Dropdown } from "@app/ui"; -import { Logo } from "@app/ui/Logo"; -import { BrandMark } from "@app/components/shared/BrandMark"; -import { - AppSwitchMenuItems, - type AppSwitchTarget, -} from "@app/components/shared/AppSwitch"; -import "@app/components/shared/BrandSwitcher.css"; - -interface BrandSwitcherProps { - /** The app this is rendered in (shown active in the menu). */ - current: AppSwitchTarget; - /** Called with the selected app (only for the non-current one). */ - onSwitch: (app: AppSwitchTarget) => void; - /** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */ - collapsed?: boolean; - className?: string; -} - -/** - * Brand lockup that doubles as the editor⇄processor switcher. The whole logo - * is the dropdown trigger: on hover / focus / open the mark morphs into a - * downward chevron (see BrandMark), so no separate chevron button is needed. - * Shared so the editor and the processor present one identical header. - */ -export function BrandSwitcher({ - current, - onSwitch, - collapsed = false, - className, -}: BrandSwitcherProps) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - - return ( -
- - - - - - - - -
- ); -} diff --git a/frontend/editor/src/core/components/shared/BrandTile.tsx b/frontend/editor/src/core/components/shared/BrandTile.tsx new file mode 100644 index 0000000000..e8ccba6db8 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandTile.tsx @@ -0,0 +1,29 @@ +interface BrandTileProps { + /** CSS length. Omit to let the caller's CSS size it. */ + size?: string; + className?: string; +} + +/** The mark in a rounded square. Decorative: call sites carry the accessible name. */ +export function BrandTile({ size, className }: BrandTileProps) { + return ( + + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 2347d9a2b2..76db9deab4 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -1,7 +1,9 @@ /* ========== FILE SIDEBAR ========== */ .file-sidebar { - background-color: var(--c-bg); + /* One solid panel, with a rule only on the workbench side, so it frames the document. */ + background-color: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); display: flex; flex-direction: column; height: 100%; @@ -37,12 +39,19 @@ gap: 0.5rem; } -/* ---- Brand header (logo / editor⇄processor switcher) ---- */ -.file-sidebar-brand { +/* Flattened here; two classes deep to beat .sui-nav-surface without relying on order. */ +.file-sidebar .sui-nav-surface { + background: transparent; + border: 0; + border-radius: 0; +} + +/* ---- Header row (wordmark + collapse toggle) ---- */ +.file-sidebar-header { display: flex; align-items: center; - min-height: 40px; - padding: 0 0.375rem; + min-height: var(--nav-header-h); + padding: 0 var(--nav-gutter); flex-shrink: 0; } @@ -51,9 +60,9 @@ flex-shrink: 0; } -.file-sidebar[data-collapsed="true"] .file-sidebar-brand { - flex-direction: column; - gap: 0.25rem; +/* Collapsed the row holds only the toggle, so centre it. */ +.file-sidebar[data-collapsed="true"] .file-sidebar-header { + justify-content: center; padding: 0; } .file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle { diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index f9337bd880..1dcf4a7301 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -24,7 +24,6 @@ import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary"; -import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; import { useOpenPlan } from "@app/hooks/useOpenPlan"; import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; import { @@ -32,8 +31,7 @@ import { useIndexedDBRevision, } from "@app/contexts/IndexedDBContext"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; -import { AppSwitcher } from "@app/components/shared/AppSwitcher"; -import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; +import { SidebarHeader } from "@app/components/shared/SidebarHeader"; import type { StirlingFileStub } from "@app/types/fileContext"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import FolderSpecialIcon from "@mui/icons-material/FolderSpecial"; @@ -78,8 +76,9 @@ import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import "@app/components/shared/FileSidebar.css"; -const COLLAPSED_WIDTH = "3.5rem"; -const EXPANDED_WIDTH = "16.25rem"; // ~260px +// Shared with the processor sidebar via tokens, so the two cannot drift. +const COLLAPSED_WIDTH = "var(--sidebar-collapsed-w)"; +const EXPANDED_WIDTH = "var(--sidebar-w)"; // Inlined to avoid a circular import with WatchedFoldersRegistration. const WATCHED_FOLDER_VIEW_ID = "watchedFolder"; @@ -98,9 +97,11 @@ export interface FileSidebarProps { collapsed?: boolean; onToggleCollapse?: () => void; onOpenSettings?: () => void; - /** Accessible name override for the toggle button. */ + /** The quick nav rail owns the account control, so the footer drops its own row. */ + accountHoisted?: boolean; + /** Accessible name override for the collapse toggle. */ toggleAriaLabel?: string; - /** Icon override for the toggle button (e.g. back-arrow on /files). */ + /** Icon override for the collapse toggle (e.g. back-arrow on /files). */ toggleIcon?: React.ReactNode; /** Override the Open-from-computer handler (e.g. upload to /files folder). */ onUploadFiles?: (files: File[]) => void | Promise; @@ -155,11 +156,12 @@ const FileSidebar = forwardRef( collapsed = false, onToggleCollapse, onOpenSettings, + accountHoisted = false, + toggleAriaLabel, + toggleIcon, onUploadFiles, onPickGoogleDriveFiles, extraAction, - toggleAriaLabel, - toggleIcon, }, ref, ) { @@ -249,7 +251,6 @@ const FileSidebar = forwardRef( const { displayName, profilePictureUrl, isAnonymous } = useAccountIdentity(); const credits = useFreeCreditsSummary(); - const otherApp = useOtherAppSwitch(); const openPlan = useOpenPlan(); // Leaf files = user-visible files (excludes intermediate tool outputs) @@ -943,25 +944,12 @@ const FileSidebar = forwardRef(
)}
-
- - {onToggleCollapse && ( - onToggleCollapse()} - aria-label={ - toggleAriaLabel ?? - (collapsed - ? t("fileSidebar.expand", "Expand sidebar") - : t("fileSidebar.collapse", "Collapse sidebar")) - } - > - {toggleIcon ?? } - - )} -
+ {/* Box 1 — top controls (open / my files / cloud). No title. File search lives in the global super search (top bar), not here. */} @@ -984,7 +972,7 @@ const FileSidebar = forwardRef( {/* Tooltips only fire when collapsed - when expanded the visible text label below already identifies each row, so a tooltip would just flash a duplicate. Distinct icons (UploadFile for - "Open from computer" vs FolderOpen for "My Files") so the + "Open from computer" vs FolderOpen for "File library") so the collapsed rail isn't two identical folder icons either. */} ( onClick={() => { // "Open from computer" goes straight to the native OS file // picker. The full file manager (recent + drives + folders) - // is reachable via "My Files" below. + // is reachable via "File library" below. nativeFileInputRef.current?.click(); }} role="button" @@ -1080,7 +1068,7 @@ const FileSidebar = forwardRef( )} ( }} role="button" tabIndex={0} - aria-label={t("fileSidebar.myFiles", "My Files")} + aria-label={t("fileSidebar.myFiles", "File library")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -1105,7 +1093,7 @@ const FileSidebar = forwardRef( {!collapsed && ( - {t("fileSidebar.myFiles", "My Files")} + {t("fileSidebar.myFiles", "File library")} )}
@@ -1370,15 +1358,15 @@ const FileSidebar = forwardRef( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Box 3 — the shared footer: credits, app switch, account row. */} + {/* Box 3 — the shared footer: credits, plan, and the account row unless hoisted. */}
diff --git a/frontend/editor/src/core/components/shared/SidebarHeader.tsx b/frontend/editor/src/core/components/shared/SidebarHeader.tsx new file mode 100644 index 0000000000..1a2befa074 --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarHeader.tsx @@ -0,0 +1,33 @@ +import { Logo } from "@app/ui/Logo"; +import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton"; + +export interface SidebarHeaderProps { + collapsed?: boolean; + onToggleCollapse?: () => void; + toggleAriaLabel?: string; + toggleIcon?: React.ReactNode; + className?: string; +} + +/** The wordmark and the collapse toggle; the brand mark sits in the rail beside it. */ +export function SidebarHeader({ + collapsed, + onToggleCollapse, + toggleAriaLabel, + toggleIcon, + className, +}: SidebarHeaderProps) { + return ( +
+ {!collapsed && } + {onToggleCollapse && ( + + )} +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx b/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx new file mode 100644 index 0000000000..3a1345360b --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarToggleButton.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from "react-i18next"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; + +export interface SidebarToggleButtonProps { + collapsed?: boolean; + onToggle: () => void; + ariaLabel?: string; + icon?: React.ReactNode; +} + +/** Opens and closes the sidebar; on /files the caller swaps in a back arrow. */ +export function SidebarToggleButton({ + collapsed, + onToggle, + ariaLabel, + icon, +}: SidebarToggleButtonProps) { + const { t } = useTranslation(); + return ( + onToggle()} + aria-label={ + ariaLabel ?? + (collapsed + ? t("fileSidebar.expand", "Expand sidebar") + : t("fileSidebar.collapse", "Collapse sidebar")) + } + > + {icon ?? } + + ); +} diff --git a/frontend/editor/src/core/components/shared/Tooltip.tsx b/frontend/editor/src/core/components/shared/Tooltip.tsx index 55c06a1533..b8128bf637 100644 --- a/frontend/editor/src/core/components/shared/Tooltip.tsx +++ b/frontend/editor/src/core/components/shared/Tooltip.tsx @@ -13,7 +13,7 @@ import { addEventListenerWithCleanup } from "@app/utils/genericUtils"; import { useTooltipPosition } from "@app/hooks/useTooltipPosition"; import { TooltipTip } from "@app/types/tips"; import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent"; -import { useSidebarContext } from "@app/contexts/SidebarContext"; +import { useOptionalSidebarContext } from "@app/contexts/SidebarContext"; import { useLogoAssets } from "@app/hooks/useLogoAssets"; import styles from "@app/components/shared/tooltip/Tooltip.module.css"; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; @@ -59,6 +59,29 @@ export interface TooltipProps { showCloseButton?: boolean; } +/** Split out so only tooltips with a header need the logo and the providers behind it. */ +function TooltipHeader({ + header, +}: { + header: NonNullable; +}) { + const { tooltipLogo } = useLogoAssets(); + return ( +
+
+ {header.logo || ( + Stirling PDF + )} +
+ {header.title} +
+ ); +} + export const Tooltip: React.FC = ({ sidebarTooltip = false, position, @@ -85,7 +108,6 @@ export const Tooltip: React.FC = ({ const { t } = useTranslation(); const [internalOpen, setInternalOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); - const { tooltipLogo } = useLogoAssets(); const triggerRef = useRef(null); const tooltipRef = useRef(null); @@ -105,9 +127,9 @@ export const Tooltip: React.FC = ({ }, []); // Always call the hook unconditionally to satisfy React's rules of hooks. - // The context is only used when sidebarTooltip is true. - const sidebarContextValue = useSidebarContext(); - const sidebarContext = sidebarTooltip ? sidebarContextValue : null; + // Optional: the plain tooltip renders outside the provider. + const sidebarContextValue = useOptionalSidebarContext(); + const sidebarContext = sidebarTooltip ? (sidebarContextValue ?? null) : null; const isControlled = controlledOpen !== undefined; const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled; @@ -443,20 +465,7 @@ export const Tooltip: React.FC = ({ } /> )} - {header && ( -
-
- {header.logo || ( - Stirling PDF - )} -
- {header.title} -
- )} + {header && } - {/* Left: optional "Back to My Files" + view switcher */} + {/* Left: optional "Back to File library" + view switcher */}
{returnRoute && hasFiles && ( <> @@ -501,7 +503,7 @@ export default function WorkbenchBar({ : "filesPage.backToMyFiles", returnRoute.label ? `Back to ${returnRoute.label}` - : "Back to My Files", + : "Back to File library", { folder: returnRoute.label ?? "" }, )} leftSection={} @@ -511,7 +513,7 @@ export default function WorkbenchBar({ ? t("filesPage.backToFolder", "Back to {{folder}}", { folder: returnRoute.label, }) - : t("filesPage.backToMyFiles", "Back to My Files")} + : t("filesPage.backToMyFiles", "Back to File library")}
@@ -603,9 +605,13 @@ export default function WorkbenchBar({ enforcingProgress={enforcingProgress} /> )} - {/* Last in the globals, so it is the rightmost control. */} -
- + {isPhone && ( + <> + {/* Last in the globals, so it is the rightmost control. */} +
+ + + )}
); diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx index 373c91bccf..848c0cb4a5 100644 --- a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx +++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx @@ -33,6 +33,8 @@ export interface NavFooterProps { otherApp?: NavFooterAppLink | null; /** Extra rows above the account row (the self-hosted link-account CTA). */ accountExtras?: ReactNode; + /** False where the rail owns the account control, so only one avatar is drawn. */ + showAccount?: boolean; /** Icon-rail state: labels collapse to tooltips. */ collapsed?: boolean; className?: string; @@ -69,6 +71,7 @@ export function NavFooter({ onOpenPlan, otherApp, accountExtras, + showAccount = true, collapsed = false, className, }: NavFooterProps) { @@ -144,49 +147,51 @@ export function NavFooter({ }); } - rows.push({ - key: "account", - node: ( - - - - ), - }); + {!collapsed && ( + + {displayName} + + )} + {onOpenSettings && !collapsed && ( + + + + )} + + + ), + }); + } + + if (rows.length === 0) return null; return ( void; +} + +export function QuickNavBrand({ onReturnHome }: QuickNavBrandProps) { + const { t } = useTranslation(); + const label = t("quickNav.home", "Stirling"); + + return ( +
+ + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx new file mode 100644 index 0000000000..9a9101a826 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx @@ -0,0 +1,85 @@ +import { useCallback, useMemo, useState } from "react"; +import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; +import { + NotificationPanel, + NOTIFICATIONS_PANEL_ID, +} from "@app/components/notifications/NotificationPanel"; +import { useNotificationActions } from "@app/components/notifications/notificationActions"; +import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons"; +import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; +import { useSigningBadgeCount } from "@app/hooks/signing/useSigningBadgeCount"; +import { + useRegisterQuickNavHost, + type QuickNavToolReasons, +} from "@app/contexts/QuickNavHostContext"; +import type { ToolId } from "@app/types/toolId"; + +export interface QuickNavHostBridgeProps { + portalAccess?: boolean; + readerMode?: boolean; + onSetReaderMode?: (on: boolean) => void; + onOpenSettings: () => void; + requestNavigation?: (go: () => void) => void; + onGoToDefaultState?: () => void; + onSelectTool?: (toolId: ToolId) => void; + /** Merged over the reasons worked out here, for what only the app can see. */ + toolReasons?: QuickNavToolReasons; +} + +/** Registers with the rail what only the app can see, and owns the notifications panel. */ +export function QuickNavHostBridge({ + portalAccess = false, + readerMode = false, + onSetReaderMode, + onOpenSettings, + requestNavigation, + onSelectTool, + onGoToDefaultState, + toolReasons, +}: QuickNavHostBridgeProps) { + const { displayName, profilePictureUrl } = useAccountIdentity(); + const signingBadge = useSigningBadgeCount(); + const notificationsAvailable = useNotificationsAvailable(); + // Built even when closed: it carries a one-shot document pickup that would sit unclaimed. + const notificationActions = useNotificationActions(); + const endpointReasons = useQuickNavToolReasons(); + const mergedToolReasons = useMemo(() => { + // An empty map from the app is silence, not an answer. + const extra = + toolReasons && Object.keys(toolReasons).length > 0 ? toolReasons : null; + if (!endpointReasons && !extra) return undefined; + return { ...endpointReasons, ...extra }; + }, [endpointReasons, toolReasons]); + const [notificationsOpen, setNotificationsOpen] = useState(false); + const closeNotifications = useCallback(() => setNotificationsOpen(false), []); + + useRegisterQuickNavHost( + { + identity: { displayName, profilePictureUrl }, + signingBadge, + portalAccess, + readerMode, + notificationsOpen, + toolReasons: mergedToolReasons, + }, + { + openSettings: onOpenSettings, + requestNavigation, + selectTool: onSelectTool, + setReaderMode: onSetReaderMode, + goToDefaultState: onGoToDefaultState, + toggleNotifications: () => setNotificationsOpen((open) => !open), + }, + ); + + // Mounted only while open, so a closed panel never subscribes to the poll. + if (!notificationsAvailable || !notificationsOpen) return null; + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css new file mode 100644 index 0000000000..4d6845f592 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css @@ -0,0 +1,173 @@ +/* ========== QUICK NAV RAIL ========== */ + +.quick-nav-rail { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + width: 100%; +} + +.quick-nav-rail-group { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--quicknav-item-gap); + flex-shrink: 0; + width: 100%; +} + +/* The glyph stays at the sidebar's scale; the button fills the rail for hit area. */ +.quick-nav-rail-item { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 2.25rem; + padding: 0; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--c-text-subtle); + cursor: pointer; + transition: + background-color var(--motion-fast), + color var(--motion-fast); +} + +.quick-nav-rail-item svg, +.quick-nav-rail-item img { + width: 1.125rem; + height: 1.125rem; + color: inherit; + fill: currentColor; +} + +/* Taller than wide (71x79), so height drives the width. */ +.quick-nav-rail-item .sui-brandmark { + width: auto; + height: 1.125rem; +} + +.quick-nav-rail-item[aria-disabled="true"] .sui-brandmark, +.quick-nav-rail-item[aria-disabled="true"] svg[viewBox="0 0 256 256"] { + filter: grayscale(1); +} + +.quick-nav-rail-item:hover { + background: var(--c-hover); + color: var(--c-text); +} + +/* Opacity rather than a colour: there is no disabled-text token. */ +.quick-nav-rail-item[aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; +} +.quick-nav-rail-item[aria-disabled="true"]:hover { + background: transparent; + color: var(--c-primary); +} + +.quick-nav-rail-item:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: -0.125rem; +} + +.quick-nav-rail-badge { + position: absolute; + top: 0.125rem; + inset-inline-end: 0.125rem; + min-width: 0.875rem; + height: 0.875rem; + padding: 0 0.1875rem; + border-radius: var(--radius-pill); + /* The solid step: 9px numerals need the darker end of the ramp. */ + background: var(--c-danger-solid); + color: var(--c-text-on-primary); + font-size: 0.5625rem; + font-weight: var(--font-weight-semibold); + line-height: 0.875rem; + text-align: center; + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +.quick-nav-rail-badge[data-tone="warning"] { + background: var(--c-warning-solid); +} + +/* Top margin only: the group below supplies the other half, centring the rule. */ +.quick-nav-rail-divider { + width: 100%; + height: 0; + margin: var(--quicknav-item-gap) 0 0; + border: 0; + border-top: 1px solid var(--c-border); +} + +.quick-nav-rail-footer { + margin-top: auto; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--quicknav-item-gap); + width: 100%; + flex-shrink: 0; +} + +/* ---- Brand: one header row tall, so it lines up with the sidebar's wordmark ---- */ +.quick-nav-brand { + width: 100%; + height: var(--nav-header-h); + flex-shrink: 0; + /* The bar's inset supplies part of the gap; only the remainder is added here. */ + margin-bottom: calc(var(--quicknav-item-gap) - var(--quicknav-surface-pad)); +} + +.quick-nav-brand-button { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border: none; + background: transparent; + padding: 0; + cursor: pointer; +} + +.quick-nav-brand-button:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: -0.125rem; + border-radius: var(--radius-md); +} + +/* The "on" state: a solid block with the glyph knocked out, hover owning the tints. */ +.quick-nav-rail-item[aria-current="true"], +.quick-nav-rail-item[aria-current="true"]:hover, +.quick-nav-rail-item[aria-pressed="true"], +.quick-nav-rail-item[aria-pressed="true"]:hover { + background: var(--c-text); + color: var(--c-surface); +} + +/* On a dark ground full ink is white, so mix the block back toward the surface. */ +[data-theme="dark"] .quick-nav-rail-item[aria-current="true"], +[data-theme="dark"] .quick-nav-rail-item[aria-current="true"]:hover, +[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"], +[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"]:hover, +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"], +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"]:hover, +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"], +html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"]:hover, +[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-current="true"], +[data-mantine-color-scheme="dark"] + .quick-nav-rail-item[aria-current="true"]:hover, +[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-pressed="true"], +[data-mantine-color-scheme="dark"] + .quick-nav-rail-item[aria-pressed="true"]:hover { + background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface)); + color: var(--c-surface); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css new file mode 100644 index 0000000000..c12a6a1f71 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css @@ -0,0 +1,29 @@ +/* The account control, pinned to the bottom of the bar. */ + +.quick-nav-rail-account { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + flex-shrink: 0; + /* Further than the shortcut gap: a filled disc reads heavier than a line glyph. */ + margin-top: var(--space-2); + /* Matches the slack centring the brand mark leaves at the top. */ + padding-bottom: 0.3125rem; +} + +.quick-nav-rail-avatar-target { + display: inline-flex; +} + +/* Appearance comes from the shared Avatar; only the button reset is ours. */ +.quick-nav-rail-avatar { + border: none; + padding: 0; + user-select: none; +} + +.quick-nav-rail-avatar:focus-visible { + outline: 0.125rem solid var(--c-primary); + outline-offset: 0.125rem; +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx new file mode 100644 index 0000000000..1404925de6 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import { Avatar } from "@app/ui/Avatar"; +import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext"; +import "@app/components/shared/quickNav/QuickNavRailAccount.css"; + +export interface QuickNavRailAccountProps { + onOpenSettings: () => void; + /** Null between apps; the disc still renders, so the bar keeps its shape. */ + identity: QuickNavIdentity | null; +} + +/** The avatar opens settings, so there is no separate gear beside it. */ +export function QuickNavRailAccount({ + onOpenSettings, + identity, +}: QuickNavRailAccountProps) { + const { t } = useTranslation(); + const displayName = + identity?.displayName ?? t("auth.displayName.user", "User"); + const profilePictureUrl = identity?.profilePictureUrl ?? null; + const label = `${displayName} — ${t("fileSidebar.openSettings", "Open settings")}`; + + return ( +
+ + {/* A span, not the Avatar: Tooltip binds by cloning its child. */} + + + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx new file mode 100644 index 0000000000..328f2d8f4e --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { + QuickNavRailBase, + type QuickNavEntry, +} from "@app/components/shared/quickNav/QuickNavRailBase"; + +/** The rail needs no providers. */ +function withProviders(ui: React.ReactNode) { + return <>{ui}; +} + +function entry( + id: string, + overrides: Partial = {}, +): QuickNavEntry { + return { + id, + label: id, + icon: null, + onClick: () => {}, + ...overrides, + }; +} + +const PROCESSOR = entry("processor"); +const WITHIN = [entry("files"), entry("reader")]; + +function renderRail(groups: QuickNavEntry[][]) { + const { container } = render( + withProviders(), + ); + return { + labels: [...container.querySelectorAll(".quick-nav-rail-item")].map((b) => + b.getAttribute("aria-label"), + ), + dividers: container.querySelectorAll(".quick-nav-rail-divider").length, + }; +} + +describe("QuickNavRailBase — groups", () => { + it("divides one group from the next", () => { + const { labels, dividers } = renderRail([[PROCESSOR], WITHIN]); + + expect(labels).toEqual(["processor", "files", "reader"]); + expect(dividers).toBe(1); + }); + + it("drops an empty group, and the divider with it", () => { + const { labels, dividers } = renderRail([[], WITHIN]); + + expect(labels).toEqual(["files", "reader"]); + expect(dividers).toBe(0); + }); +}); + +describe("QuickNavRailBase — entry state", () => { + it("reports on/off for a toggle and nothing for the rest", () => { + // Nothing here is a view you occupy, so only a real toggle has state. + const { container } = render( + withProviders( + , + ), + ); + + const state = [...container.querySelectorAll(".quick-nav-rail-item")].map( + (b) => [b.getAttribute("aria-label"), b.getAttribute("aria-pressed")], + ); + expect(state).toEqual([ + ["processor", null], + ["reader", "true"], + ["files", null], + ]); + expect(container.querySelectorAll("[aria-current]")).toHaveLength(0); + }); + + it("keeps a disabled entry in the tab order so its reason stays reachable", () => { + // The tooltip carrying the reason is only reachable while it can be focused. + const { container } = render( + withProviders( + , + ), + ); + + const automate = container.querySelector('[aria-label="automate"]')!; + expect(automate.getAttribute("aria-disabled")).toBe("true"); + expect(automate.hasAttribute("disabled")).toBe(false); + }); + + it("keeps an unavailable entry rendered, disabled rather than dropped", () => { + // Slots must not appear and vanish as access resolves. + const { container } = render( + withProviders( + , + ), + ); + + const processor = container.querySelector('[aria-label="processor"]'); + expect(processor).not.toBeNull(); + expect(processor?.getAttribute("aria-disabled")).toBe("true"); + // aria-disabled, not the disabled attribute: it stays focusable for its tooltip. + expect(processor?.hasAttribute("disabled")).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx new file mode 100644 index 0000000000..5c0be45db0 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx @@ -0,0 +1,101 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import "@app/components/shared/quickNav/QuickNavRail.css"; + +export type QuickNavTarget = "reader" | "editor" | "files" | "processor"; + +export interface QuickNavEntry { + id: string; + label: string; + icon: ReactNode; + /** The app you are in, drawn with an edge bar. */ + current?: boolean; + /** Only for entries that toggle something; use `current` for the app you are in. */ + pressed?: boolean; + /** Inert, with `reason` as its tooltip. Entries are dimmed, never dropped. */ + disabled?: boolean; + reason?: string; + badge?: number; + /** Popup semantics for an entry whose panel is rendered in another tree. */ + expanded?: boolean; + controls?: string; + /** "danger" waits on the user; "warning" is awareness only. */ + badgeTone?: "danger" | "warning"; + onClick: () => void; +} + +export interface QuickNavRailBaseProps { + /** Divided by a rule; empty groups are dropped. */ + groups: QuickNavEntry[][]; + footer?: ReactNode; +} + +/** Exported so footer entries reuse it rather than a lookalike. */ +export function RailButton({ + label, + icon, + pressed, + disabled, + reason, + badge, + badgeTone = "danger", + current, + expanded, + controls, + onClick, +}: Omit) { + return ( + + + + ); +} + +export function QuickNavRailBase({ groups, footer }: QuickNavRailBaseProps) { + const { t } = useTranslation(); + const populated = groups.filter((entries) => entries.length > 0); + return ( + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css new file mode 100644 index 0000000000..606c2c3831 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css @@ -0,0 +1,38 @@ +.quick-nav-rail-container { + /* On the column, so the parts outside the nav inherit them too. */ + --quicknav-item-gap: var(--space-3); + --quicknav-surface-pad: 0.375rem; + + width: var(--nav-rail-w); + height: 100%; + flex-shrink: 0; + box-sizing: border-box; + /* On the column, not the bar, so the fill covers the gutters too. */ + background-color: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); + display: flex; + flex-direction: column; + padding-block: var(--nav-gutter); + padding-inline: calc(var(--nav-gutter) / 2); +} + +/* Child selector to beat .sui-nav-surface, which would win on order. */ +.quick-nav-rail-container > .quick-nav-rail-surface { + background: transparent; + border: 0; + border-radius: 0; +} + +.quick-nav-rail-surface { + flex: 1; + min-height: 0; + /* No inline padding: the bar is one target wide and would squeeze the buttons. */ + padding: var(--quicknav-surface-pad) 0; +} + +/* Below this width the sidebar is an off-canvas drawer, and the rail is just noise. */ +@media (max-width: 48rem) { + .quick-nav-rail-container { + display: none; + } +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx new file mode 100644 index 0000000000..154700518e --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from "react-i18next"; +import { NavSurface } from "@app/ui/NavSurface"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { QuickNavBrand } from "@app/components/shared/quickNav/QuickNavBrand"; +import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext"; +import { + QuickNavRailBase, + RailButton, + type QuickNavRailBaseProps, +} from "@app/components/shared/quickNav/QuickNavRailBase"; +import { QuickNavRailAccount } from "@app/components/shared/quickNav/QuickNavRailAccount"; +import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications"; +import "@app/components/shared/quickNav/QuickNavRailContainer.css"; + +export type { + QuickNavEntry, + QuickNavTarget, +} from "@app/components/shared/quickNav/QuickNavRailBase"; + +export interface QuickNavRailContainerProps extends Omit< + QuickNavRailBaseProps, + "footer" +> { + /** The rail owns the account control, so the sidebars drop their own row. */ + onOpenSettings?: () => void; + /** Omitted in builds with no processor to invite anyone into. */ + onInvite?: () => void; + onToggleNotifications?: () => void; + notificationsOpen?: boolean; + identity?: QuickNavIdentity | null; + onReturnHome: () => void; +} + +/** The fixed-width column the rail sits in. */ +export function QuickNavRailContainer({ + onOpenSettings, + onInvite, + onToggleNotifications, + notificationsOpen, + identity = null, + onReturnHome, + ...railProps +}: QuickNavRailContainerProps) { + const { t } = useTranslation(); + return ( +
+ + + + + {onInvite && ( + + } + onClick={onInvite} + /> + )} + {onOpenSettings && ( + + )} +
+ } + /> +
+
+ ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx new file mode 100644 index 0000000000..5c748b36cc --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx @@ -0,0 +1,178 @@ +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { QuickNavRailContainer } from "@app/components/shared/quickNav/QuickNavRailContainer"; +import type { QuickNavEntry } from "@app/components/shared/quickNav/QuickNavRailBase"; +import type { ToolId } from "@app/types/toolId"; +import { useQuickNavHost } from "@app/contexts/QuickNavHostContext"; +import { requestReaderMode } from "@app/utils/pendingReaderMode"; +import { + saveEditorReturnPath, + takeEditorReturnPath, +} from "@app/services/workbenchSession"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; + +const SIZE = "1.125rem"; + +/** Entries come from the URL, not either app's context, so the rail survives a switch. */ +export function QuickNavRailHost() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { pathname, search } = useLocation(); + const host = useQuickNavHost(); + + const appMounted = Boolean(host?.appMounted); + + const inPortal = pathname.startsWith(PORTAL_BASENAME); + + // Only the app knows its own default state. + const returnHome = () => { + const reset = host?.actions.current?.goToDefaultState; + if (reset) reset(); + else navigate(inPortal ? PORTAL_BASENAME : EDITOR_BASENAME); + }; + + // Guarded where the app supplies a guard, so leaving mid-edit still prompts. + const go = (to: string) => { + const guard = host?.actions.current?.requestNavigation; + if (guard) guard(() => navigate(to)); + else navigate(to); + }; + + // Through the app where possible: its route only selects a tool on a fresh mount. + const openTool = (toolId: ToolId, route: string) => { + const select = host?.actions.current?.selectTool; + if (select) select(toolId); + else go(route); + }; + + const unusable = (id: ToolId) => { + const reason = host?.toolReasons?.[id]; + return { disabled: Boolean(reason), reason }; + }; + + const apps: QuickNavEntry[] = [ + { + id: "processor", + label: t("quickNav.processor", "Processor"), + // Two literals, not a computed name: the offline icon bundle scans for `icon="..."`. + icon: inPortal ? ( + + ) : ( + + ), + current: inPortal, + disabled: HAS_PORTAL && !inPortal && !host?.portalAccess, + reason: + HAS_PORTAL && !inPortal && !host?.portalAccess + ? t("quickNav.noProcessorAccess", "Ask an admin for processor access") + : undefined, + onClick: () => { + if (inPortal) { + returnHome(); + return; + } + saveEditorReturnPath(pathname + search); + go(PORTAL_BASENAME); + }, + }, + { + id: "editor", + label: t("quickNav.editor", "Editor"), + icon: inPortal ? ( + + ) : ( + + ), + current: !inPortal, + onClick: () => { + if (!inPortal) { + returnHome(); + return; + } + // Back to where you left the editor, not its front door. + navigate(takeEditorReturnPath() ?? EDITOR_BASENAME); + }, + }, + ]; + + const within: QuickNavEntry[] = [ + { + id: "files", + label: t("fileSidebar.myFiles", "File library"), + icon: ( + + ), + onClick: () => go("/files"), + }, + { + id: "reader", + label: t("quickNav.reader", "Reader"), + icon: ( + + ), + pressed: Boolean(host?.readerMode), + // From the processor there is no editor to toggle - see pendingReaderMode. + onClick: () => { + const setMode = host?.actions.current?.setReaderMode; + if (setMode) { + setMode(!host?.readerMode); + return; + } + requestReaderMode(); + go(EDITOR_BASENAME); + }, + }, + { + id: "automate", + label: t("quickAccess.automate", "Automate"), + icon: ( + + ), + ...unusable("automate"), + onClick: () => openTool("automate", "/automate"), + }, + { + id: "sharedSign", + label: t("home.sharedSign.title", "Shared Signing"), + icon: ( + + ), + badge: host?.signingBadge, + badgeTone: "warning", + ...unusable("sharedSign"), + onClick: () => openTool("sharedSign", "/shared-sign"), + }, + ]; + + // Read at click time, so it's always the mounted app's. + const openSettings = () => host?.actions.current?.openSettings?.(); + + // A route that isn't the app hides the bar - see useSuppressQuickNavRail. + if (!appMounted || host?.chromeless) return null; + + return ( + go(`${PORTAL_BASENAME}/users`) + : undefined + } + onToggleNotifications={() => + host?.actions.current?.toggleNotifications?.() + } + notificationsOpen={host?.notificationsOpen} + /> + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx new file mode 100644 index 0000000000..800eb55b5b --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { AppNotification } from "@app/services/notifications"; +import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications"; + +const fetchNotifications = vi.fn(); + +vi.mock("@app/services/notifications", () => ({ + fetchNotifications: (...args: unknown[]) => fetchNotifications(...args), +})); + +vi.mock("@app/services/localFilePresence", () => ({ + hasLocalFile: () => Promise.resolve(false), +})); + +const h = vi.hoisted(() => ({ notificationsAvailable: true })); + +vi.mock("@app/components/notifications/useNotificationsAvailable", () => ({ + useNotificationsAvailable: () => h.notificationsAvailable, +})); + +function notification(id: string): AppNotification { + return { + id, + kind: "PIPELINE_FAILED", + title: id, + createdAt: "2026-01-01T00:00:00Z", + fileId: null, + sourceId: null, + count: 1, + actions: [], + } as unknown as AppNotification; +} + +describe("QuickNavRailNotifications", () => { + beforeEach(() => { + window.localStorage.clear(); + fetchNotifications.mockReset().mockResolvedValue([]); + h.notificationsAvailable = true; + }); + + it("keeps out of a build with no notifications API, and off its timer", async () => { + // No endpoint to poll and nothing it could show. + h.notificationsAvailable = false; + + const { container } = render( + {}} />, + ); + + await Promise.resolve(); + expect(container.querySelector(".quick-nav-rail-item")).toBeNull(); + expect(fetchNotifications).not.toHaveBeenCalled(); + }); + + it("carries the unread count on the icon", async () => { + fetchNotifications.mockResolvedValue([ + notification("a"), + notification("b"), + ]); + + render( {}} />); + + expect(await screen.findByText("2")).toBeTruthy(); + }); + + it("asks the mounted app to open the panel rather than opening one itself", async () => { + const onToggle = vi.fn(); + const { container } = render( + , + ); + + await waitFor(() => expect(fetchNotifications).toHaveBeenCalled()); + fireEvent.click(container.querySelector(".quick-nav-rail-item")!); + + expect(onToggle).toHaveBeenCalledTimes(1); + // No panel of its own: a row's actions would have no workbench to act on. + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("stays pressable before an app has registered, doing nothing", async () => { + // Between apps there is briefly no handler. + const { container } = render(); + + await waitFor(() => expect(fetchNotifications).toHaveBeenCalled()); + const button = container.querySelector(".quick-nav-rail-item")!; + expect(() => fireEvent.click(button)).not.toThrow(); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx new file mode 100644 index 0000000000..aee294de1c --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { RailButton } from "@app/components/shared/quickNav/QuickNavRailBase"; +import { useNotifications } from "@app/hooks/useNotifications"; +import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable"; +import { NOTIFICATIONS_PANEL_ID } from "@app/components/notifications/NotificationPanel"; + +export interface QuickNavRailNotificationsProps { + onToggle?: () => void; + /** Whether the app's panel is open, which this button reports but does not own. */ + open?: boolean; +} + +/** The count is read here; the app owns the panel - see NotificationPanel. */ +export function QuickNavRailNotifications({ + onToggle, + open = false, +}: QuickNavRailNotificationsProps) { + // Gated before the count is read: subscribing starts the poll. + const available = useNotificationsAvailable(); + if (!available) return null; + return ; +} + +function MountedRailNotifications({ + onToggle, + open, +}: QuickNavRailNotificationsProps) { + const { t } = useTranslation(); + const { unreadCount } = useNotifications(); + + return ( + // Read by the panel's outside-click handler; on a wrapper, RailButton's props being fixed. + + + } + badge={unreadCount} + expanded={Boolean(open)} + controls={NOTIFICATIONS_PANEL_ID} + onClick={() => onToggle?.()} + /> + + ); +} diff --git a/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx new file mode 100644 index 0000000000..6295ddde66 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons"; + +const h = vi.hoisted(() => ({ + endpointStatus: {} as Record, + endpointDetails: {} as Record, + loading: false, + configLoading: false, + groupSigningEnabled: true, +})); + +vi.mock("@app/hooks/useEndpointConfig", () => ({ + useMultipleEndpointsEnabled: () => ({ + endpointStatus: h.endpointStatus, + endpointDetails: h.endpointDetails, + loading: h.loading, + error: null, + refetch: async () => {}, + }), +})); + +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ + config: null, + loading: h.configLoading, + error: null, + refetch: async () => {}, + }), +})); + +vi.mock("@app/hooks/useGroupSigningEnabled", () => ({ + useGroupSigningEnabled: () => h.groupSigningEnabled, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})); + +describe("useQuickNavToolReasons", () => { + beforeEach(() => { + window.localStorage.clear(); + h.endpointStatus = {}; + h.endpointDetails = {}; + h.loading = false; + h.configLoading = false; + h.groupSigningEnabled = true; + }); + + it("admits it does not know rather than reporting nothing wrong", () => { + h.loading = true; + h.endpointStatus = { automate: false }; + + expect( + renderHook(() => useQuickNavToolReasons()).result.current, + ).toBeNull(); + }); + + it("reports what it last knew while the answer is being fetched again", () => { + // Each app has its own query cache, and a reload has none at all. + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + renderHook(() => useQuickNavToolReasons()); + + h.loading = true; + h.endpointStatus = {}; + h.endpointDetails = {}; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.automate).toBe("Disabled by server administrator"); + }); + + it("forgets a reason once the server stops reporting it", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + renderHook(() => useQuickNavToolReasons()); + + h.endpointStatus = { automate: true }; + h.endpointDetails = {}; + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + + // The cleared state, not the old reason, is what a reload reads back. + h.loading = true; + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + }); + + it("says nothing about an endpoint the server reports as available", () => { + h.endpointStatus = { automate: true }; + + expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual( + {}, + ); + }); + + it("blames the administrator when the endpoint was turned off by config", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "CONFIG" } }; + + const { result } = renderHook(() => useQuickNavToolReasons()); + // The tool picker's label with its trailing colon stripped. + expect(result.current?.automate).toBe("Disabled by server administrator"); + }); + + it("blames the missing dependency when that is what the server said", () => { + h.endpointStatus = { automate: false }; + h.endpointDetails = { automate: { reason: "DEPENDENCY" } }; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.automate).toBe( + "Unavailable - required tool missing on server", + ); + }); + + it("greys out shared signing when the server has the feature switched off", () => { + // A whole feature rather than a removable endpoint, so it has its own signal. + h.groupSigningEnabled = false; + + const { result } = renderHook(() => useQuickNavToolReasons()); + expect(result.current?.sharedSign).toBe( + "Collaborative signing isn't enabled on this server", + ); + }); + + it("waits for the config before judging shared signing", () => { + // The config loads separately and reads as "off" before it arrives. + h.configLoading = true; + h.groupSigningEnabled = false; + + expect( + renderHook(() => useQuickNavToolReasons()).result.current, + ).toBeNull(); + }); +}); diff --git a/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts new file mode 100644 index 0000000000..235f4d4f08 --- /dev/null +++ b/frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled"; +import { getDisabledLabel } from "@app/components/tools/fullscreen/shared"; +import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext"; +import type { ToolId } from "@app/types/toolId"; + +const ENTRY_ENDPOINTS = { + automate: ["automate"], +} satisfies Partial>; + +// Object.keys widens to string, which a tool-id-keyed record can't be indexed by. +const ENDPOINT_ENTRIES = Object.keys( + ENTRY_ENDPOINTS, +) as (keyof typeof ENTRY_ENDPOINTS)[]; + +/** Shared signing is a feature toggle rather than an endpoint, so it has its own cause. */ +type EndpointCause = "missingDependency" | "disabledByAdmin"; +type Cause = EndpointCause | "groupSigningOff"; +type Causes = Partial>; +const CAUSES: Cause[] = [ + "missingDependency", + "disabledByAdmin", + "groupSigningOff", +]; + +/** Causes, not sentences, so a language change can't resurrect stale text. */ +const STORAGE_KEY = "stirling.quickNav.toolCauses"; + +function readRemembered(): Causes | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const known = Object.entries(parsed as Record).filter( + ([, cause]) => CAUSES.includes(cause as Cause), + ) as [ToolId, Cause][]; + return Object.fromEntries(known); + } catch { + return null; + } +} + +function remember(causes: Causes): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(causes)); + } catch { + // Won't survive the next reload. + } +} + +function causesFor( + endpointStatus: Record, + endpointDetails: Record, +): Causes { + const causes: Causes = {}; + for (const entry of ENDPOINT_ENTRIES) { + const off = ENTRY_ENDPOINTS[entry].filter( + (name) => endpointStatus[name] === false, + ); + if (off.length === 0) continue; + causes[entry] = off.some( + (name) => endpointDetails[name]?.reason === "DEPENDENCY", + ) + ? "missingDependency" + : "disabledByAdmin"; + } + return causes; +} + +/** Why a rail entry can't be used. Null means no answer yet, an empty map nothing wrong. */ +export function useQuickNavToolReasons(): QuickNavToolReasons | null { + const { t } = useTranslation(); + const endpoints = useMemo(() => Object.values(ENTRY_ENDPOINTS).flat(), []); + const { endpointStatus, endpointDetails, loading } = + useMultipleEndpointsEnabled(endpoints); + + // Read once: later reads would fight the live answer. + const [remembered] = useState(readRemembered); + + const { loading: configLoading } = useAppConfig(); + const groupSigningEnabled = useGroupSigningEnabled(); + + const live = useMemo(() => { + // A half answer would dim entries it can't see yet. + if (loading || configLoading) return null; + const causes = causesFor(endpointStatus, endpointDetails); + if (!groupSigningEnabled) causes.sharedSign = "groupSigningOff"; + return causes; + }, [ + loading, + configLoading, + endpointStatus, + endpointDetails, + groupSigningEnabled, + ]); + + // Keyed on contents: the object is rebuilt every render. + const liveKey = live ? JSON.stringify(live) : null; + useEffect(() => { + if (liveKey) remember(JSON.parse(liveKey) as Causes); + }, [liveKey]); + + const causes = live ?? remembered; + + return useMemo(() => { + if (!causes) return null; + const reasons: QuickNavToolReasons = {}; + for (const entry of Object.keys(causes) as ToolId[]) { + const cause = causes[entry]; + if (cause === "groupSigningOff") { + // The tool's own wording, minus the full stop. + reasons[entry] = t( + "sharedSign.disabledBody", + "Collaborative signing isn't enabled on this server.", + ).replace(/\.\s*$/, ""); + continue; + } + if (!cause) continue; + // These labels normally sit in front of a tool name, hence the trailing colon. + const { key, fallback } = getDisabledLabel(cause); + reasons[entry] = t(key, fallback).replace(/:\s*$/, ""); + } + return reasons; + }, [causes, t]); +} diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index d66d30c442..031ecd8e20 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -115,7 +115,7 @@ export default function RightSidebar() { const computedWidth = () => { if (isMobile) return "100%"; - if (!isPanelVisible) return "3.5rem"; + if (!isPanelVisible) return "var(--nav-rail-w)"; return expandedWidth; }; @@ -181,7 +181,8 @@ export default function RightSidebar() { content={tool.name} position="left" arrow - delay={300} + // No delay: collapsed to icons, the tooltip is the only label. + delay={0} > ( - - - -
+ +
+ {/* Inside the Popover: Tooltip binds by cloning, and Popover passes no ref on. */} + -
-
- -
- -
-
- - + +
+
+ +
+ +
+
+
), }, { diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx new file mode 100644 index 0000000000..fc648cbfe5 --- /dev/null +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { render, act } from "@testing-library/react"; +import { + QuickNavHostProvider, + useQuickNavHost, + useRegisterQuickNavHost, + useSuppressQuickNavRail, +} from "@app/contexts/QuickNavHostContext"; + +function Probe({ onRead }: { onRead: (value: unknown) => void }) { + const host = useQuickNavHost(); + onRead({ + appMounted: host?.appMounted, + chromeless: host?.chromeless, + identity: host?.identity, + openSettings: Boolean(host?.actions.current?.openSettings), + }); + return null; +} + +function App() { + useRegisterQuickNavHost( + { identity: { displayName: "Ada", profilePictureUrl: null } }, + { openSettings: () => {} }, + ); + return null; +} + +function LoginRoute() { + useSuppressQuickNavRail(); + return null; +} + +function setup() { + let latest: Record = {}; + const view = render( + + (latest = value as Record)} /> + + , + ); + return { view, read: () => latest }; +} + +describe("QuickNavHostContext", () => { + it("keeps what the app published after it unmounts, but drops its handlers", () => { + // Data survives the gap between one app unmounting and the next registering. + const { view, read } = setup(); + + expect(read().appMounted).toBe(true); + expect(read().identity).toEqual({ + displayName: "Ada", + profilePictureUrl: null, + }); + expect(read().openSettings).toBe(true); + + view.rerender( + + {}} /> + , + ); + + // Re-read through a fresh probe in the same provider. + let after: Record = {}; + view.rerender( + + (after = value as Record)} /> + , + ); + expect(after.appMounted).toBe(true); + expect(after.openSettings).toBe(false); + }); + + it("hides the bar while a route with no app chrome is on screen", () => { + // appMounted is sticky, so it can't answer "is an app on screen now". + const { view, read } = setup(); + expect(read().chromeless).toBe(false); + + act(() => { + view.rerender( + + {}} /> + + + , + ); + }); + + let during: Record = {}; + view.rerender( + + (during = value as Record)} + /> + + + , + ); + expect(during.chromeless).toBe(true); + }); + + it("brings the bar back when that route leaves", () => { + const { view } = setup(); + + act(() => { + view.rerender( + + {}} /> + + + , + ); + }); + + let after: Record = {}; + act(() => { + view.rerender( + + (after = value as Record)} + /> + + , + ); + }); + expect(after.chromeless).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx new file mode 100644 index 0000000000..540ec8cd6c --- /dev/null +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx @@ -0,0 +1,201 @@ +import type { ToolId } from "@app/types/toolId"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +export type QuickNavToolReasons = Partial>; + +export interface QuickNavIdentity { + displayName: string; + profilePictureUrl: string | null; +} + +export interface QuickNavHostData { + /** Sticky: one app unmounts before the next one registers. */ + appMounted: boolean; + identity: QuickNavIdentity | null; + signingBadge: number; + portalAccess: boolean; + readerMode: boolean; + /** The app owns the panel; the rail's bell only reports its state. */ + notificationsOpen: boolean; + /** Translated; absent means usable. */ + toolReasons: QuickNavToolReasons; + /** Mirrors `openSettings`, which lives in a ref and so cannot trigger a render. */ + hasSettings: boolean; +} + +export interface QuickNavHostActions { + openSettings?: () => void; + /** The editor reads its tool from the URL only on mount. */ + selectTool?: (toolId: ToolId) => void; + setReaderMode?: (on: boolean) => void; + toggleNotifications?: () => void; + goToDefaultState?: () => void; + requestNavigation?: (go: () => void) => void; +} + +interface QuickNavHostValue extends QuickNavHostData { + /** Reset on unmount, unlike the data above. */ + chromeless: boolean; + setChromeless: (chromeless: boolean) => void; + /** A ref, so a click reaches the app currently mounted. */ + actions: React.RefObject; + setData: (data: Partial) => void; + setActions: (actions: QuickNavHostActions) => void; +} + +const EMPTY_REASONS: QuickNavToolReasons = {}; + +const EMPTY_DATA: QuickNavHostData = { + appMounted: false, + toolReasons: EMPTY_REASONS, + identity: null, + signingBadge: 0, + portalAccess: false, + readerMode: false, + notificationsOpen: false, + hasSettings: false, +}; + +function sameReasons( + next: QuickNavToolReasons, + prev: QuickNavToolReasons, +): boolean { + const nextKeys = Object.keys(next); + if (nextKeys.length !== Object.keys(prev).length) return false; + return nextKeys.every((key) => next[key as ToolId] === prev[key as ToolId]); +} + +const QuickNavHostContext = createContext(null); + +/** Outside both apps' providers, so each app registers what only it knows. */ +export function QuickNavHostProvider({ children }: { children: ReactNode }) { + const [data, setDataState] = useState(EMPTY_DATA); + const [chromeless, setChromelessState] = useState(false); + const actions = useRef({}); + + const setData = useCallback((next: Partial) => { + setDataState((prev) => { + const merged = { ...prev, ...next }; + const unchanged = + merged.appMounted === prev.appMounted && + merged.signingBadge === prev.signingBadge && + merged.portalAccess === prev.portalAccess && + merged.readerMode === prev.readerMode && + merged.notificationsOpen === prev.notificationsOpen && + merged.hasSettings === prev.hasSettings && + merged.identity?.displayName === prev.identity?.displayName && + merged.identity?.profilePictureUrl === + prev.identity?.profilePictureUrl && + // Compared by value: the object is rebuilt every render. + sameReasons(merged.toolReasons, prev.toolReasons); + return unchanged ? prev : merged; + }); + }, []); + + const setActions = useCallback((next: QuickNavHostActions) => { + actions.current = next; + }, []); + + const setChromeless = useCallback((next: boolean) => { + setChromelessState(next); + }, []); + + const value = useMemo( + () => ({ + ...data, + chromeless, + actions, + setData, + setActions, + setChromeless, + }), + [data, chromeless, setData, setActions, setChromeless], + ); + + return ( + + {children} + + ); +} + +export function useQuickNavHost(): QuickNavHostValue | null { + return useContext(QuickNavHostContext); +} + +/** No-ops outside the provider. */ +export function useRegisterQuickNavHost( + data: Partial, + actions: QuickNavHostActions, +): void { + const host = useQuickNavHost(); + const { + identity, + signingBadge, + portalAccess, + readerMode, + notificationsOpen, + toolReasons, + } = data; + const hasSettings = Boolean(actions.openSettings); + + useEffect(() => { + host?.setData({ + appMounted: true, + identity: identity ?? null, + signingBadge: signingBadge ?? 0, + portalAccess: portalAccess ?? false, + readerMode: readerMode ?? false, + notificationsOpen: notificationsOpen ?? false, + // Omitted when unknown, so the last answer survives a re-fetch. + ...(toolReasons ? { toolReasons } : {}), + hasSettings, + }); + // By field: identity is rebuilt every render. + }, [ + host, + identity?.displayName, + identity?.profilePictureUrl, + signingBadge, + portalAccess, + readerMode, + notificationsOpen, + toolReasons, + hasSettings, + ]); + + const setActions = host?.setActions; + + // No deps: a click has to reach the current closure. + useEffect(() => { + setActions?.(actions); + }); + + // Handlers only: clearing the data too would blink the controls mid-switch. + useEffect( + () => () => { + setActions?.({}); + }, + [setActions], + ); +} + +/** `appMounted` is sticky, so a screen that isn't the app has to say so itself. */ +export function useSuppressQuickNavRail(active = true): void { + const host = useQuickNavHost(); + const setChromeless = host?.setChromeless; + useEffect(() => { + if (!active) return; + setChromeless?.(true); + return () => setChromeless?.(false); + }, [active, setChromeless]); +} diff --git a/frontend/editor/src/core/contexts/SidebarContext.tsx b/frontend/editor/src/core/contexts/SidebarContext.tsx index ac9ddbb0df..ce0e5184bb 100644 --- a/frontend/editor/src/core/contexts/SidebarContext.tsx +++ b/frontend/editor/src/core/contexts/SidebarContext.tsx @@ -62,6 +62,11 @@ export function SidebarProvider({ children }: SidebarProviderProps) { ); } +/** For components that render outside a SidebarProvider, such as the rail's tooltips. */ +export function useOptionalSidebarContext(): SidebarContextValue | undefined { + return useContext(SidebarContext); +} + export function useSidebarContext(): SidebarContextValue { const context = useContext(SidebarContext); if (context === undefined) { diff --git a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx index 6bc61fa294..070442a966 100644 --- a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx @@ -218,8 +218,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const setReaderMode = useCallback( (mode: boolean) => { if (mode) { + // Reading is a mode the open document is put into, not a tool run on it. actions.setWorkbench("viewer"); - actions.setSelectedTool("read"); } dispatch({ type: "SET_READER_MODE", payload: mode }); }, diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index 0d7a571f98..efb6af40d2 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -1,4 +1,11 @@ -import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { Group } from "@mantine/core"; @@ -14,6 +21,7 @@ import { useFileContext } from "@app/contexts/file/fileHooks"; import { useNavigationState, useNavigationActions, + useNavigationGuard, } from "@app/contexts/NavigationContext"; import { isApplyingRestoredView } from "@app/services/workbenchSession"; import { useViewer } from "@app/contexts/ViewerContext"; @@ -28,10 +36,21 @@ import FileSidebar from "@app/components/shared/FileSidebar"; import FileManager from "@app/components/FileManager"; import LocalIcon from "@app/components/shared/LocalIcon"; import AppConfigModal from "@app/components/shared/AppConfigModalLazy"; -import { getStartupNavigationAction } from "@app/utils/homePageNavigation"; +import { + getStartupNavigationAction, + getDefaultWorkbenchForFileCount, +} from "@app/utils/homePageNavigation"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { stripBasePath } from "@app/constants/app"; import { HomePageExtensions } from "@app/components/home/HomePageExtensions"; +import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge"; +import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext"; +import { + getToolDisabledReason, + getDisabledLabel, +} from "@app/components/tools/fullscreen/shared"; +import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; +import { consumeReaderModeRequest } from "@app/utils/pendingReaderMode"; import { FilesPageProvider, useFilesPage, @@ -42,6 +61,7 @@ import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel"; import type { FileSidebarProps } from "@app/components/shared/FileSidebar"; import { Button } from "@app/ui/Button"; +import "@app/components/layout/WorkspaceFrame.css"; import "@app/pages/HomePage.css"; const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed"; @@ -90,9 +110,11 @@ export default function HomePage() { handleToolSelect, handleBackToTools, readerMode, + setReaderMode, setLeftPanelView, toolAvailability, customWorkbenchViews, + toolRegistry, } = useToolWorkflow(); const navigate = useNavigate(); @@ -103,6 +125,7 @@ export default function HomePage() { const [activeMobileView, setActiveMobileView] = useState("tools"); const isProgrammaticScroll = useRef(false); const [configModalOpen, setConfigModalOpen] = useState(false); + const otherApp = useOtherAppSwitch(); const location = useLocation(); // Persisted user preference for the FileSidebar collapsed state. Auto- // collapse on /files is layered on top in the transition effect below and @@ -152,8 +175,64 @@ export default function HomePage() { const { activeFiles } = useFileContext(); const navigationState = useNavigationState(); + const { requestNavigation } = useNavigationGuard(); + + // From the processor's Reader entry. Ref-guarded: one-shot, and StrictMode double-invokes. + const consumedReaderRequest = useRef(false); + useEffect(() => { + if (consumedReaderRequest.current) return; + consumedReaderRequest.current = true; + if (consumeReaderModeRequest()) setReaderMode(true); + }, [setReaderMode]); const { actions } = useNavigationActions(); + const { searchInterfaceActions } = useViewer(); + + // Reading hides both search controls, so leave it first. e.code, for non-QWERTY layouts. + const focusSearchAfterRestore = useRef(false); + useEffect(() => { + if (!readerMode) return; + const onKeyDown = (e: KeyboardEvent) => { + const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey; + if (!combo) return; + if (e.code !== "KeyK" && e.code !== "KeyF") return; + // Same carve-out the search itself makes: a dialog owns the keyboard. + if ((e.target as HTMLElement | null)?.closest?.('[role="dialog"]')) + return; + e.preventDefault(); + setReaderMode(false); + if (e.code === "KeyK") { + focusSearchAfterRestore.current = true; + return; + } + // Visibility is state, so it can open before the bar it renders in exists. + searchInterfaceActions.open(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [readerMode, setReaderMode, searchInterfaceActions]); + + useEffect(() => { + if (readerMode || !focusSearchAfterRestore.current) return; + focusSearchAfterRestore.current = false; + requestAnimationFrame(() => + window.dispatchEvent(new Event("superSearch:focus")), + ); + }, [readerMode]); + + // Clean slate: no tool, out of the file library and reading. + const goToDefaultState = useCallback(() => { + handleBackToTools(); + if (location.pathname.startsWith("/files")) navigate(EDITOR_BASENAME); + actions.setWorkbench(getDefaultWorkbenchForFileCount(activeFiles.length)); + }, [ + handleBackToTools, + location.pathname, + navigate, + actions, + activeFiles.length, + ]); + // Sync the /files* URL into the workbench state so the file manager view // takes over the workbench area when the user lands on it. This is the // only state-of-truth for the active workbench, so keep the URL pinned. @@ -194,6 +273,17 @@ export default function HomePage() { prevWorkbenchRef.current = curr; // fileSidebarCollapsed read as snapshot on transition only. }, [navigationState.workbench]); + // Imperative, so the toggle still works while reading. Never persisted: not a preference. + const prevReaderModeRef = useRef(readerMode); + useEffect(() => { + if (readerMode !== prevReaderModeRef.current) { + setFileSidebarCollapsed( + readerMode ? true : readPersistedSidebarCollapsed(), + ); + prevReaderModeRef.current = readerMode; + } + }, [readerMode]); + const { setActiveFileIndex } = useViewer(); const prevFileCountRef = useRef(activeFiles.length); @@ -242,6 +332,38 @@ export default function HomePage() { const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); + // The tool picker's own helpers, so the wording can't drift. + const quickNavToolReasons = useMemo(() => { + const reasons: QuickNavToolReasons = {}; + for (const id of ["automate", "sharedSign"] as const) { + const tool = toolRegistry[id]; + if (!tool) continue; + const disabledReason = getToolDisabledReason( + id, + tool, + toolAvailability, + config?.premiumEnabled, + ); + if (!disabledReason) continue; + const { key, fallback } = getDisabledLabel(disabledReason); + reasons[id] = t(key, fallback).replace(/:\s*$/, ""); + } + return reasons; + }, [toolRegistry, toolAvailability, config?.premiumEnabled, t]); + + // Shared with the sidebar's own toggle. On /files it leaves rather than collapses. + const handleSidebarToggle = useCallback(() => { + if (navigationState.workbench === "myFiles") { + navigate(EDITOR_BASENAME); + return; + } + setFileSidebarCollapsed((c) => { + const next = !c; + writePersistedSidebarCollapsed(next); + return next; + }); + }, [navigationState.workbench, navigate]); + const [showSwipeHint, setShowSwipeHint] = useState( () => !readSwipeHintSeen(), ); @@ -395,6 +517,16 @@ export default function HomePage() { return (
+ setConfigModalOpen(true)} + requestNavigation={requestNavigation} + readerMode={readerMode} + onSetReaderMode={setReaderMode} + onGoToDefaultState={goToDefaultState} + onSelectTool={handleToolSelect} + toolReasons={quickNavToolReasons} + /> {isMobile ? (
- - ) : undefined - } - onToggleCollapse={() => { - if (navigationState.workbench === "myFiles") { - navigate(EDITOR_BASENAME); - return; +
+ { - const next = !c; - writePersistedSidebarCollapsed(next); - return next; - }); - }} - onOpenSettings={() => setConfigModalOpen(true)} - /> + toggleIcon={ + navigationState.workbench === "myFiles" ? ( + + ) : undefined + } + active={navigationState.workbench === "myFiles"} + // Forced: a deep link to /files has no transition to collapse on. + collapsed={ + navigationState.workbench === "myFiles" || + fileSidebarCollapsed + } + onToggleCollapse={handleSidebarToggle} + onOpenSettings={() => setConfigModalOpen(true)} + /> +
{!hideToolPanel && } diff --git a/frontend/editor/src/core/routes/hasPortal.ts b/frontend/editor/src/core/routes/hasPortal.ts new file mode 100644 index 0000000000..3d7f107ac3 --- /dev/null +++ b/frontend/editor/src/core/routes/hasPortal.ts @@ -0,0 +1,2 @@ +/** Whether this build ships the processor. Shadowed per build. */ +export const HAS_PORTAL = false; diff --git a/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts index df3ce94b23..a2e2c9c7e3 100644 --- a/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts +++ b/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts @@ -60,7 +60,8 @@ function fixture(filename: string): string { } async function openSamplePdfInViewer(page: import("@playwright/test").Page) { - await page.goto("/read"); + // Not /read: reading collapses the workbench bar these sidebars are toggled from. + await page.goto("/"); await page.waitForLoadState("domcontentloaded"); await page .locator('[data-testid="file-input"]') diff --git a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts index 67df3865e1..f0da928122 100644 --- a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts @@ -23,7 +23,8 @@ const SAMPLE_PDF = path.join( ); async function openViewerWithSample(page: import("@playwright/test").Page) { - await page.goto("/read"); + // Not /read: reading collapses the workbench bar these sidebars are toggled from. + await page.goto("/"); await page.waitForLoadState("domcontentloaded"); await page .locator('[data-testid="file-input"]') diff --git a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts index 48765392fe..c1a71ac48c 100644 --- a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts @@ -27,6 +27,7 @@ async function restoreEnabled( } const NO_RESTORE = "this build ships the workbench restore off"; +const NO_PORTAL = "this build ships no processor to switch to"; // Switching editor -> processor unmounts every editor provider; the session record // in sessionStorage is what brings the workbench back on return. @@ -73,8 +74,13 @@ test.describe("Workbench survives the editor/processor switch", () => { page.getByRole("radio", { name: /Active Files/i }), ).toBeChecked(); - // Out through the sidebar footer switch - the real user path. - await page.getByRole("button", { name: "Open PDF Processor" }).click(); + // Out through the rail's processor mark, the only chrome that offers the switch. + const processorMark = page.getByRole("button", { name: /^Processor$/i }); + test.skip( + !(await processorMark.isVisible({ timeout: 5_000 }).catch(() => false)), + NO_PORTAL, + ); + await processorMark.click(); await expect(page).toHaveURL(/\/processor/, { timeout: 15000 }); // Split the two halves of the feature: if this fails the writer is at fault, diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index 31f9666271..1a74158022 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -46,7 +46,7 @@ html[data-app-theme="light"] { non-text floor applies. Scheme-independent: a filled badge reads white on either ground. */ --c-success-solid: var(--p-green-700); - --c-danger-solid: var(--p-red-600); + --c-danger-solid: var(--p-red-700); --c-warning-solid: var(--p-amber-700); --c-neutral-solid: var(--p-gray-600); --c-accent-solid: var(--p-blue-600); diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css index 554baddcca..50531641cb 100644 --- a/frontend/editor/src/core/theme/dimensions.css +++ b/frontend/editor/src/core/theme/dimensions.css @@ -30,7 +30,12 @@ --radius-nav: 0.625rem; --nav-gutter: 0.5rem; - --nav-rail-w: 3.5rem; + /* Every minimised rail is this wide, so they line up as one column of icons. */ + --nav-rail-w: 3rem; + /* Header row, so the rail's brand and a sidebar's wordmark line up. */ + --nav-header-h: 3rem; + --sidebar-w: 16.25rem; + --sidebar-collapsed-w: var(--nav-rail-w); /* ── Layout sizing ── */ --footer-height: 2rem; diff --git a/frontend/editor/src/core/ui/NavSurface.tsx b/frontend/editor/src/core/ui/NavSurface.tsx index 38947fb91f..37caaa063e 100644 --- a/frontend/editor/src/core/ui/NavSurface.tsx +++ b/frontend/editor/src/core/ui/NavSurface.tsx @@ -2,8 +2,8 @@ import { forwardRef, type HTMLAttributes } from "react"; import "@app/ui/NavSurface.css"; export interface NavSurfaceProps extends HTMLAttributes { - /** Element to render; `section`/`aside` when the box is a landmark. */ - as?: "div" | "section" | "aside"; + /** Element to render; `section`/`aside`/`nav` when the box is a landmark. */ + as?: "div" | "section" | "aside" | "nav"; } /** diff --git a/frontend/editor/src/core/utils/homePageNavigation.ts b/frontend/editor/src/core/utils/homePageNavigation.ts index 001e026710..7a91bfec65 100644 --- a/frontend/editor/src/core/utils/homePageNavigation.ts +++ b/frontend/editor/src/core/utils/homePageNavigation.ts @@ -1,4 +1,4 @@ -import type { WorkbenchType } from "@app/types/workbench"; +import { getDefaultWorkbench, type WorkbenchType } from "@app/types/workbench"; export type StartupWorkbench = "viewer" | "fileEditor"; @@ -7,6 +7,13 @@ export interface StartupNavigationAction { activeFileIndex?: number; } +/** Several files means the file editor; one or none the viewer. */ +export function getDefaultWorkbenchForFileCount( + fileCount: number, +): WorkbenchType { + return fileCount > 1 ? "fileEditor" : getDefaultWorkbench(); +} + export function getStartupNavigationAction( previousFileCount: number, currentFileCount: number, diff --git a/frontend/editor/src/core/utils/pendingReaderMode.ts b/frontend/editor/src/core/utils/pendingReaderMode.ts new file mode 100644 index 0000000000..faacfba6b8 --- /dev/null +++ b/frontend/editor/src/core/utils/pendingReaderMode.ts @@ -0,0 +1,13 @@ +let pending = false; + +/** Carries "open in reading mode" across an app switch, and deliberately not a reload. */ +export function requestReaderMode(): void { + pending = true; +} + +/** True once per request. */ +export function consumeReaderModeRequest(): boolean { + if (!pending) return false; + pending = false; + return true; +} diff --git a/frontend/editor/src/core/utils/viewTransition.test.ts b/frontend/editor/src/core/utils/viewTransition.test.ts new file mode 100644 index 0000000000..5fb11c8aa2 --- /dev/null +++ b/frontend/editor/src/core/utils/viewTransition.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { withViewTransition } from "@app/utils/viewTransition"; + +// The stub carries only the field the helper reads, hence the cast through unknown. +type MutableDoc = { startViewTransition?: unknown }; +const doc = document as unknown as MutableDoc; + +function stubApi(): ReturnType { + const start = vi.fn((cb: () => void) => { + cb(); + return { finished: Promise.resolve() }; + }); + doc.startViewTransition = start; + return start; +} + +function stubReducedMotion(reduced: boolean): void { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: reduced && query.includes("prefers-reduced-motion"), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); +} + +afterEach(() => { + delete doc.startViewTransition; + vi.unstubAllGlobals(); +}); + +describe("withViewTransition", () => { + it("runs the update inside a transition when one is possible", async () => { + const start = stubApi(); + stubReducedMotion(false); + const update = vi.fn(); + + await withViewTransition(update); + + expect(start).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("skips the transition when the user asked for less motion", async () => { + // The state change must still happen - only the animation is dropped. + const start = stubApi(); + stubReducedMotion(true); + const update = vi.fn(); + + await withViewTransition(update); + + expect(start).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("still applies the update where the API is unavailable", async () => { + stubReducedMotion(false); + const update = vi.fn(); + + await withViewTransition(update); + + expect(update).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/core/utils/viewTransition.ts b/frontend/editor/src/core/utils/viewTransition.ts index 049c3e7673..85a05ca78d 100644 --- a/frontend/editor/src/core/utils/viewTransition.ts +++ b/frontend/editor/src/core/utils/viewTransition.ts @@ -4,21 +4,20 @@ type ViewTransitionDoc = Document & { startViewTransition?: (cb: () => void) => { finished: Promise }; }; -/** - * Run a state update inside a View Transition so the browser cross-fades - * (and morphs any elements sharing a {@code view-transition-name}) between - * the before/after DOMs. - * - * Falls back to a plain synchronous update when the API is unavailable - * (Firefox <130, JSDOM, motion-reduced preference). - */ +/** Runs a state update in a View Transition, plainly where that is unavailable. */ export function withViewTransition(update: () => void): Promise { if (typeof document === "undefined") { update(); return Promise.resolve(); } + // Callers don't each check: reduced motion still gets the state change. + const reduced = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const doc = document as ViewTransitionDoc; - if (doc.startViewTransition) { + if (doc.startViewTransition && !reduced) { return doc.startViewTransition(() => flushSync(update)).finished; } update(); diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx deleted file mode 100644 index 21896d9a1d..0000000000 --- a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Logo } from "@app/ui/Logo"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; - -/** - * Desktop inherits proprietary's layers but does not ship the portal (see - * desktop/routes/adminRouteExtensions), so there's nothing to switch to — - * shadow the brand header back to a plain logo. (Also avoids the desktop - * bundle referencing @portal via the proprietary switcher's imports.) - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - return ( - - ); -} diff --git a/frontend/editor/src/desktop/routes/hasPortal.ts b/frontend/editor/src/desktop/routes/hasPortal.ts new file mode 100644 index 0000000000..0eec40f4c9 --- /dev/null +++ b/frontend/editor/src/desktop/routes/hasPortal.ts @@ -0,0 +1,2 @@ +/** Desktop inherits proprietary's app but never ships the portal. */ +export const HAS_PORTAL = false; diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index fe8f775fd0..ec6c720297 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -7,8 +7,11 @@ import { PortalSearchBar } from "@portal/components/PortalSearchBar"; import { useUI } from "@portal/contexts/UIContext"; import { MenuIcon, SearchIcon } from "@portal/components/icons"; import { Logo } from "@app/ui/Logo"; +import "@app/components/layout/WorkspaceFrame.css"; +import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge"; import "@portal/components/AppShell.css"; import { NotificationBell } from "@app/components/notifications/NotificationBell"; +import { useIsPhone } from "@app/hooks/useIsMobile"; /** * Compact header shown only under the mobile breakpoint (CSS-hidden on @@ -58,8 +61,10 @@ function MobileTopbar() { * prop-free. */ export function AppShell({ children }: { children: ReactNode }) { - const { mobileNavOpen, closeMobileNav } = useUI(); + const { mobileNavOpen, closeMobileNav, openSettings } = useUI(); const { pathname } = useLocation(); + // Below this width the rail, and the bell it carries, is gone. + const isPhone = useIsPhone(); // Navigating (tap on a nav row, back button, deep link) always dismisses the // drawer. Depends on pathname only: the close fn's identity changes with any @@ -79,7 +84,11 @@ export function AppShell({ children }: { children: ReactNode }) { return (
- + {/* portalAccess: being here is proof the processor is available. */} + openSettings()} /> +
+ +
{mobileNavOpen && (
-
- -
+ {/* Phone only: above that the rail carries it, and this would be a second. */} + {isPhone && ( +
+ +
+ )}
{children}
diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx index e4918ed859..774dc4d1be 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx @@ -11,31 +11,9 @@ import { import { type EditorInstance } from "@portal/api/editorDeploy"; import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; import "@portal/theme/surface.css"; +import { BrandTile } from "@app/components/shared/BrandTile"; import "@portal/components/EditorStatusCard.css"; -/** The Stirling brand mark, drawn at the hero size. Decorative. */ -function StirlingMark() { - return ( - - - - - - ); -} - /** The instance to headline: the busiest healthy one, else the first. */ function primaryInstance(instances: EditorInstance[]): EditorInstance | null { if (instances.length === 0) return null; @@ -126,7 +104,7 @@ export function EditorStatusCard({ footer }: EditorStatusCardProps) { >
- +
diff --git a/frontend/editor/src/portal/components/PortalSearchBar.css b/frontend/editor/src/portal/components/PortalSearchBar.css index 8e253b6136..847991af20 100644 --- a/frontend/editor/src/portal/components/PortalSearchBar.css +++ b/frontend/editor/src/portal/components/PortalSearchBar.css @@ -1,5 +1,4 @@ -/* Unpainted strip at the top of the main column. Height matches the sidebar's - logo row (.portal-sidebar__logo, 51px) so the search lines up with the brand. */ +/* Unpainted strip at the top of the main column, matching the sidebar header's height. */ .portal-searchbar { display: flex; align-items: center; diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index 48b54b177d..43f8c44695 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -1,8 +1,10 @@ .portal-sidebar { - width: 15rem; + width: var(--sidebar-w); height: 100vh; height: 100dvh; /* track mobile browser chrome */ - background: var(--c-bg); + /* Matches the editor's sidebar: one solid panel with a rule on the content side. */ + background: var(--c-surface); + border-inline-end: 1px solid var(--c-border-subtle); display: flex; flex-direction: column; flex-shrink: 0; @@ -21,10 +23,6 @@ /* Nav labels stay on one line and are clipped by the narrowing rail so they reveal/hide cleanly as the width animates rather than wrapping. */ -.portal-sidebar__nav, -.portal-sidebar__footer { - overflow-x: hidden; -} .portal-sidebar .sui-navitem__label, .portal-sidebar__section-label { white-space: nowrap; @@ -34,6 +32,8 @@ .portal-sidebar__close { display: none; flex-shrink: 0; + /* Trailing edge: on mobile this is the row's only control. */ + margin-left: auto; } .portal-sidebar__collapse { @@ -78,23 +78,16 @@ /* ---- Collapsed icon rail (desktop only) ---- */ .portal-sidebar[data-collapsed] { - width: var(--nav-rail-w); -} -.portal-sidebar[data-collapsed] .portal-sidebar__logo { - flex-direction: column; - height: auto; - padding: 0.5rem 0; - gap: 0.375rem; -} -.portal-sidebar[data-collapsed] .portal-sidebar__collapse { - margin-left: 0; + width: var(--sidebar-collapsed-w); } +/* Flush, like the nav: the selected row runs the full width of the rail. */ .portal-sidebar[data-collapsed] .portal-sidebar__nav { - padding-inline: 0.375rem; + padding-inline: 0; } +/* Stretch, not centre: a centred group shrinks to its content, so rows can't fill the rail. */ .portal-sidebar[data-collapsed] .portal-sidebar__section { padding-inline: 0; - align-items: center; + align-items: stretch; } .portal-sidebar[data-collapsed] .portal-sidebar__section-label { display: none; @@ -111,23 +104,16 @@ margin-inline: 0; padding-inline: 0; width: 100%; -} -/* Neutralise the active-item edge-bar geometry (negative margins + overhang) - that assumes the full-width rail. */ -.portal-sidebar[data-collapsed] .sui-navitem.is-active { - width: 100%; - margin-inline: 0; - border-left: none; - border-radius: 0.5rem; - padding-left: 0; + /* A square target, so it takes the rail's radius rather than NavItem's pill. */ + border-radius: var(--radius-md); } .portal-sidebar[data-collapsed] .portal-sidebar__footer { margin-inline: 0.375rem; } -.portal-sidebar__logo { - height: 3.1875rem; /* 51px */ - padding: 0 0.875rem; +.portal-sidebar__header { + height: var(--nav-header-h); + padding: 0 var(--nav-gutter); display: flex; align-items: center; gap: 0.5rem; @@ -136,35 +122,58 @@ .portal-sidebar__nav { flex: 0 1 auto; overflow-y: auto; - padding: 0.75rem 0.625rem; + overflow-x: clip; + /* No inline inset above the rows, so a row is full width and needs no bleed past the clip. */ + padding: var(--nav-gutter) 0; display: flex; flex-direction: column; - gap: 0.5rem; + gap: var(--nav-gutter); } .portal-sidebar .sui-navitem { - margin-inline: 0.25rem; - padding-inline: 0.625rem; + margin-inline: 0; + padding-inline: 1.75rem; } -.portal-sidebar .sui-navitem.is-active { - width: calc(100% + 0.75rem); - margin-inline: -0.375rem; +/* The selected view, marked as the rail marks the current app: a knocked-out solid block. */ +.portal-sidebar .sui-navitem.is-active, +.portal-sidebar .sui-navitem.is-active:hover { + background: var(--c-text); + color: var(--c-surface); border-radius: 0; - border-left: 3px solid var(--c-primary); - padding-left: calc(1.25rem - 3px); +} + +/* On a dark ground full ink is white, so mix the block back toward the surface. */ +[data-theme="dark"] .portal-sidebar .sui-navitem.is-active, +[data-theme="dark"] .portal-sidebar .sui-navitem.is-active:hover, +html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active, +html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active:hover, +[data-mantine-color-scheme="dark"] .portal-sidebar .sui-navitem.is-active, +[data-mantine-color-scheme="dark"] + .portal-sidebar + .sui-navitem.is-active:hover { + background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface)); + color: var(--c-surface); } .portal-sidebar__section { - padding: 0.5rem 0.375rem 0.375rem; + padding: 0.5rem 0 0.375rem; display: flex; flex-direction: column; gap: 0.375rem; } +/* Flattened here; two classes deep to beat .sui-nav-surface regardless of load order. */ +.portal-sidebar .sui-nav-surface { + background: transparent; + border: 0; + border-radius: 0; +} + .portal-sidebar__section-label { margin: 0; - padding: 0 0.5rem; + /* Its own 0.5rem, plus the inset the nav and section no longer add. */ + padding: 0 1.375rem; font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.02em; @@ -181,4 +190,18 @@ only positions it. */ .portal-sidebar__footer { margin: 0 0.625rem 0.75rem; + overflow-x: hidden; +} + +/* Fills the frame, not the viewport: a 100vh sticky column would overhang it. */ +.workspace-frame .portal-sidebar { + height: 100%; + position: static; +} + +@media (max-width: 48rem) { + .workspace-frame .portal-sidebar { + position: fixed; + height: auto; + } } diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 685fa32722..2895ad4ec4 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -1,20 +1,16 @@ import { useMediaQuery } from "@mantine/hooks"; import { Tooltip } from "@mantine/core"; import { ActionIcon, NavItem, NavSurface } from "@app/ui"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; +import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton"; +import { Logo } from "@app/ui/Logo"; import { NavFooter } from "@app/components/shared/navFooter/NavFooter"; import { useAccountIdentity } from "@app/hooks/useAccountIdentity"; import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary"; import { useOpenPlan } from "@portal/hooks/useOpenPlan"; -import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; -import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; -import { EDITOR_BASENAME } from "@app/routes/editorBasename"; -import { takeEditorReturnPath } from "@app/services/workbenchSession"; import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, @@ -30,19 +26,17 @@ const NAV_SECTIONS: NavGroup[] = [ ]; /** Must match the shell breakpoint in AppShell.css / Sidebar.css. */ -const MOBILE_QUERY = "(max-width: 48rem)"; +export const MOBILE_QUERY = "(max-width: 48rem)"; export function Sidebar() { const { activeView, setActiveView } = useView(); const { - openSettings, mobileNavOpen, closeMobileNav, sidebarCollapsed, toggleSidebarCollapsed, } = useUI(); const { t } = useTranslation(); - const navigate = useNavigate(); const isMobile = useMediaQuery(MOBILE_QUERY, false, { getInitialValueInEffect: false, }); @@ -54,14 +48,6 @@ export function Sidebar() { // off-canvas drawer, so the icon-rail state never applies there. const collapsed = sidebarCollapsed && !isMobile; - // Editor and portal are one SPA when the editor serves this origin's root, so - // the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup) - // needs a full page load. - const goToEditor = () => { - if (EDITOR_IS_SAME_APP) navigate(takeEditorReturnPath() ?? EDITOR_BASENAME); - else window.location.href = EDITOR_URL; - }; - // Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into // a takeover modal (matching the marketing prototype). @@ -107,25 +93,13 @@ export function Sidebar() { // Off-canvas on mobile: remove from the tab order and accessibility tree. inert={isMobile && !mobileNavOpen} > -
- +
+ {!collapsed && } - - - + } collapsed={collapsed} /> diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index c64935bb16..ca040d4bbe 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -18,6 +18,8 @@ const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; +import { AppFrame } from "@app/components/layout/AppFrame"; +import { NoAppChrome } from "@app/components/layout/NoAppChrome"; import { RootGate } from "@app/routes/RootGate"; // Import global styles @@ -80,40 +82,52 @@ export default function App() { } /> - {/* Admin-only route-set (the portal): its own top-level shell, mounted - before the catch-all. Absent from core/desktop builds (empty stub). */} - {getAdminRouteExtensions()} + {/* Both apps, under a shared frame so the rail renders once outside them. */} + }> + {/* The portal: its own shell, before the catch-all. An empty stub in core. */} + {getAdminRouteExtensions()} - {/* All other routes need AppProviders for backend integration. - RootGate makes "/" route by role BEFORE any of it mounts, so a user - bound for the processor never boots the editor on the way. */} - - - - - } /> - {/* Self-hosted has no signup - accounts are created by an - admin. Old links land on login instead. */} - } - /> - } /> - } /> - } /> - {/* The editor and its tool routes - Landing handles auth logic */} - } /> - - - {WATCHED_FOLDERS_ENABLED && } - - - - } - /> + {/* All other routes need AppProviders for backend integration. RootGate + routes "/" by role before any of it mounts. */} + + + + + {/* Not the app: no rail over any of these, ever. */} + }> + } /> + {/* Self-hosted has no signup: old links land on login. */} + } + /> + } + /> + } + /> + } + /> + + {/* The editor and its tool routes - Landing handles auth logic */} + } /> + + + {WATCHED_FOLDERS_ENABLED && } + + + + } + /> + ); diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx deleted file mode 100644 index 9ba0b6438d..0000000000 --- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Logo } from "@app/ui/Logo"; -import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; -import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; -import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch"; - -/** - * Sidebar brand header for builds that ship the processor. When this user can - * open it, the Stirling logo doubles as the editor⇄processor switcher: the mark - * morphs into a chevron and opens the switch menu (the same BrandSwitcher the - * processor sidebar uses). Users without access get a plain logo. - * - * The access gate lives in {@link useOtherAppSwitch} so this header and the - * sidebar footer's "Open PDF Processor" row are driven by one answer. - */ -export function AppSwitcher({ collapsed }: AppSwitcherProps) { - const otherApp = useOtherAppSwitch(); - - if (!otherApp) { - return ( - - ); - } - - return ( - - ); -} diff --git a/frontend/editor/src/proprietary/data/processorEntitySearch.ts b/frontend/editor/src/proprietary/data/processorEntitySearch.ts index 544babf59f..58b32b64d3 100644 --- a/frontend/editor/src/proprietary/data/processorEntitySearch.ts +++ b/frontend/editor/src/proprietary/data/processorEntitySearch.ts @@ -6,15 +6,10 @@ import type { PortalEntityItems, PortalEntityScopeId, } from "@portal/search/entitySearch"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; type EntitySearchModule = typeof import("@portal/search/entitySearch"); -// Mirrors the admin-route seam's gate: the portal route-set is only mounted in -// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not -// fetch or offer entities that have nowhere to open. -const includePortal = - import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; - const NO_GROUPS: SuperSearchGroup[] = []; const NO_SCOPES: readonly PortalEntityScopeId[] = []; @@ -41,7 +36,8 @@ export function useProcessorEntityGroups( ): SuperSearchGroup[] { const [mod, setMod] = useState(null); const modRef = useRef(null); - const active = enabled && includePortal; + // Without the portal these entities have nowhere to open, so don't fetch them. + const active = enabled && HAS_PORTAL; const hasQuery = trimmed.length > 0; useEffect(() => { diff --git a/frontend/editor/src/proprietary/data/processorSearchIndex.ts b/frontend/editor/src/proprietary/data/processorSearchIndex.ts index ae48893e25..8d99b2f77a 100644 --- a/frontend/editor/src/proprietary/data/processorSearchIndex.ts +++ b/frontend/editor/src/proprietary/data/processorSearchIndex.ts @@ -3,15 +3,10 @@ import { PORTAL_BASENAME } from "@app/routes/portalBasename"; // the lazy portal chunk into the main bundle the way @portal/* values would. import { usersCapabilities } from "@app/portal/usersCapabilities"; import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; export type { ProcessorSearchEntry }; -// Mirrors the admin-route seam's gate: the portal route-set is only mounted in -// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not -// offer destinations that would 404 elsewhere. -const includePortal = - import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; - /** * The portal's in-app views. Deliberately a static mirror of the portal's nav * (labels via the same portal.nav.* keys its sidebar uses) rather than an @@ -91,7 +86,8 @@ const VIEWS: ProcessorSearchEntry[] = [ }, ]; -export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal +// Empty without the portal: these destinations would 404. +export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = HAS_PORTAL ? VIEWS : []; diff --git a/frontend/editor/src/proprietary/routes/Landing.tsx b/frontend/editor/src/proprietary/routes/Landing.tsx index 62c9083aad..7406078d30 100644 --- a/frontend/editor/src/proprietary/routes/Landing.tsx +++ b/frontend/editor/src/proprietary/routes/Landing.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext"; import HomePage from "@app/pages/HomePage"; import { useBackendProbe } from "@app/hooks/useBackendProbe"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; @@ -27,6 +28,9 @@ export default function Landing() { const loading = authLoading || configLoading || backendProbe.loading; + // The backend-down screen is not the app. Loading is: it resolves in a moment. + useSuppressQuickNavRail(!session && backendProbe.status !== "up"); + // Debug: Track Landing component lifecycle useEffect(() => { const mountId = Math.random().toString(36).substring(7); diff --git a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx index 22ce46f03c..a422376086 100644 --- a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx +++ b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx @@ -2,11 +2,9 @@ import { lazy } from "react"; import type { ReactElement } from "react"; import { Route } from "react-router-dom"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { HAS_PORTAL } from "@app/routes/hasPortal"; -const includePortal = - import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; - -const PortalApp = includePortal +const PortalApp = HAS_PORTAL ? lazy(async () => { const m = await import("@portal/PortalApp"); return { default: m.PortalApp }; @@ -16,7 +14,7 @@ const PortalApp = includePortal /** * Return leg of the account-link handshake, which Stirling redirects to with the admin's session in the URL fragment. */ -const ConnectCallback = includePortal +const ConnectCallback = HAS_PORTAL ? lazy(async () => { const m = await import("@portal/views/ConnectCallback"); return { default: m.default }; diff --git a/frontend/editor/src/proprietary/routes/hasPortal.ts b/frontend/editor/src/proprietary/routes/hasPortal.ts new file mode 100644 index 0000000000..e3c54bc50e --- /dev/null +++ b/frontend/editor/src/proprietary/routes/hasPortal.ts @@ -0,0 +1,3 @@ +/** Dev always ships it, so the switch is there to work on. */ +export const HAS_PORTAL = + import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 2e091ebf05..07a563ea5f 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -19,6 +19,8 @@ import OAuthConsent from "@app/routes/OAuthConsent"; import ConnectApprove from "@app/routes/ConnectApprove"; import ShareLinkPage from "@app/routes/ShareLinkPage"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; +import { AppFrame } from "@app/components/layout/AppFrame"; +import { NoAppChrome } from "@app/components/layout/NoAppChrome"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; @@ -96,46 +98,63 @@ export default function App() { } /> - {/* Admin-only route-set (the portal): its own top-level shell, mounted - before the catch-all. */} - {getAdminRouteExtensions()} + {/* Both apps, under a shared frame so the rail renders once outside them. */} + }> + {/* The portal: its own top-level shell, before the catch-all. */} + {getAdminRouteExtensions()} - {/* Everything else needs the auth/backend providers. RootGate makes "/" - route by role BEFORE any of it mounts, so a user bound for the - processor never boots the editor on the way. */} - - - - - - - } /> - } /> - } /> - } /> - } /> - {/* Human half of the self-hosted account-link handshake. It - lives on this origin because a customer hostname can - never be in the provider's redirect allow-list. */} - } /> - {/* Shared-file links. Team invites are NOT routed here: on - SaaS they are accepted in-app via the Supabase team - invitation banner, not the Spring password-based - /invite/:token page used by the self-hosted build. */} - } /> - } /> - - - - - - } - /> + {/* Everything else needs the auth/backend providers. RootGate routes "/" + by role before any of it mounts. */} + + + + + + + {/* Not the app: no rail over any of these, ever. */} + }> + } /> + } /> + } + /> + } /> + } + /> + {/* Human half of the self-hosted account-link handshake. + It lives on this origin because a customer hostname can + never be in the provider's redirect allow-list. Grouped + with the pages above: it is an approval step, not the + app. */} + } /> + {/* Shared-file links. Team invites are NOT routed here: + on SaaS they are accepted in-app via the Supabase team + invitation banner, not the Spring password-based + /invite/:token page used by the self-hosted build. */} + } + /> + + } /> + + + + + + } + /> + ); From a48356a2d22babeae7ad7c66673bee9a405fa083 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:17:21 +0100 Subject: [PATCH 16/57] Deploy a dev SaaS server alongside the PR previews and main demo (#7697) --- .github/workflows/Saas-Dev-Deploy.yml | 246 ++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 .github/workflows/Saas-Dev-Deploy.yml diff --git a/.github/workflows/Saas-Dev-Deploy.yml b/.github/workflows/Saas-Dev-Deploy.yml new file mode 100644 index 0000000000..733b92bca2 --- /dev/null +++ b/.github/workflows/Saas-Dev-Deploy.yml @@ -0,0 +1,246 @@ +name: Auto SaaS Dev Deployment + +on: + push: + branches: + - saas-prod + workflow_dispatch: + +permissions: + contents: read + +env: + FRONTEND_PORT: "901" + BACKEND_PORT: "902" + DEPLOY_DIR: /stirling/SAAS-DEV + +jobs: + deploy-saas-dev: + runs-on: ubuntu-latest + environment: saas-dev + concurrency: + group: saas-dev-deploy + cancel-in-progress: true + permissions: + contents: read + packages: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Check SaaS configuration + id: config + env: + PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }} + run: | + echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT" + echo "meter_endpoint=https://${PROJECT_REF}.supabase.co/functions/v1/meter-payg-units" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Convert repository owner to lowercase + id: repoowner + run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT + + - name: Get commit hash + id: commit-hash + run: echo "app_short=$(git rev-parse --short=8 HEAD)" >> $GITHUB_OUTPUT + + - name: Build and push backend image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./docker/backend/Dockerfile + push: true + cache-from: type=gha,scope=stirling-saas-backend + cache-to: type=gha,mode=max,scope=stirling-saas-backend + tags: | + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-${{ steps.commit-hash.outputs.app_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-latest + build-args: | + VERSION_TAG=v2-alpha + STIRLING_FLAVOR=saas + platforms: linux/amd64 + + - name: Build and push frontend image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./docker/frontend/Dockerfile + push: true + cache-from: type=gha,scope=stirling-saas-frontend + cache-to: type=gha,mode=max,scope=stirling-saas-frontend + tags: | + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-${{ steps.commit-hash.outputs.app_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-latest + build-args: | + VERSION_TAG=v2-alpha + STIRLING_FLAVOR=saas + VITE_BUILD_MODE=development + VITE_SUPABASE_URL=${{ steps.config.outputs.supabase_url }} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }} + platforms: linux/amd64 + + - name: Build and push AI engine image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./engine/Dockerfile + push: true + cache-from: type=gha,scope=stirling-saas-engine + cache-to: type=gha,mode=max,scope=stirling-saas-engine + tags: | + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-${{ steps.commit-hash.outputs.app_short }} + ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-latest + platforms: linux/amd64 + + - name: Set up SSH + env: + SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }} + run: | + mkdir -p ~/.ssh/ + echo "$SSH_KEY" > ../private.key + sudo chmod 600 ../private.key + + - name: Deploy to VPS + env: + IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test + IMAGE_TAG: ${{ steps.commit-hash.outputs.app_short }} + GHCR_USER: ${{ github.actor }} + GHCR_TOKEN: ${{ github.token }} + VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }} + VPS_HOST: ${{ secrets.NEW_VPS_HOST }} + SAAS_DB_URL: ${{ secrets.SAAS_DB_URL }} + SAAS_DB_USERNAME: ${{ secrets.SAAS_DB_USERNAME || 'postgres' }} + SAAS_DB_PASSWORD: ${{ secrets.SAAS_DB_PASSWORD }} + SAAS_DB_PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }} + SUPABASE_EDGE_FUNCTION_SECRET: ${{ secrets.SUPABASE_EDGE_FUNCTION_SECRET }} + PAYG_METER_ENDPOINT: ${{ steps.config.outputs.meter_endpoint }} + STIRLING_KEYGEN_ENABLED: ${{ secrets.KEYGEN_ACCOUNT_ID != '' && secrets.KEYGEN_API_TOKEN != '' && secrets.KEYGEN_POLICY_ID != '' }} + KEYGEN_ACCOUNT_ID: ${{ secrets.KEYGEN_ACCOUNT_ID }} + KEYGEN_API_TOKEN: ${{ secrets.KEYGEN_API_TOKEN }} + KEYGEN_POLICY_ID: ${{ secrets.KEYGEN_POLICY_ID }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }} + run: | + set -euo pipefail + + BASE_URL="http://${VPS_HOST}:${FRONTEND_PORT}" + + yaml() { + printf "'%s'" "$(printf '%s' "$1" | sed -e "s/'/''/g" -e 's/\$/$$/g')" + } + + ENGINE_SECRET="$(openssl rand -hex 32)" + AI_BACKEND_VARS=" + SYSTEM_AIENGINE_ENABLED: \"true\" + SYSTEM_AIENGINE_URL: \"http://saas-engine:5001\" + APP_AI_SERVICEBASEURL: \"http://saas-engine:5001\" + STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")" + AI_SERVICE=" + + saas-engine: + container_name: stirling-saas-dev-engine + image: ${IMAGE_BASE}:saas-engine-${IMAGE_TAG} + environment: + ANTHROPIC_API_KEY: $(yaml "$ANTHROPIC_API_KEY") + VOYAGE_API_KEY: $(yaml "$VOYAGE_API_KEY") + STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET") + restart: on-failure:5" + + cat > docker-compose.yml << EOF + version: '3.3' + services: + saas-backend: + container_name: stirling-saas-dev-backend + image: ${IMAGE_BASE}:saas-backend-${IMAGE_TAG} + ports: + - "${BACKEND_PORT}:8080" + volumes: + - ${DEPLOY_DIR}/config:/configs:rw + - ${DEPLOY_DIR}/logs:/logs:rw + - ${DEPLOY_DIR}/storage:/storage:rw + environment: + SPRING_PROFILES_ACTIVE: "saas" + DISABLE_ADDITIONAL_FEATURES: "false" + SAAS_DB_URL: $(yaml "$SAAS_DB_URL") + SAAS_DB_USERNAME: $(yaml "$SAAS_DB_USERNAME") + SAAS_DB_PASSWORD: $(yaml "$SAAS_DB_PASSWORD") + SAAS_DB_PROJECT_REF: $(yaml "$SAAS_DB_PROJECT_REF") + SUPABASE_EDGE_FUNCTION_SECRET: $(yaml "$SUPABASE_EDGE_FUNCTION_SECRET") + PAYG_METER_ENDPOINT: $(yaml "$PAYG_METER_ENDPOINT") + STIRLING_KEYGEN_ENABLED: $(yaml "$STIRLING_KEYGEN_ENABLED") + KEYGEN_ACCOUNT_ID: $(yaml "$KEYGEN_ACCOUNT_ID") + KEYGEN_API_TOKEN: $(yaml "$KEYGEN_API_TOKEN") + KEYGEN_POLICY_ID: $(yaml "$KEYGEN_POLICY_ID") + SYSTEM_DEFAULTLOCALE: en-US + SYSTEM_MAXFILESIZE: "100" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "false" + SWAGGER_SERVER_URL: "${BASE_URL}" + baseUrl: "${BASE_URL}"${AI_BACKEND_VARS} + restart: on-failure:5 + + saas-frontend: + container_name: stirling-saas-dev-frontend + image: ${IMAGE_BASE}:saas-frontend-${IMAGE_TAG} + ports: + - "${FRONTEND_PORT}:80" + environment: + VITE_API_BASE_URL: "http://saas-backend:8080" + depends_on: + - saas-backend + restart: on-failure:5${AI_SERVICE} + EOF + + SSH_OPTS=(-i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null) + + scp "${SSH_OPTS[@]}" docker-compose.yml "${VPS_USERNAME}@${VPS_HOST}:/tmp/saas-dev-docker-compose.yml" + + ssh "${SSH_OPTS[@]}" -T "${VPS_USERNAME}@${VPS_HOST}" << ENDSSH + set -e + mkdir -p ${DEPLOY_DIR}/{config,logs,storage} + mv /tmp/saas-dev-docker-compose.yml ${DEPLOY_DIR}/docker-compose.yml + chmod 600 ${DEPLOY_DIR}/docker-compose.yml + cd ${DEPLOY_DIR} + printf '%s' "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin + docker-compose down --remove-orphans 2>/dev/null || true + docker-compose pull + docker-compose up -d + docker logout ghcr.io >/dev/null 2>&1 || true + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true + ENDSSH + + - name: Wait for the backend to answer + env: + VPS_HOST: ${{ secrets.NEW_VPS_HOST }} + run: | + URL="http://${VPS_HOST}:${BACKEND_PORT}/api/v1/info/status" + for i in $(seq 1 60); do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL" || true) + if [ "$code" = "200" ]; then echo "Healthy after $((i * 10))s"; exit 0; fi + sleep 10 + done + echo "::error::SaaS dev backend did not become healthy within 10 minutes" + exit 1 + + - name: Cleanup temporary files + if: always() + run: rm -f ../private.key docker-compose.yml + continue-on-error: true From 849d616451feb79b706ad6f9a9250924327b0020 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 27 Aug 2026 23:45:10 +0100 Subject: [PATCH 17/57] Fix refreshing causing you to go to the Processor (#7694) --- .../common/util/RequestUriUtilsTest.java | 10 ++++++++ .../configuration/SecurityConfiguration.java | 6 ++--- .../src/proprietary/routes/Login.test.tsx | 23 ++++++++++++++++++- .../editor/src/proprietary/routes/Login.tsx | 8 +++++-- .../proprietary/services/apiClientSetup.ts | 4 ++++ 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 72e5eae9a2..c144f9726e 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -113,6 +113,16 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf")); } + @Test + void testIsFrontendRoute_editorRouteOwnedByFrontend() { + // /editor (and its tool routes) is an SPA route: a direct-nav/refresh must + // serve index.html, not the auth filter's 302-to-/login. Regression test for + // the editor moving from / to /editor, whose refresh bounced processor users + // to the processor because the redirect dropped the return path. + assertTrue(RequestUriUtils.isFrontendRoute("", "/editor")); + assertTrue(RequestUriUtils.isFrontendRoute("/app", "/app/editor")); + } + @Test void testIsFrontendRoute_filesRouteOwnedByFrontend() { // /files and /files/ are FileManagerView routes - they diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 9fc4428f73..0f0c7315d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -357,12 +357,12 @@ public class SecurityConfiguration { req -> { String uri = req.getRequestURI(); String contextPath = req.getContextPath(); - // Check if it's a public auth endpoint or static - // resource return RequestUriUtils.isStaticResource( contextPath, uri) || RequestUriUtils.isPublicAuthEndpoint( - uri, contextPath); + uri, contextPath) + || RequestUriUtils.isFrontendRoute( + contextPath, uri); }) .permitAll() .anyRequest() diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index 29ae9d9d79..5553f202fb 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -234,7 +234,10 @@ describe("Login", () => { ); }; - afterEach(() => window.history.replaceState({}, "", "/")); + afterEach(() => { + window.history.replaceState({}, "", "/"); + sessionStorage.clear(); + }); it("returns to where the user came from", async () => { signedIn(); @@ -247,6 +250,24 @@ describe("Login", () => { }); }); + // A full-page 401 redirect drops router state and Spring can strip ?from=, + // leaving the return path only in the sessionStorage stash. Without reading + // it here a processor user refreshing /editor falls through to the role + // router and lands on the processor. + it("returns to the stashed path when there is no ?from=", async () => { + signedIn(); + sessionStorage.setItem("stirling_post_login_path", "/compress"); + renderAtLogin(""); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith("/compress", { + replace: true, + }); + }); + // Consumed, so a later sign-in can't reuse a stale path. + expect(sessionStorage.getItem("stirling_post_login_path")).toBeNull(); + }); + // Delegated to the shared isSafePostLoginRedirect, so the backslash form // (browsers normalise "\" to "/") and auth routes are covered too. it.each([ diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index b134d9ee89..7fd3e86670 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -7,7 +7,10 @@ import { } from "react-router-dom"; import { Button } from "@app/ui/Button"; import { isSafePostLoginRedirect } from "@app/auth"; -import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; +import { + setPostLoginRedirectPath, + consumePostLoginRedirectPath, +} from "@app/auth/spring/springAuthClient"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useTranslation } from "react-i18next"; @@ -207,7 +210,8 @@ export default function Login() { useEffect(() => { if (loading) return; if (!session) return; - const returnPath = resolveReturnPath(); + const stashed = consumePostLoginRedirectPath(); + const returnPath = resolveReturnPath() ?? stashed; if (returnPath) { navigate(returnPath, { replace: true }); return; diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts index 8615156e9a..3f50e1bb4c 100644 --- a/frontend/editor/src/proprietary/services/apiClientSetup.ts +++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts @@ -1,6 +1,7 @@ import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; import { withBasePath } from "@app/constants/app"; import { getBrowserId } from "@app/utils/browserIdentifier"; +import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; let isRefreshing = false; let failedQueue: Array<{ @@ -93,6 +94,9 @@ async function refreshAuthToken(client: AxiosInstance): Promise { // Redirect to login const loginPath = withBasePath("/login"); if (window.location.pathname !== loginPath) { + setPostLoginRedirectPath( + window.location.pathname + window.location.search, + ); console.log("[API Client] Redirecting to login page..."); window.location.href = loginPath; } From 0a3f0c181474e1b0ebd03744330481aee6f495be Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 28 Aug 2026 10:03:52 +0000 Subject: [PATCH 18/57] Fix redirect bugs in SaaS (#7721) # Description of Changes Fixes various bugs that affected SaaS (and some self-hosted): - Refreshing on Editor caused the user to be redirected to Processor - User was unable to access Processor in SaaS - Deep link hijacking fixes - Fix double prefix `/app/app` issue - Fix going from tool -> editor -> processor -> editor putting you back into tool --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../shared/quickNav/QuickNavRailHost.tsx | 4 +- .../src/core/contexts/ToolWorkflowContext.tsx | 16 +++ .../editor/src/core/hooks/useUrlSync.test.tsx | 80 +++++++++++ frontend/editor/src/core/hooks/useUrlSync.ts | 30 +++- .../httpErrorHandler.basePath.test.ts | 76 +++++++++++ .../src/core/services/httpErrorHandler.ts | 28 ++-- .../core/services/postLoginRedirect.test.ts | 39 ++++++ .../src/core/services/postLoginRedirect.ts | 15 ++ .../core/services/workbenchSession.test.ts | 8 +- .../src/core/services/workbenchSession.ts | 5 +- .../auth/spring/springAuthClient.ts | 22 +-- .../proprietary/auth/supabase/UseSession.tsx | 14 +- .../auth/supabase/portalAccessFetch.test.tsx | 128 ++++++++++++++++++ .../hooks/useOtherAppSwitch.test.tsx | 5 +- .../proprietary/hooks/useOtherAppSwitch.ts | 5 +- .../proprietary/services/apiClientSetup.ts | 6 +- .../services/postLoginRedirect.test.ts | 25 ++++ .../proprietary/services/postLoginRedirect.ts | 7 + .../src/saas/hooks/useOtherAppSwitch.ts | 5 +- .../editor/src/saas/routes/AuthCallback.tsx | 21 ++- frontend/editor/src/saas/routes/Login.tsx | 13 +- .../src/saas/services/apiClient.test.ts | 51 ++++++- .../editor/src/saas/services/apiClient.ts | 25 +++- 23 files changed, 545 insertions(+), 83 deletions(-) create mode 100644 frontend/editor/src/core/hooks/useUrlSync.test.tsx create mode 100644 frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts create mode 100644 frontend/editor/src/core/services/postLoginRedirect.test.ts create mode 100644 frontend/editor/src/core/services/postLoginRedirect.ts create mode 100644 frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx create mode 100644 frontend/editor/src/proprietary/services/postLoginRedirect.test.ts create mode 100644 frontend/editor/src/proprietary/services/postLoginRedirect.ts diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx index 5c748b36cc..3fba4d7e1f 100644 --- a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx @@ -20,7 +20,7 @@ const SIZE = "1.125rem"; export function QuickNavRailHost() { const { t } = useTranslation(); const navigate = useNavigate(); - const { pathname, search } = useLocation(); + const { pathname } = useLocation(); const host = useQuickNavHost(); const appMounted = Boolean(host?.appMounted); @@ -74,7 +74,7 @@ export function QuickNavRailHost() { returnHome(); return; } - saveEditorReturnPath(pathname + search); + saveEditorReturnPath(); go(PORTAL_BASENAME); }, }, diff --git a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx index 070442a966..b4ef874184 100644 --- a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx @@ -29,6 +29,8 @@ import { isBaseWorkbench, } from "@app/types/workbench"; import { useNavigationUrlSync } from "@app/hooks/useUrlSync"; +import { stripBasePath } from "@app/constants/app"; +import { EDITOR_BASENAME } from "@app/routes/editorBasename"; import { filterToolRegistryByQuery } from "@app/utils/toolSearch"; import { useToolHistory } from "@app/hooks/tools/useUserToolActivity"; import { @@ -373,15 +375,28 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { // This runs once to navigate to the user's preferred tab (read/automate) // instead of always starting on the tools tab. const hasAppliedStartupView = React.useRef(false); + // Set when the startup view picks the tool, so the URL sync knows this + // selection came from a preference and must not be written to the address. + const startupSelectedToolRef = React.useRef(null); useEffect(() => { if (hasAppliedStartupView.current) return; + // The URL wins: the startup view decides what you see when you arrive at the + // editor's home, never what a deep link to a tool shows. Without this, a + // "Reader" preference rewrote every / link to /read. + const path = stripBasePath(window.location.pathname); + if (path !== "/" && path !== EDITOR_BASENAME) { + hasAppliedStartupView.current = true; + return; + } const startupView = preferences.defaultStartupView; if (startupView === "read") { hasAppliedStartupView.current = true; + startupSelectedToolRef.current = "read"; setReaderMode(true); actions.setSelectedTool("read"); } else if (startupView === "automate") { hasAppliedStartupView.current = true; + startupSelectedToolRef.current = "automate"; actions.setSelectedTool("automate"); setLeftPanelView("toolContent"); } @@ -573,6 +588,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { handleBackToTools, allTools, true, + startupSelectedToolRef, ); // Ref-backed wrappers so callback identities stay stable across renders. diff --git a/frontend/editor/src/core/hooks/useUrlSync.test.tsx b/frontend/editor/src/core/hooks/useUrlSync.test.tsx new file mode 100644 index 0000000000..e35c7dbd44 --- /dev/null +++ b/frontend/editor/src/core/hooks/useUrlSync.test.tsx @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useRef } from "react"; +import type { ToolId } from "@app/types/toolId"; + +const h = vi.hoisted(() => ({ + updateToolRoute: vi.fn(), + clearToolRoute: vi.fn(), +})); + +vi.mock("@app/utils/urlRouting", () => ({ + parseToolRoute: () => ({ workbench: "fileEditor", toolId: null }), + updateToolRoute: h.updateToolRoute, + clearToolRoute: h.clearToolRoute, +})); +vi.mock("@app/utils/scarfTracking", () => ({ firePixel: vi.fn() })); +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ config: { premiumEnabled: true } }), +})); + +import { useNavigationUrlSync } from "@app/hooks/useUrlSync"; + +const registry = { + read: { name: "Read", workbench: "viewer" }, + compress: { name: "Compress", workbench: "fileEditor" }, +} as never; + +/** Drives the hook the way ToolWorkflowContext does, with a startup marker. */ +function useHarness(selectedTool: ToolId | null, startupTool: ToolId | null) { + const ref = useRef(startupTool); + useNavigationUrlSync(selectedTool, vi.fn(), vi.fn(), registry, true, ref); + return ref; +} + +describe("useNavigationUrlSync — startup-view selections", () => { + beforeEach(() => h.updateToolRoute.mockClear()); + + // The default-startup-view preference selects a tool to change the *view*. + // Writing it to the address turned every visit to /editor into /read. + it("never writes the URL for the startup-applied tool", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + expect(h.updateToolRoute).not.toHaveBeenCalled(); + }); + + // The effect re-runs whenever the registry identity changes, so a marker that + // was consumed on first sight let the second run write /read anyway. + it("survives a re-run for the same tool", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + rerender({ tool: "read" as ToolId }); + rerender({ tool: "read" as ToolId }); + expect(h.updateToolRoute).not.toHaveBeenCalled(); + }); + + it("still writes the URL when the user picks a different tool", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + rerender({ tool: "compress" as ToolId }); + expect(h.updateToolRoute).toHaveBeenCalledWith("compress", registry, false); + }); + + it("writes the URL for a tool chosen without a startup marker", () => { + const { rerender } = renderHook( + ({ tool }: { tool: ToolId | null }) => useHarness(tool, null), + { initialProps: { tool: null as ToolId | null } }, + ); + rerender({ tool: "read" as ToolId }); + expect(h.updateToolRoute).toHaveBeenCalledWith("read", registry, false); + }); +}); diff --git a/frontend/editor/src/core/hooks/useUrlSync.ts b/frontend/editor/src/core/hooks/useUrlSync.ts index 5fad71ba29..76fd67178a 100644 --- a/frontend/editor/src/core/hooks/useUrlSync.ts +++ b/frontend/editor/src/core/hooks/useUrlSync.ts @@ -2,7 +2,7 @@ * URL synchronization hooks for tool routing with registry support */ -import { useEffect, useCallback, useRef } from "react"; +import { useEffect, useCallback, useRef, type MutableRefObject } from "react"; import { ToolId } from "@app/types/toolId"; import { parseToolRoute, @@ -24,6 +24,11 @@ export function useNavigationUrlSync( clearToolSelection: () => void, registry: ToolRegistry, enableSync: boolean = true, + /** + * Tool the default-startup-view preference selected, if any. That selection + * sets the view, not the address, so it must not be written to the URL. + */ + startupSelectedToolRef?: MutableRefObject, ) { const { config } = useAppConfig(); const premiumEnabled = config?.premiumEnabled; @@ -77,8 +82,16 @@ export function useNavigationUrlSync( useEffect(() => { if (!enableSync) return; + const startupTool = startupSelectedToolRef?.current ?? null; + if (selectedTool) { - updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation + // A startup-view selection is a view preference, not a navigation: writing + // it here rewrote /editor to /read on every load. The effect re-runs + // whenever the registry identity changes, so the marker has to survive + // until the selection actually moves off it (cleared below). + if (startupTool !== selectedTool) { + updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation + } } else if (prevSelectedTool.current !== null) { // Only clear URL if we had a tool before (user navigated away) // Don't clear on initial load when both current and previous are null @@ -88,8 +101,19 @@ export function useNavigationUrlSync( } } + // Spent once the user leaves the startup-applied tool, so re-picking it + // later is a real navigation and does update the URL. + if ( + startupSelectedToolRef && + startupTool !== null && + prevSelectedTool.current === startupTool && + selectedTool !== startupTool + ) { + startupSelectedToolRef.current = null; + } + prevSelectedTool.current = selectedTool; - }, [selectedTool, registry, enableSync]); + }, [selectedTool, registry, enableSync, startupSelectedToolRef]); // Handle browser back/forward navigation useEffect(() => { diff --git a/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts b/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts new file mode 100644 index 0000000000..27a4231a2c --- /dev/null +++ b/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@app/components/toast", () => ({ alert: vi.fn() })); +vi.mock("@app/services/specialErrorToasts", () => ({ + showSpecialErrorToast: vi.fn(() => false), +})); +vi.mock("@app/services/saasErrorInterceptor", () => ({ + handleSaaSError: vi.fn(() => false), +})); +vi.mock("@app/services/errorUtils", () => ({ + broadcastErroredFiles: vi.fn(), + extractErrorFileIds: vi.fn(() => []), + normalizeAxiosErrorData: vi.fn(async (d: unknown) => d), +})); + +const hrefs: string[] = []; + +/** Serve the app from `base`, sitting on `pathname`, then load the handler fresh. */ +async function loadAt(base: string, pathname: string) { + document.head.innerHTML = ``; + Object.defineProperty(window, "location", { + configurable: true, + value: { + pathname, + search: "", + origin: "http://localhost:3000", + get href() { + // Absolute: jsdom resolves against this. + return "http://localhost:3000" + pathname; + }, + set href(v: string) { + hrefs.push(v); + }, + }, + }); + vi.resetModules(); + return (await import("@app/services/httpErrorHandler")).handleHttpError; +} + +const unauthorized = { + isAxiosError: true, + message: "unauthorized", + config: {}, + response: { status: 401, data: {} }, +}; + +beforeEach(() => { + hrefs.length = 0; + sessionStorage.clear(); + localStorage.clear(); +}); +afterEach(() => vi.resetModules()); + +describe("401 return path is router-relative", () => { + // Login replays this through navigate(), which re-applies the router + // basename. Carrying /app here produced /app/app/compress. + it("strips the base path on a subpath deploy", async () => { + const handle = await loadAt("/app/", "/app/compress"); + await handle(unauthorized); + + expect(sessionStorage.getItem("stirling_post_login_path")).toBe( + "/compress", + ); + expect(hrefs[0]).toBe("/app/login?from=%2Fcompress"); + }); + + it("is unchanged at the origin root", async () => { + const handle = await loadAt("/", "/compress"); + await handle(unauthorized); + + expect(sessionStorage.getItem("stirling_post_login_path")).toBe( + "/compress", + ); + expect(hrefs[0]).toBe("/login?from=%2Fcompress"); + }); +}); diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index dd0c692d97..30272c381e 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -12,7 +12,8 @@ import { clampText, extractAxiosErrorMessage, } from "@app/services/httpErrorUtils"; -import { withBasePath } from "@app/constants/app"; +import { stripBasePath, withBasePath } from "@app/constants/app"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; // Module-scoped state to reduce global variable usage const recentSpecialByEndpoint: Record = {}; @@ -21,26 +22,9 @@ const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate // Mirrors the key in proprietary/auth/springAuthClient.ts; AuthCallback consumes it. const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path"; -function isSafePostLoginPath(path: string): boolean { - if ( - !path.startsWith("/") || - path.startsWith("//") || - path.startsWith("/\\") - ) { - return false; - } - const lowered = path.toLowerCase(); - return ( - !lowered.startsWith("/login") && - !lowered.startsWith("/auth/") && - !lowered.startsWith("/oauth2") && - !lowered.startsWith("/saml2") - ); -} - function stashPostLoginRedirect(path: string): void { try { - if (typeof window === "undefined" || !isSafePostLoginPath(path)) return; + if (typeof window === "undefined" || !isSafePostLoginRedirect(path)) return; window.sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, path); } catch { // sessionStorage unavailable (private mode) — fail open @@ -128,7 +112,11 @@ export async function handleHttpError(error: unknown): Promise { console.debug("[httpErrorHandler] 401 detected, redirecting to login"); // Spring 302-strips the ?from= query from /login, so stash the return // path in sessionStorage (AuthCallback reads it after SSO round-trip). - const currentLocation = window.location.pathname + window.location.search; + // Router-relative, not browser-relative: every consumer replays this + // through navigate(), which re-applies the basename. Keeping the base + // path here yields /app/app/ on a subpath deploy. + const currentLocation = + stripBasePath(window.location.pathname) + window.location.search; stashPostLoginRedirect(currentLocation); let hadStoredJwt = false; try { diff --git a/frontend/editor/src/core/services/postLoginRedirect.test.ts b/frontend/editor/src/core/services/postLoginRedirect.test.ts new file mode 100644 index 0000000000..9f1687f392 --- /dev/null +++ b/frontend/editor/src/core/services/postLoginRedirect.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; + +// Core default. Rejects off-origin forms and the auth routes every build has +// (/login, /auth/…); Spring SSO routes are the proprietary override's concern. +describe("isSafePostLoginRedirect (core base)", () => { + it("accepts same-origin router paths", () => { + expect(isSafePostLoginRedirect("/editor")).toBe(true); + expect(isSafePostLoginRedirect("/compress")).toBe(true); + expect(isSafePostLoginRedirect("/editor?foo=bar")).toBe(true); + expect(isSafePostLoginRedirect("/oauth/consent?x=1")).toBe(true); + expect(isSafePostLoginRedirect("/")).toBe(true); + }); + + it("rejects empty and non-string values", () => { + expect(isSafePostLoginRedirect(null)).toBe(false); + expect(isSafePostLoginRedirect(undefined)).toBe(false); + expect(isSafePostLoginRedirect("")).toBe(false); + expect(isSafePostLoginRedirect(42 as unknown)).toBe(false); + }); + + it("rejects off-origin and protocol-relative forms", () => { + expect(isSafePostLoginRedirect("//evil.example.com")).toBe(false); + expect(isSafePostLoginRedirect("/\\evil.example.com")).toBe(false); + expect(isSafePostLoginRedirect("https://evil.example.com")).toBe(false); + expect(isSafePostLoginRedirect("editor")).toBe(false); + }); + + it("rejects the universal auth routes so returning back can never loop", () => { + expect(isSafePostLoginRedirect("/login")).toBe(false); + expect(isSafePostLoginRedirect("/login?next=%2Feditor")).toBe(false); + expect(isSafePostLoginRedirect("/auth/callback")).toBe(false); + }); + + it("leaves the Spring SSO routes to the proprietary override", () => { + expect(isSafePostLoginRedirect("/oauth2/authorize")).toBe(true); + expect(isSafePostLoginRedirect("/saml2/login")).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/services/postLoginRedirect.ts b/frontend/editor/src/core/services/postLoginRedirect.ts new file mode 100644 index 0000000000..d9ffe82940 --- /dev/null +++ b/frontend/editor/src/core/services/postLoginRedirect.ts @@ -0,0 +1,15 @@ +/** + * Is `path` safe to send a user back to after they log in? + */ +export function isSafePostLoginRedirect(path: unknown): path is string { + if (typeof path !== "string" || path.length === 0) return false; + if ( + !path.startsWith("/") || + path.startsWith("//") || + path.startsWith("/\\") + ) { + return false; + } + const lowered = path.toLowerCase(); + return !lowered.startsWith("/login") && !lowered.startsWith("/auth/"); +} diff --git a/frontend/editor/src/core/services/workbenchSession.test.ts b/frontend/editor/src/core/services/workbenchSession.test.ts index d9feec8d1c..0258b83623 100644 --- a/frontend/editor/src/core/services/workbenchSession.test.ts +++ b/frontend/editor/src/core/services/workbenchSession.test.ts @@ -55,10 +55,14 @@ describe("workbench session record", () => { }); describe("editor return path", () => { - it("is consumed by the first take", () => { - saveEditorReturnPath("/compress?x=1"); + it("captures the live address bar and is consumed by the first take", () => { + // The editor writes its tool route via raw history.pushState, so the save + // must read window.location, not a lagging router location. + window.history.pushState({}, "", "/compress?x=1"); + saveEditorReturnPath(); expect(takeEditorReturnPath()).toBe("/compress?x=1"); expect(takeEditorReturnPath()).toBeNull(); + window.history.pushState({}, "", "/"); }); }); diff --git a/frontend/editor/src/core/services/workbenchSession.ts b/frontend/editor/src/core/services/workbenchSession.ts index 67f6d1abfc..2f23db8655 100644 --- a/frontend/editor/src/core/services/workbenchSession.ts +++ b/frontend/editor/src/core/services/workbenchSession.ts @@ -2,6 +2,7 @@ // does not cost the user their workbench. sessionStorage on purpose: per-tab, tabs never clobber. import type { StirlingFileStub } from "@app/types/fileContext"; +import { stripBasePath } from "@app/constants/app"; const SESSION_KEY = "stirling.workbench.session"; /** Bumped when the record's shape or meaning changes, so an old one is discarded rather than @@ -153,8 +154,10 @@ export function isSeedableView( return view !== undefined && SEEDABLE_VIEWS.includes(view); } -export function saveEditorReturnPath(path: string): void { +export function saveEditorReturnPath(): void { try { + const path = + stripBasePath(window.location.pathname) + window.location.search; sessionStorage.setItem(RETURN_PATH_KEY, path); } catch { // Best-effort: the switch back just lands on the editor root. diff --git a/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts b/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts index b9583e3dee..6d887ce8a9 100644 --- a/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts +++ b/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts @@ -16,6 +16,7 @@ import { AxiosError, type AxiosRequestConfig } from "axios"; import { getSpringAuthConfig } from "@app/auth/config"; import { type OAuthProvider } from "@app/auth/spring/oauthTypes"; import { resetOAuthState } from "@app/auth/spring/oauthStorage"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; import type { AuthUser as User, AuthSession as Session, @@ -100,23 +101,10 @@ function persistRedirectPath(path: string): void { } } -// Same-origin relative path, not pointing at auth plumbing. Rejects protocol-relative -// URLs to guard against open-redirect abuse if the stored value is tampered with. -export function isSafePostLoginRedirect(path: unknown): path is string { - if (typeof path !== "string" || path.length === 0) return false; - if (!path.startsWith("/") || path.startsWith("//")) return false; - if (path.startsWith("/\\")) return false; - const lowered = path.toLowerCase(); - if ( - lowered.startsWith("/login") || - lowered.startsWith("/auth/") || - lowered.startsWith("/oauth2") || - lowered.startsWith("/saml2") - ) { - return false; - } - return true; -} +// The safe-return-path rule lives in the shared @app/services/postLoginRedirect +// extension point (proprietary override adds the Spring SSO routes). Re-exported +// here so existing importers via @app/auth keep resolving it. +export { isSafePostLoginRedirect }; export function setPostLoginRedirectPath( path: string | null | undefined, diff --git a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx index 385f3f4e35..07670bfff5 100644 --- a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx @@ -16,6 +16,7 @@ import type { import { getSupabaseClient } from "@app/auth/supabase/supabaseClient"; import { AuthContext } from "@app/auth/context"; import { isAdminRole } from "@app/auth/roles"; +import { getApiBaseUrl } from "@app/services/apiClientConfig"; import { defaultTranslate, type AuthContextValue, @@ -154,14 +155,23 @@ export function SupabaseAuthProvider({ return; } let cancelled = false; + // Same API base the rest of the app uses: SaaS serves the frontend and the + // API from different hosts, so a root-relative path never reaches /me. + const apiBase = (getApiBaseUrl() || "").replace(/\/+$/, ""); + const meUrl = `${apiBase}/api/v1/auth/me`; const loadAccess = () => { - void fetch("/api/v1/auth/me", { + void fetch(meUrl, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json", }, }) - .then((res) => (res.ok ? res.json() : null)) + .then((res) => { + // Must throw, not resolve null: swallowing a non-ok leaves + // portalAccess undefined and hangs the portal gate on a spinner. + if (!res.ok) throw new Error(`auth/me responded ${res.status}`); + return res.json(); + }) .then( ( data: { diff --git a/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx b/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx new file mode 100644 index 0000000000..3b3d9b37c8 --- /dev/null +++ b/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { useContext } from "react"; + +const h = vi.hoisted(() => ({ apiBase: "/" })); + +vi.mock("@app/services/apiClientConfig", () => ({ + getApiBaseUrl: () => h.apiBase, +})); + +const sbSession = { + access_token: "supabase-token", + user: { + id: "u1", + email: "user@example.com", + is_anonymous: false, + app_metadata: {}, + user_metadata: {}, + }, +}; + +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => ({ + auth: { + getSession: () => Promise.resolve({ data: { session: sbSession } }), + onAuthStateChange: () => ({ + data: { subscription: { unsubscribe: () => {} } }, + }), + refreshSession: () => Promise.resolve({ data: {}, error: null }), + signOut: () => Promise.resolve({ error: null }), + }, + }), +})); + +import { SupabaseAuthProvider } from "@app/auth/supabase/UseSession"; +import { AuthContext } from "@app/auth/context"; + +function Probe() { + const v = useContext(AuthContext); + return ( + <> + {String(v?.portalAccess)} + {/* Raw, un-defaulted value: this is what SaasPortalGate reads to decide + "access not known yet" vs "denied". undefined = spinner forever. */} + {String(v?.user?.portalAccess)} + + ); +} + +const mount = () => + render( + + + , + ); + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("supabase provider portalAccess lookup", () => { + // SaaS serves the frontend and the API from different hosts; a root-relative + // path silently missed /me, so a granted non-admin was denied the Processor. + it("calls /me on the configured API base, not the page origin", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ user: { portalAccess: true } }), + }); + mount(); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/auth/me", + expect.anything(), + ), + ); + }); + + it("keeps a same-origin base as a single leading slash", async () => { + h.apiBase = "/"; + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ user: { portalAccess: true } }), + }); + mount(); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/auth/me", + expect.anything(), + ), + ); + }); + + it("grants access when /me says so", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ user: { portalAccess: true } }), + }); + const { getByTestId } = mount(); + await waitFor(() => expect(getByTestId("access").textContent).toBe("true")); + }); + + // A non-ok used to resolve to null and return early, leaving the raw + // portalAccess undefined forever - SaasPortalGate reads that as "still + // loading" and hangs on a spinner instead of falling back. + it("resolves the raw portalAccess when /me returns non-ok", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + }); + const { getByTestId } = mount(); + await waitFor(() => expect(getByTestId("raw").textContent).toBe("false")); + }); + + it("leaves the raw portalAccess defined when the request rejects", async () => { + h.apiBase = "https://api.example.com"; + fetchMock.mockRejectedValue(new Error("network down")); + const { getByTestId } = mount(); + await waitFor(() => expect(getByTestId("raw").textContent).toBe("false")); + }); +}); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx index c2a81c53d9..4acdc7a17a 100644 --- a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx @@ -7,9 +7,10 @@ const mocks = vi.hoisted(() => ({ portalAccess: true, })); +// The hook reads window.location for the return path (not useLocation), because +// the editor's raw history.pushState leaves react-router's location stale. vi.mock("react-router-dom", () => ({ useNavigate: () => mocks.navigate, - useLocation: () => ({ pathname: "/compress", search: "?mode=fast" }), })); vi.mock("@app/auth/context", () => ({ useAuth: () => ({ portalAccess: mocks.portalAccess }), @@ -27,6 +28,7 @@ beforeEach(() => { sessionStorage.clear(); vi.clearAllMocks(); mocks.portalAccess = true; + window.history.pushState({}, "", "/"); }); describe("useOtherAppSwitch", () => { @@ -45,6 +47,7 @@ describe("useOtherAppSwitch", () => { }); it("records where to return to, then navigates to the processor", () => { + window.history.pushState({}, "", "/compress?mode=fast"); const { result } = renderHook(() => useOtherAppSwitch()); result.current?.onOpen(); mocks.requestNavigation.mock.calls[0][0](); diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts index dff36a06f4..69b55aff35 100644 --- a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts +++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts @@ -1,4 +1,4 @@ -import { useLocation, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useAuth } from "@app/auth/context"; import { useNavigationActions } from "@app/contexts/NavigationContext"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; @@ -12,7 +12,6 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote export function useOtherAppSwitch(): NavFooterAppLink | null { const { portalAccess } = useAuth(); const navigate = useNavigate(); - const location = useLocation(); const { actions } = useNavigationActions(); if (!portalAccess) return null; return { @@ -20,7 +19,7 @@ export function useOtherAppSwitch(): NavFooterAppLink | null { onOpen: () => // Through the guard, so unsaved edits get the same warning as any other navigation. actions.requestNavigation(() => { - saveEditorReturnPath(location.pathname + location.search); + saveEditorReturnPath(); navigate(PORTAL_BASENAME); }), }; diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts index 3f50e1bb4c..459f527080 100644 --- a/frontend/editor/src/proprietary/services/apiClientSetup.ts +++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts @@ -1,5 +1,5 @@ import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; -import { withBasePath } from "@app/constants/app"; +import { stripBasePath, withBasePath } from "@app/constants/app"; import { getBrowserId } from "@app/utils/browserIdentifier"; import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; @@ -94,8 +94,10 @@ async function refreshAuthToken(client: AxiosInstance): Promise { // Redirect to login const loginPath = withBasePath("/login"); if (window.location.pathname !== loginPath) { + // Router-relative: Login replays this through navigate(), which applies + // the basename itself. See the same note in httpErrorHandler. setPostLoginRedirectPath( - window.location.pathname + window.location.search, + stripBasePath(window.location.pathname) + window.location.search, ); console.log("[API Client] Redirecting to login page..."); window.location.href = loginPath; diff --git a/frontend/editor/src/proprietary/services/postLoginRedirect.test.ts b/frontend/editor/src/proprietary/services/postLoginRedirect.test.ts new file mode 100644 index 0000000000..6f2451f336 --- /dev/null +++ b/frontend/editor/src/proprietary/services/postLoginRedirect.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; + +// Proprietary override: the core base plus the Spring SSO routes (/oauth2, /saml2). +describe("isSafePostLoginRedirect (proprietary override)", () => { + it("still accepts ordinary router paths", () => { + expect(isSafePostLoginRedirect("/editor")).toBe(true); + expect(isSafePostLoginRedirect("/share/abc123?x=1")).toBe(true); + expect(isSafePostLoginRedirect("/")).toBe(true); + }); + + it("inherits the base rejections", () => { + expect(isSafePostLoginRedirect("")).toBe(false); + expect(isSafePostLoginRedirect(null)).toBe(false); + expect(isSafePostLoginRedirect("//evil.example")).toBe(false); + expect(isSafePostLoginRedirect("/\\evil")).toBe(false); + expect(isSafePostLoginRedirect("/login")).toBe(false); + expect(isSafePostLoginRedirect("/auth/callback")).toBe(false); + }); + + it("also rejects the Spring SSO routes", () => { + expect(isSafePostLoginRedirect("/oauth2/authorization/google")).toBe(false); + expect(isSafePostLoginRedirect("/saml2/authenticate/x")).toBe(false); + }); +}); diff --git a/frontend/editor/src/proprietary/services/postLoginRedirect.ts b/frontend/editor/src/proprietary/services/postLoginRedirect.ts new file mode 100644 index 0000000000..5cd6546fa7 --- /dev/null +++ b/frontend/editor/src/proprietary/services/postLoginRedirect.ts @@ -0,0 +1,7 @@ +import { isSafePostLoginRedirect as isSafeBaseRedirect } from "@core/services/postLoginRedirect"; + +export function isSafePostLoginRedirect(path: unknown): path is string { + if (!isSafeBaseRedirect(path)) return false; + const lowered = path.toLowerCase(); + return !lowered.startsWith("/oauth2") && !lowered.startsWith("/saml2"); +} diff --git a/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts b/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts index 67d0aa7d49..e97d4d0ed3 100644 --- a/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts +++ b/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts @@ -1,4 +1,4 @@ -import { useLocation, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { usePortalAccess } from "@app/hooks/usePortalAccess"; import { useNavigationActions } from "@app/contexts/NavigationContext"; import { PORTAL_BASENAME } from "@app/routes/portalBasename"; @@ -13,7 +13,6 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote export function useOtherAppSwitch(): NavFooterAppLink | null { const portalAccess = usePortalAccess(); const navigate = useNavigate(); - const location = useLocation(); const { actions } = useNavigationActions(); if (!portalAccess) return null; return { @@ -21,7 +20,7 @@ export function useOtherAppSwitch(): NavFooterAppLink | null { onOpen: () => // Through the guard, so unsaved edits get the same warning as any other navigation. actions.requestNavigation(() => { - saveEditorReturnPath(location.pathname + location.search); + saveEditorReturnPath(); navigate(PORTAL_BASENAME); }), }; diff --git a/frontend/editor/src/saas/routes/AuthCallback.tsx b/frontend/editor/src/saas/routes/AuthCallback.tsx index b69619b85d..3fe081ed82 100644 --- a/frontend/editor/src/saas/routes/AuthCallback.tsx +++ b/frontend/editor/src/saas/routes/AuthCallback.tsx @@ -5,6 +5,7 @@ import { supabase } from "@app/auth/supabase"; import { Button } from "@app/ui/Button"; import { withBasePath } from "@app/constants/app"; import { readPendingConnect } from "@app/routes/pendingConnect"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; import { AuthShell } from "@app/auth/ui/AuthShell"; import ErrorMessage from "@app/auth/ui/ErrorMessage"; import { Spinner } from "@app/ui/Spinner"; @@ -136,18 +137,16 @@ export default function AuthCallback() { // else on the editor. // Explicit `next` first, so a sign-in started for another reason is not // hijacked by a remembered connect request. - const explicitNext = url.searchParams.get("next"); + const explicitNext = + url.searchParams.get("next") ?? url.searchParams.get("from"); const pendingConnect = readPendingConnect(); - const destination = - explicitNext && - explicitNext.startsWith("/") && - !explicitNext.startsWith("//") - ? explicitNext - : pendingConnect - ? `/link?request=${encodeURIComponent(pendingConnect)}` - : next.startsWith("/") && !next.startsWith("//") - ? next - : await resolveLandingPath(); + const destination = isSafePostLoginRedirect(explicitNext) + ? explicitNext + : pendingConnect + ? `/link?request=${encodeURIComponent(pendingConnect)}` + : isSafePostLoginRedirect(next) + ? next + : await resolveLandingPath(); console.log("[Auth Callback Debug] Redirecting to:", destination); setTimeout(() => navigate(destination, { replace: true }), 1500); diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index 418616104e..ba251561fd 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -14,6 +14,7 @@ import { getBaseUrl, withBasePath, } from "@app/constants/app"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; import LinkRoundedIcon from "@mui/icons-material/LinkRounded"; // Import login components @@ -48,14 +49,14 @@ export default function Login() { } }, []); - // Same-origin relative path to return to after login (e.g. the OAuth - // consent page). Same sanitization rules as AuthCallback's `next`. + // Same-origin router path to return to after login (e.g. the OAuth consent + // page, or the editor a 401 bounced the user off). `?next=` is what this app + // writes; `?from=` is what the shared core 401 handler writes. const nextPath = useMemo(() => { try { - const next = new URL(window.location.href).searchParams.get("next"); - return next && next.startsWith("/") && !next.startsWith("//") - ? next - : null; + const params = new URL(window.location.href).searchParams; + const candidate = params.get("next") ?? params.get("from"); + return isSafePostLoginRedirect(candidate) ? candidate : null; } catch (_) { return null; } diff --git a/frontend/editor/src/saas/services/apiClient.test.ts b/frontend/editor/src/saas/services/apiClient.test.ts index 45646bf8d8..af4a1d90f4 100644 --- a/frontend/editor/src/saas/services/apiClient.test.ts +++ b/frontend/editor/src/saas/services/apiClient.test.ts @@ -219,10 +219,11 @@ describe("apiClient", () => { // Import apiClient after mocking const { default: apiClient } = await import("@app/services/apiClient"); - // Mock window.location for redirect test + // On /editor when the session dies: the return path must ride along so the + // login screen sends the user back here, not to the role-based landing. Object.defineProperty(window, "location", { writable: true, - value: { href: "" }, + value: { href: "", pathname: "/editor", search: "" }, }); const mockAdapter = vi.fn((config) => { @@ -245,8 +246,50 @@ describe("apiClient", () => { } catch (_) { // Verify refresh was attempted expect(supabase.auth.refreshSession).toHaveBeenCalled(); - // Verify redirect to login - expect(window.location.href).toBe("/login"); + // Verify redirect to login carries the return path + expect(window.location.href).toBe("/login?next=%2Feditor"); + } + }); + + it("does not redirect (or loop) when already on the login page", async () => { + expectConsole.error(/\[API Client\] Token refresh failed/); + const oldSession = { access_token: "old", user: { id: "user-123" } }; + vi.mocked(supabase.auth.getSession).mockResolvedValue({ + data: { session: oldSession as unknown as Session }, + error: null, + }); + vi.mocked(supabase.auth.refreshSession).mockResolvedValue({ + data: { user: null, session: null }, + error: { + name: "AuthError", + message: "Refresh failed", + status: 400, + code: "auth_error", + } as unknown as AuthError, + }); + + const { default: apiClient } = await import("@app/services/apiClient"); + + Object.defineProperty(window, "location", { + writable: true, + value: { href: "", pathname: "/login", search: "?next=%2Feditor" }, + }); + + apiClient.defaults.adapter = vi.fn((config) => + Promise.reject( + Object.assign(new Error("Unauthorized"), { + response: { status: 401, data: { error: "Unauthorized" } }, + config, + }), + ), + ); + + try { + await apiClient.get("/api/v1/test"); + expect(true).toBe(false); + } catch (_) { + // Left untouched: no second redirect off the login page. + expect(window.location.href).toBe(""); } }); }); diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index 64cd05fd01..73ebe97e47 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -5,8 +5,9 @@ import { classifyPaygError, handlePaygError, } from "@app/services/paygErrorInterceptor"; -import { withBasePath } from "@app/constants/app"; +import { stripBasePath, withBasePath } from "@app/constants/app"; import { getBrowserId } from "@app/utils/browserIdentifier"; +import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect"; // Helper: decode base64url JWT payload safely function decodeJwtPayload(token: string): Record | null { @@ -113,6 +114,21 @@ function refreshSessionOnce(): ReturnType { return inFlightRefresh; } +// Hard-redirect to /login, carrying where the user was so the login screen can +// return them there instead of falling through to the role-based landing (which +// sends processor users to the processor - the "refresh /editor bounces me to +// the processor" bug). Router-relative, matching what Login reads via `?next=`. +function redirectToLogin(): void { + const loginPath = withBasePath("/login"); + // Already on the login page: another redirect would just loop. + if (window.location.pathname === loginPath) return; + const returnPath = + stripBasePath(window.location.pathname) + window.location.search; + window.location.href = isSafePostLoginRedirect(returnPath) + ? `${loginPath}?next=${encodeURIComponent(returnPath)}` + : loginPath; +} + // Response interceptor for handling token refresh apiClient.interceptors.response.use( (response) => response, @@ -173,7 +189,7 @@ apiClient.interceptors.response.use( // The session genuinely can't be recovered. Send protected requests // to login; public ones just fail quietly (no redirect). if (!isPublicEndpoint) { - window.location.href = withBasePath("/login"); + redirectToLogin(); } return Promise.reject(error); @@ -194,10 +210,7 @@ apiClient.interceptors.response.use( console.debug( "[API Client] No session to refresh, 401 on protected endpoint", ); - const loginPath = withBasePath("/login"); - if (window.location.pathname !== loginPath) { - window.location.href = loginPath; - } + redirectToLogin(); return Promise.reject(error); } } catch (refreshError) { From 658aa54c20446f9f1188f59cf35fb82fa86bc81e Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 28 Aug 2026 10:35:41 +0000 Subject: [PATCH 19/57] Update tool models to fix main (#7725) # Description of Changes When #6697 merged, the CI didn't run for some reason so it was never caught that the tool models were out of date. This PR updates them to the correct state. --- engine/src/stirling/models/tool_models.py | 3 +++ frontend/editor/src/core/types/toolApiTypes.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py index 6650942b6f..dff977a7f7 100644 --- a/engine/src/stirling/models/tool_models.py +++ b/engine/src/stirling/models/tool_models.py @@ -725,6 +725,9 @@ class OcrPdfParams(ApiModel): ) ocr_type: OcrType = Field(..., description="Specify the OCR type, e.g., 'skip-text', 'force-ocr', or 'Normal'") remove_images_after: bool | None = Field(None, description="Remove images from the output PDF if set to true") + rotate_pages: bool | None = Field( + None, description="Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true" + ) sidecar: bool | None = Field(None, description="Include OCR text in a sidecar text file if set to true") diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index 55938d231e..d980029624 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -1022,6 +1022,10 @@ export interface ProcessPdfWithOcrRequest { * Remove images from the output PDF if set to true */ removeImagesAfter?: boolean; + /** + * Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true + */ + rotatePages?: boolean; /** * Include OCR text in a sidecar text file if set to true */ From 4ab2505a6c8fe2ed469576b6d4e93d7565b2567a Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:56:50 +0000 Subject: [PATCH 20/57] Comment-quality standard, and the gate that enforces it (#7663) ## The problem AI PRs write comments that restate the line below them, mark sections with box drawing, and narrate the diff. Nothing in the repo said not to, and nothing checked. `AGENTS.md` had one line about comments and it was buried in the Python section. Banners and `Step N:` narration have zero occurrences in the 15 months before Aug 2025, so this is new. ## The fix A written standard, plus a linter that enforces the mechanical part of it on added lines only. - [devGuide/CODE_COMMENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/devGuide/CODE_COMMENTS.md) holds the reasoning and worked examples; a section in [AGENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/AGENTS.md) holds the operative rules, kept short so they stay in an agent's context. The two are split by kind rather than duplicated, because the same prose in two places drifts. - Rules in [comment-rules.mjs](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs), shared by both engines. - Two engines. `.ts` / `.tsx` / `.mjs` go to an [oxlint JS plugin](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-oxlint-plugin.mjs) so comments come from the parser rather than a line scan; `.java` / `.py` go to a [line scanner](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint.mjs). Neither reads the other's files, so they cannot disagree about one file. - Between them they read every comment form the repo writes: `//` and `/* */`, Javadoc and JSDoc, JSX comments, `#`, and Python docstrings. - Runs in `task pre-commit`, so the git hook and the `pre_commit.yml` CI job both get it, and as a Claude Code [`Stop` hook](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-hook.mjs) so an agent fixes the comment inside the turn that wrote it. ## The rules The part worth arguing about. **Every rule blocks.** A rule that only warns is a rule nobody acts on, so a finding you believe is wrong is a bug in the rule: narrow it, or mark the line and say why. | | Fires on | | --- | --- | | [CMT001](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L71) | Every word in the comment already appears in the code below it. Max 6 words, skipped for prose punctuation and for a bare Arrange/Act/Assert marker | | [CMT002](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L92) | 4+ rule or box-drawing characters, or a bare section label from [a fixed list](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L84) (`Types`, `Helpers`, `State`, `Handlers`, ...) | | [CMT003](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L110) | `Step N:` with a separator, or `Then,` / `Next,` / `Finally,`. Suppressed in test files | | [CMT004](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L129) | A comment about the code's own past: `this used to`, `renamed from`, `was previously called`. Suppressed in test files | | [CMT005](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L154) | 3+ consecutive comment lines where 2/3 [parse as code](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L143) | | [CMT006](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L31) | A run of implementation comment over 12 lines, outside the first 5 lines of a file. Doc blocks are exempt, because the standard asks for thorough contracts | | [CMT007](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L180) | A parameter or return description that adds no word its name lacks. Reads Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google `name: description` | | [CMT008](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L239) | An allow directive naming a rule that does not exist, or one that silenced nothing | | [CMT009](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L219) | A `TODO` / `FIXME` / `HACK` naming no issue or link. An owner is not accepted: a username goes stale, an issue outlives it | Each rule carries the readings it deliberately excludes, next to the rule. Those exclusions came from running the rules over this repo, not from taste: `CMT004` does not match a bare "no longer needed" because that is as often about runtime lifecycle as about history, and `CMT003` needs a separator after the number so a wrapped line beginning "step 2 unmounts + remounts the panel" reads as the prose it is. A comment sharing a line with code is judged by the rules that do not depend on the code below it, so a trailing `// TODO fix this` or `/* this used to run before the flush */` still reports, while `50L * 1024 * 1024 // 50 MB` does not. `CMT001` would have been wrong about six in seven trailing comments here, so it stays out of them. If a finding is wrong, `// comment-lint-allow: CMT002` on the line above. Rule-specific, [no blanket disable](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L229). A directive naming a rule that does not exist, or silencing nothing, is itself a `CMT008` failure, so a typo cannot quietly disable a rule and a stale one gets deleted rather than accumulating. No native linter covers `CMT007`. `eslint-plugin-jsdoc`'s `require-param-description`, Checkstyle's `NonEmptyAtclauseDescription` and ruff's D-rules all check that a description exists, not whether it says anything. ## Scoping Added comment **text** only, not lines git calls new. Reindenting a file or moving a block makes git mark untouched comments as added; findings are matched against the comment text at the base, so only genuinely new content reports. The whole file is read and every comment in it evaluated. Only the *reporting* is filtered, so a rule still sees the code a comment introduces, the full run it belongs to, and the base version of the file. Existing tree is untouched. `task pre-commit:comment-lint:all` reports it and always exits 0: | | java | ts/js | py | | --- | --- | --- | --- | | findings | 1,218 | 741 | 204 | 2,163 across 542 files, mostly `CMT002` banners (1,482) and `CMT001` restatements (456). Clearing it is separate work, by directory. Not in this PR: an advisory LLM review layer for the things no pattern can judge. ## Verification Run against [#7494](https://github.com/Stirling-Tools/Stirling-PDF/pull/7494) as CI would, in a throwaway worktree: **two findings on a 78 file, +4,512 line change, both genuine banners, in 952ms**. A whole-file scan of those same files gives 11; the other 9 were withheld because that PR's author did not write them, and they are the `@param teamId the team ID` shape this standard exists to stop. Both scanners blank string and character literals before looking for comment markers, because a partial lex desynchronises everything after it: one apostrophe in a Java comment, or one Python template whose closing quotes start a line, is enough to read dozens of lines of code as a single comment. Two fixtures carry canaries that stop being reported if either engine ever desynchronises again. The [fixture corpus](https://github.com/Stirling-Tools/Stirling-PDF/tree/claude/ai-pr-comment-quality-dd970e/scripts/lint/fixtures) pins all 9 rules against both engines, and `--selftest` fails if the two disagree about the same file. ## Two things reviewers should know **The oxlint JS plugin API is alpha.** oxlint itself is stable and already this repo's frontend linter; the plugin API is the new dependency. Its documented failure mode ([oxc#25203](https://github.com/oxc-project/oxc/issues/25203)) is being skipped silently while oxlint still reports success. That affects the standalone release binary rather than the npm package this invokes, but the class of failure reads exactly like clean code, so the run asserts `number_of_rules >= 1` from oxlint's own report and a broken engine exits 2 rather than passing. If the API ever breaks, the fallback is folding these rules into the line scanner, which already implements all nine for Java and Python. **`.claude/settings.json` is now committed**, carrying the hook and nothing else: 19 lines, no `permissions`, nothing machine-specific. That partly reverts `c35546a212` ("Ignore claude dir"), which existed because this file had twice been committed by accident with a personal `permissions` allowlist, once with absolute machine paths. Personal config still belongs in `.claude/settings.local.json`, which the new pattern keeps ignored, and hook entries merge across the two so nobody's own hooks are lost. If you already hand-wrote a `.claude/settings.json`, copy it somewhere first: that path used to be git-ignored, and git overwrites an ignored file without warning when a commit starts tracking it. Across 19 local checkouts here, 13 have `settings.local.json` and none has a hand-written `settings.json`. To turn the hook off, `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in local settings. Claude Code can only disable all hooks at once, hence the switch. The commit-time gate still applies. ## How to test ```bash task pre-commit:comment-lint:ci ``` The fixture corpus, then the diff. The corpus checks the rules themselves rather than the code under review, so it runs on CI and before a rule change, not on every local commit. ```bash task comment-lint:branch ``` `clean (34 files in scope)`. `task comment-lint` is the same thing scoped to uncommitted work, which is what the git hook and CI run. To watch it bite, add `// Is banner` above `export function isBanner` in `scripts/lint/comment-rules.mjs` and run `task comment-lint`: one `CMT001`, exit 1. The gate covers its own source, which is why these scripts have no section dividers. ```bash task pre-commit:comment-lint:all ``` The standing backlog, report-only. Verified on the pinned oxlint 1.77.0, not only the 1.79 the plugin was prototyped against. --- .claude/settings.json | 19 + .github/pull_request_template.md | 1 + .github/workflows/pre_commit.yml | 5 + .gitignore | 9 +- .taskfiles/pre-commit.yml | 82 +++ AGENTS.md | 39 +- CONTRIBUTING.md | 1 + Taskfile.yml | 14 + devGuide/CODE_COMMENTS.md | 232 ++++++ devGuide/README.md | 1 + frontend/oxlint.comments.config.ts | 31 + scripts/lint/comment-lint-hook.mjs | 119 ++++ scripts/lint/comment-lint-oxlint-plugin.mjs | 126 ++++ scripts/lint/comment-lint.mjs | 743 ++++++++++++++++++++ scripts/lint/comment-rules.mjs | 500 +++++++++++++ scripts/lint/fixtures/AaaTest.java | 19 + scripts/lint/fixtures/README.md | 17 + scripts/lint/fixtures/allow.java | 32 + scripts/lint/fixtures/apostrophes.java | 23 + scripts/lint/fixtures/clean.java | 26 + scripts/lint/fixtures/clean.ts | 17 + scripts/lint/fixtures/deadcode.java | 14 + scripts/lint/fixtures/docs.java | 12 + scripts/lint/fixtures/docstrings.py | 36 + scripts/lint/fixtures/expected.json | 63 ++ scripts/lint/fixtures/narration.java | 22 + scripts/lint/fixtures/narration.tsx | 17 + scripts/lint/fixtures/restates.java | 24 + scripts/lint/fixtures/restates.py | 9 + scripts/lint/fixtures/restates.ts | 20 + scripts/lint/fixtures/strings.java | 11 + scripts/lint/fixtures/templates.py | 24 + scripts/lint/fixtures/todos.java | 25 + scripts/lint/fixtures/trailing.java | 22 + 34 files changed, 2352 insertions(+), 3 deletions(-) create mode 100644 .claude/settings.json create mode 100644 devGuide/CODE_COMMENTS.md create mode 100644 frontend/oxlint.comments.config.ts create mode 100644 scripts/lint/comment-lint-hook.mjs create mode 100644 scripts/lint/comment-lint-oxlint-plugin.mjs create mode 100644 scripts/lint/comment-lint.mjs create mode 100644 scripts/lint/comment-rules.mjs create mode 100644 scripts/lint/fixtures/AaaTest.java create mode 100644 scripts/lint/fixtures/README.md create mode 100644 scripts/lint/fixtures/allow.java create mode 100644 scripts/lint/fixtures/apostrophes.java create mode 100644 scripts/lint/fixtures/clean.java create mode 100644 scripts/lint/fixtures/clean.ts create mode 100644 scripts/lint/fixtures/deadcode.java create mode 100644 scripts/lint/fixtures/docs.java create mode 100644 scripts/lint/fixtures/docstrings.py create mode 100644 scripts/lint/fixtures/expected.json create mode 100644 scripts/lint/fixtures/narration.java create mode 100644 scripts/lint/fixtures/narration.tsx create mode 100644 scripts/lint/fixtures/restates.java create mode 100644 scripts/lint/fixtures/restates.py create mode 100644 scripts/lint/fixtures/restates.ts create mode 100644 scripts/lint/fixtures/strings.java create mode 100644 scripts/lint/fixtures/templates.py create mode 100644 scripts/lint/fixtures/todos.java create mode 100644 scripts/lint/fixtures/trailing.java diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..40d0b73379 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/scripts/lint/comment-lint-hook.mjs" + ], + "timeout": 60 + } + ] + } + ] + } +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d9eb6dbe10..41666ff608 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,6 +20,7 @@ Closes #(issue_number) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code +- [ ] Every comment I added says something the code does not ([guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/CODE_COMMENTS.md)) - [ ] My changes generate no new warnings ### Documentation diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 674822363b..3566f5864f 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -38,3 +38,8 @@ jobs: - name: Run pre-commit checks run: task pre-commit + + # The fixture corpus checks the comment rules themselves, so it runs here + # rather than on every local commit. + - name: Check the comment-lint fixture corpus + run: task pre-commit:comment-lint:selftest diff --git a/.gitignore b/.gitignore index 1290056e05..ca683dc5d4 100644 --- a/.gitignore +++ b/.gitignore @@ -298,8 +298,13 @@ docs/type3/signatures/ **/application-dev-local.properties -# Claude -.claude/ +# Claude. Contents are ignored so personal config stays local, with the two +# shared pieces re-included: settings.json (the comment-lint hook) and skills/. +# The directory itself cannot be ignored or git will not look inside it. +.claude/* +!.claude/settings.json +!.claude/skills/ +.claude/settings.local.json # Playwright MCP screenshots / traces .playwright-mcp/ diff --git a/.taskfiles/pre-commit.yml b/.taskfiles/pre-commit.yml index 444115010f..f65a296160 100644 --- a/.taskfiles/pre-commit.yml +++ b/.taskfiles/pre-commit.yml @@ -11,6 +11,7 @@ vars: '.github/scripts/*.py' 'app/core/src/main/resources/static/python/*.py' ':(exclude)*split_photos.py' + ':(exclude)scripts/lint/fixtures/*' SPELL_FILES: >- '*.html' '*.css' @@ -59,6 +60,7 @@ tasks: - task: gitleaks - task: whitespace - task: toml-sort + - task: comment-lint fix: desc: "Auto-fix formatting, spelling, and secrets issues across the repo" @@ -75,6 +77,7 @@ tasks: vars: { FIX: '1' } - task: codespell - task: gitleaks + - task: comment-lint install: desc: "Install the pinned pre-commit Python tools" @@ -130,6 +133,85 @@ tasks: cmds: - "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose" + comment-lint: + desc: "Check comment quality on the lines this branch adds" + summary: | + Blocks a comment that restates the code below it, a section banner, or a + block of commented-out code. Everything else it reports is advisory. + + Scoped to added lines, so touching an old file never surfaces the standing + backlog. The standard is devGuide/CODE_COMMENTS.md. + + With no arguments it diffs the working tree against HEAD, which is what a + pre-commit run wants: the lines you are about to commit. On a CI pull request + it diffs against the target branch instead, via GITHUB_BASE_REF. + + To ask what a whole branch adds instead, use the branch variant, which + needs no argument passing: + task comment-lint:branch + + Full tree (report only): task pre-commit:comment-lint:all + Fixture corpus: task pre-commit:comment-lint:selftest + # Depends on the frontend install because the .ts/.tsx half of the rule set + # runs as an oxlint plugin. Without it the TS engine warns and skips, which + # would leave the frontend silently unchecked on CI. + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs {{.CLI_ARGS}} + + comment-lint:branch: + desc: "Check comment quality on everything this branch adds over its base" + summary: | + Like `task comment-lint`, but scoped to the whole branch rather than to + uncommitted work, so it still reports after you commit. + + Exists as its own task because passing `-- --since origin/main` through Task + is not portable: with the npm build of Task the launcher is a PowerShell + script, and PowerShell strips the `--` before Task sees it, leaving Task to + print its own usage. + + Override the base with BASE=. + vars: + BASE: '{{.BASE | default "origin/main"}}' + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs --since {{.BASE}} + + comment-lint:ci: + desc: "Comment gate as CI runs it: fixture corpus, then the diff" + summary: | + The corpus checks the rules themselves rather than the code under review, so + it belongs on CI and not on every local commit. Run this before changing a + rule, and let CI run it on every pull request. + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs --selftest + - node scripts/lint/comment-lint.mjs {{.CLI_ARGS}} + + comment-lint:hook: + desc: "Comment gate for the editor hook: everything this turn changed" + summary: | + Same scope as `task comment-lint`, kept as its own name so the hook has a + stable entry point and the taskfile shows every way the linter is invoked. + + Not in the frontend-install dependency chain on purpose: this runs at the end + of every turn, so it stays as short as it can be. If oxlint is missing the TS + half warns and skips. + cmds: + - node scripts/lint/comment-lint.mjs + + comment-lint:all: + desc: "Report every comment finding in the tree (never fails)" + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs --all + + comment-lint:selftest: + desc: "Check both comment-lint engines against the fixture corpus" + deps: [":frontend:install"] + cmds: + - node scripts/lint/comment-lint.mjs --selftest + gitleaks-bin: internal: true desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin" diff --git a/AGENTS.md b/AGENTS.md index 7383ab3c3d..88543ca3c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,43 @@ Task `desc:` fields should describe **what** the task does, not **how** it does - `task docker:build` — build standard Docker image - `task docker:up` — start Docker compose stack +## Comments + +A comment must carry information the code cannot. If a reader could derive it from the code in front of them, delete it. + +Comment the current state. Not what the code used to do, not what changed, not why it changed: git holds that. Where history explains the shape, state the reason instead, so "this used to reimplement the modal internals" becomes "thin wrapper over the shared Modal: duplicating its portal and focus trap is how dialogs drift apart". Future state goes in a TODO with an issue. + +Write a comment when it does one of these four jobs: + +- **Contract.** What a caller must know that the signature cannot say: preconditions, invariants, units, ownership and lifetime, thread-safety, error semantics, side effects. Document the contract of everything a caller outside the file can reach, and nothing else. Goes on the type/method/module as Javadoc, JSDoc, or a docstring. +- **Why.** The constraint the code satisfies, the bug it avoids, the alternative rejected and the reason. +- **Hazard.** "Must stay in sync with X", "order matters because Y", "do not remove, it prevents Z". +- **Map.** A short orientation at the top of a genuinely complex file: what it owns, and what it deliberately does not. + +Never write: + +- A comment that restates the next line. `// Handle drag start` above `handleDragStart` is noise. +- Section banners or position markers: `// --- Types ---`, `// Helpers`, `// =====`. +- Step narration in a function body (`// Step 1:`, `// Then we`). If the steps need labels they need names: extract functions. Numbering a genuinely numbered thing, like a wizard step, is fine. +- Commented-out code. Delete it. +- Doc tags that restate the signature. `@param blob - The blob` says nothing; omit the tag rather than pad it. +- Docs on self-explanatory members with no constraint to state. + +Two tests before keeping a comment: + +- **Delete it.** Is any information lost? If not, it stays deleted. +- **Could a name carry it instead?** A better identifier, an extracted function, or a named constant beats a comment. Prefer the code change. + +A comment at the end of a line usually decodes that line, and that is worth keeping: `{0x25, 0x50} // "%PDF"`, `50L * 1024 * 1024 // 50 MB`. The rules that compare a comment against the code below it do not apply there, but a trailing TODO or a trailing bit of history is judged like any other. + +A reference is supplementary, never load-bearing: the comment must survive deleting it. `// See #1234` is a dead end; `// saving first loses every annotation (#6865)` is not. Prefer a spec (`RFC 3161`) or CVE where one applies. + +A TODO needs an issue, not an owner: `// TODO(#1234): re-enable the gate once account syncing lands`. If it is not worth an issue, it is not worth a TODO. A question is not a TODO. + +A comment block over ~12 lines outside a file or type header usually means the code needs restructuring, or that the prose is product documentation and belongs in the docs repo. + +`task comment-lint` checks the mechanical part of this on the lines you add, and runs inside `task pre-commit`. Reasoning, worked examples and the linter's own rules: @devGuide/CODE_COMMENTS.md + ## Common Development Commands ### Build and Test @@ -70,7 +107,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie - Avoid nested functions and nested classes unless the language construct requires them. - Prefer composition to inheritance when combining concepts. - Avoid speculative abstractions. Add a layer only when it removes real duplication or clarifies lifecycle. -- Add comments sparingly and only when they explain non-obvious intent. +- Comments follow the repo-wide rules in the "Comments" section above. #### Python Typing and Models - Deserialize into Pydantic models as early as possible. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65fc4bc262..371630193b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,7 @@ Please make sure your Pull Request adheres to the following guidelines: - Keep commits atomic. One commit should contain one change. If you want to make multiple changes, submit multiple Pull Requests. - Commits should be clear, concise, and easy to understand. - References to the Issue number in the Pull Request and/or Commit message. +- Every comment in the diff should say something the code does not. See [Code comments](devGuide/CODE_COMMENTS.md); `task comment-lint` checks the mechanical part. ## Translations diff --git a/Taskfile.yml b/Taskfile.yml index e364242f1f..eaec7dec92 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -266,6 +266,20 @@ tasks: cmds: - task: frontend:lint - task: engine:lint + - task: comment-lint + + comment-lint: + desc: "Check comment quality on the lines this branch adds" + aliases: [comments] + cmds: + - task: pre-commit:comment-lint + vars: { CLI_ARGS: '{{.CLI_ARGS}}' } + + comment-lint:branch: + desc: "Check comment quality on everything this branch adds over its base" + cmds: + - task: pre-commit:comment-lint:branch + vars: { BASE: '{{.BASE}}' } fix: desc: "Auto-fix all components" diff --git a/devGuide/CODE_COMMENTS.md b/devGuide/CODE_COMMENTS.md new file mode 100644 index 0000000000..739744142f --- /dev/null +++ b/devGuide/CODE_COMMENTS.md @@ -0,0 +1,232 @@ +# Code comments + +A comment must carry information the code cannot. If a reader could derive it from +the code in front of them, delete it: a redundant comment still has to be +maintained, will eventually contradict the code, and dilutes the comments that +matter. + +The operative rules are in `AGENTS.md`, kept short so they stay in an agent's +context. This document is the reasoning and the worked examples behind them, plus +how to run the linter. + +## Comment the current state + +Describe the code as it is. Not what it used to be, not what changed, not why it +changed. A comment that narrates history is stale the moment the next change +lands, and git already holds that record. + +When you know the history and it explains the shape of the code, the useful half is +the reason, not the sequence. State the reason: + +```java +// Don't: +// This used to reimplement the modal internals, which is how the procurement +// dialogs drifted from the billing ones. + +// Do: +// Thin wrapper over the shared Modal: duplicating its portal and focus trap is +// how dialogs drift apart. +``` + +Future state is the exception, and it belongs in a TODO with an issue. + +## The four jobs + +**Contract.** What a caller must know that the signature cannot say: +preconditions, invariants, units, ownership and lifetime, thread-safety, error +semantics, side effects. + +The bound is the surface, not the volume: document the contract of everything a +caller outside the file can reach, and nothing else. Inside that surface say +whatever a caller needs; outside it a comment earns its place on the same terms as +any other. + +```java +/** + * Authority on which filesystem locations a policy may read or write. Fail-closed + * in order: denied entirely under the saas profile; Stirling's own config dir is + * always rejected; the path must resolve within policies.allowedFolderRoots. + * + *

Compared after normalisation so {@code ..} cannot escape a root. Symlink + * escape is not defended: an operator who roots an allowlist on a symlink to a + * sensitive location is trusted. + */ +``` + +**Why.** The constraint the code satisfies, the bug it avoids, the alternative +rejected and the reason. + +```java +// whenComplete runs on the worker thread after the run finishes, so the +// terminal event never races the step events. +handle.completion() +``` + +A reference is supplementary, never load-bearing: the comment must survive +deleting it. `// See #1234` is a dead end. + +```java +// flatten() reads the annotation list that save() clears, so saving first loses +// every annotation (#6865). +document.flatten(annotations); +``` + +Prefer a spec (`RFC 3161`, `ISO 4217`) or a CVE where one applies. Both are +immutable; a ticket can be closed, moved or made private. + +**Hazard.** "Must stay in sync with X." "Order matters because Y." "Do not remove, +it prevents Z." + +**Map.** A short orientation at the head of a genuinely complex file: what it owns, +and what it deliberately does not. + +## The test that decides it + +A comment earns its place when it sits at a different level of detail than the line +below it: lower, stating a precise fact the code implies but does not say, or +higher, giving intent a reader would otherwise assemble from ten lines. +Same-altitude is the definition of redundant. + +- **Delete it.** Is any information lost? If not, it stays deleted. +- **Could a name carry it instead?** A better identifier, an extracted function or + a named constant beats a comment. Prefer the code change. + +## What not to write + +| Don't | Instead | +| --- | --- | +| `// Handle drag start` above `handleDragStart` | Nothing. The name already says it. | +| `// ─── Types ───`, `// Helpers`, `// ====` | If a file needs internal signposting, split the file. | +| `// Step 1:` narrating a function body | Extract functions. If the steps need labels they need names. | +| `// No longer needed`, `// Previously this used X` | State why the code is as it is now, or nothing. | +| Commented-out code | Delete it. Git remembers. | +| `@param blob - The blob to download` | Omit the tag rather than pad it. | +| Docs on a self-explanatory member | Nothing, unless there is a real constraint to state. | + +Step numbering is fine where it labels a genuinely numbered thing, such as a wizard +step or a step in a written test procedure. It is narration when it numbers the +lines of one function. + +## Comments at the end of a line + +A trailing comment usually does a different job from one above the code: it decodes +the line it sits on. Those are worth keeping, and the linter leaves them alone. + +```java +byte[] pdfBytes = {0x25, 0x50, 0x44, 0x46}; // "%PDF" +long maxAttachmentSize = 50L * 1024 * 1024; // 50 MB +double buffer = 0.10; // 10% headroom +default -> toBytes(value, 2); // MB +``` + +Each overlaps in words with the code and each adds the interpretation the code +leaves implicit, which is the lower-altitude case the test above asks for. So +`CMT001` does not judge trailing comments; on this codebase it would have been +wrong about roughly six in seven of them. + +What still applies is anything that does not depend on the code below: a trailing +`// TODO fix this` is as unowned as one on its own line, and a trailing +`// this used to run before the flush` narrates history wherever it sits. + +A comment block over about 12 lines, outside a file or type header, is usually a +sign the code needs restructuring. If it is genuinely product documentation, it +belongs in the docs repo. + +## TODOs + +A TODO needs an issue, because an issue is the only part that will close it: + +```java +// TODO(#1234): re-enable the checkout gate once account syncing lands +``` + +An owner is not a substitute: a username goes stale when someone changes team and +means nothing to an outside contributor. If the work is not worth an issue it is +not worth a TODO, and the options are to do it now or leave the code alone. A +question is not a TODO. + +## Per language + +**Java.** Google Java Style, which this repo already formats to. Its §7.3.1 +exception applies: omit Javadoc on a self-explanatory member where there is +genuinely nothing to add, but do not cite it to skip something a reader needs. +Summary fragments are noun or verb phrases, not sentences starting "This method +returns". + +**TypeScript.** JSDoc on the `@app/*` seams, exported hooks, and anything crossing +a layer boundary. No `@param`/`@returns` that restates a typed signature. JSX +comments follow the same rules as any other. + +**Python.** Docstrings on modules, public functions and Pydantic models where the +contract is not obvious from the type. + +## The linter + +```bash +task comment-lint # what the working tree adds over HEAD +task comment-lint:branch # what the branch adds over origin/main (BASE= to change) +task pre-commit:comment-lint:ci # the fixture corpus, then the diff +``` + +`comment-lint` is the pre-commit question, so it reports nothing once you have +committed; on a CI pull request it compares against the target branch via +`GITHUB_BASE_REF`. `comment-lint:branch` is the review question. The corpus checks +the rules themselves rather than the code under review, so it runs on CI and before +a rule change, not on every local commit. + +`task comment-lint` also runs inside `task pre-commit`, and as a Claude Code `Stop` +hook, so an agent is told before it finishes a turn and fixes the comment inside +that turn. Stop rather than per file write: a run costs the same for one file as for +twenty-five, and half of all writes in a turn go to a file already written in it. + +Findings are scoped to comment text that is new, not to lines git calls new, so +reindenting or moving code does not resurface comments you did not write. + +The rules are the `RULES` object in +[`scripts/lint/comment-rules.mjs`](../scripts/lint/comment-rules.mjs); the exact +condition for each is the predicate of the same name in that file, with the +readings it deliberately excludes beside it. + +**Every rule blocks.** A rule that only warns is a rule nobody acts on. So a +finding you believe is wrong is a bug in the rule, not something to live with: +narrow the rule, or mark the line and say why. + +Every comment form the repo writes is covered: `//` and `/* */`, Javadoc and JSDoc, +JSX comments, `#`, and Python docstrings. `CMT007` reads all three parameter +conventions in use here, Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google +`name: description` under `Args:`. + +Two engines, one rule set. `.ts`/`.tsx`/`.mjs` go to an oxlint JS plugin, so +comments come from the parser: a `//` inside a string is not a comment, and JSX +`{/* … */}` is. `.java`/`.py` go to a line scanner. Neither reads the other's +files, so they cannot disagree about one file. `scripts/lint/fixtures/` is the +corpus that keeps them meaning the same thing. + +### When a finding is wrong + +Name the rule on the line above: + +```ts +// comment-lint-allow: CMT002 +// ─── kept deliberately, because ─── +``` + +There is no form that disables every rule, and the directive has to earn its +place. `CMT008` reports one that names something which is not a rule, and one that +silences nothing, so a typo does not read as a suppression and a stale +suppression does not sit there blinding the line. The whole comment must be the +directive; prose that mentions the syntax is just prose. + +If you reach for this more than occasionally the rule is wrong: fix it in +`comment-rules.mjs` and update the fixture corpus in the same commit, so the diff +shows what moved. + +### The existing backlog + +`task pre-commit:comment-lint:all` reports the whole tree and never fails. There is +a standing backlog being cleared by directory; diff scoping is what keeps it off +whoever touches a file first. + +To turn the editor hook off, put `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in +`.claude/settings.local.json`. The commit-time gate still applies, so you lose the +early warning rather than the check. diff --git a/devGuide/README.md b/devGuide/README.md index 5e8486f013..2ddaff63c3 100644 --- a/devGuide/README.md +++ b/devGuide/README.md @@ -8,6 +8,7 @@ This directory contains all development-related documentation for Stirling PDF. - **[DeveloperGuide.md](../DeveloperGuide.md)** - Main developer setup and architecture guide (in repo root) - **[Taskfile.yml](../Taskfile.yml)** - Unified task runner for all build/dev/test/lint commands - **[EXCEPTION_HANDLING_GUIDE.md](./EXCEPTION_HANDLING_GUIDE.md)** - Exception handling patterns and i18n best practices +- **[CODE_COMMENTS.md](./CODE_COMMENTS.md)** - What a comment is for, what not to write, and the `task comment-lint` rules - **[HowToAddNewLanguage.md](./HowToAddNewLanguage.md)** - Internationalization and translation guide - **[STORAGE_ENCRYPTION_AT_REST.md](./STORAGE_ENCRYPTION_AT_REST.md)** - Encryption at rest for stored files: key setup, migration, revocation, rotation diff --git a/frontend/oxlint.comments.config.ts b/frontend/oxlint.comments.config.ts new file mode 100644 index 0000000000..a3a238893c --- /dev/null +++ b/frontend/oxlint.comments.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from "oxlint"; + +// Comment-quality rules only, kept out of oxlint.config.ts on purpose. +// +// The main config is run with --max-warnings=0 over the whole frontend, and the +// existing tree still has several hundred findings from these rules. Enabling +// them there would fail every build until the cleanup lands. So they live here +// and are driven by `task comment-lint`, which scopes findings to the lines a +// branch actually added. +// +// Fold this into oxlint.config.ts once the tree is clean; that is the step that +// also buys IDE squiggles, and the point at which this file goes away. + +export default defineConfig({ + jsPlugins: ["../scripts/lint/comment-lint-oxlint-plugin.mjs"], + categories: { correctness: "off" }, + ignorePatterns: [ + "dist", + "dist-portal", + "node_modules", + "playwright-report", + "storybook-static", + "test-results", + "editor/dist", + "editor/public", + "editor/src-tauri", + ], + rules: { + "comments/quality": "error", + }, +}); diff --git a/scripts/lint/comment-lint-hook.mjs b/scripts/lint/comment-lint-hook.mjs new file mode 100644 index 0000000000..af573c6a91 --- /dev/null +++ b/scripts/lint/comment-lint-hook.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// Claude Code Stop hook: check the comments this turn wrote, before it ends. +// +// Wired up by .claude/settings.json. Exit 2 stops Claude from finishing and shows +// stderr to it, so the comment is fixed inside the same turn and never reaches a +// diff, a CI run, or a reviewer. +// +// Stop rather than PostToolUse, measured over 605 real turns: a run costs the same +// whether it looks at one file or twenty-five, because node startup and one git +// diff dominate and the TS engine is spawned once for the batch. Per write it was +// 40 minutes of hook latency across those turns, and up to 35 seconds inside a +// single heavy one; per turn it is under a second, flat. Half of all writes were +// to a file already written that turn, so most of that work was repeated. +// +// To turn it off, set COMMENT_LINT_HOOK=0. Claude Code has no way to disable one +// hook (only disableAllHooks, which turns off everyone's), so the opt-out lives +// here instead. Per developer, in .claude/settings.local.json: +// +// { "env": { "COMMENT_LINT_HOOK": "0" } } +// +// The commit-time gate still applies either way, so opting out costs you the +// early warning, not the check. + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "..", ".."); + +// Invoked through Task so the taskfile stays the one place that defines how the +// linter is called. +const TASK_NAME = "pre-commit:comment-lint:hook"; +const OFF = new Set(["0", "off", "false", "no"]); + +// The linter's own exit codes: 1 when it found something, 2 when its engine could +// not run. The hook's codes mean different things, so they are mapped explicitly. +const FOUND = 1; +const ENGINE_BROKEN = 2; + +if (OFF.has((process.env.COMMENT_LINT_HOOK ?? "").toLowerCase())) process.exit(0); + +const payload = readStdin(); + +// Blocking the stop puts Claude back to work, which ends in another stop and +// another chance to block. Claude Code sets this flag once a Stop hook has +// already blocked in this turn, so a rule the agent cannot satisfy costs one +// extra attempt rather than looping. The commit gate still catches whatever +// survives. +if (payload?.stop_hook_active) process.exit(0); + +const result = run(); +if (result.status === 0) process.exit(0); + +if (result.status === ENGINE_BROKEN) { + process.stderr.write("comment-lint could not run, so comments in this turn were not checked.\n"); + process.exit(1); +} + +// Anything else is Task itself failing, which means the check did not happen. +if (result.status !== FOUND) { + process.stderr.write(`comment-lint did not run (task exit ${result.status}), so comments in this turn were not checked.\n`); + process.exit(1); +} + +// The linter's own report already names the file, line, rule and the standard, so +// it is passed through rather than rewritten. +process.stderr.write(`${result.output.trim()}\n\nFix these before finishing.\n`); +process.exit(2); + +function readStdin() { + try { + return JSON.parse(readFileSync(0, "utf8")); + } catch { + return null; + } +} + +// `task` on PATH is a shell wrapper that boots Node to launch the Go binary the +// npm package already ships, which costs about 400ms. Prefer the binary; fall +// back to the wrapper when the layout is not one of the ones probed, or when Task +// came from somewhere else entirely. +function taskCommand() { + const nodeDir = dirname(process.execPath); + const exe = process.platform === "win32" ? "task.exe" : "task"; + const candidates = [ + resolve(nodeDir, "node_modules/@go-task/cli/bin", exe), + resolve(nodeDir, "../lib/node_modules/@go-task/cli/bin", exe), + resolve(REPO, "node_modules/@go-task/cli/bin", exe), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return { command: candidate, shell: false }; + } + return { command: process.platform === "win32" ? "task.cmd" : "task", shell: process.platform === "win32" }; +} + +function run() { + const { command, shell } = taskCommand(); + try { + // --output=interleaved because the root Taskfile sets `output: prefixed`, + // which would put the task name in front of every reported finding. + // --exit-code because Task otherwise reports its own 201 for any failed task, + // which hides whether the linter found something or could not run. + // Task's stderr is captured rather than inherited: it announces its own + // "Failed to run task" for any non-zero command, which would reach Claude + // alongside the findings and read as a tooling error. + const output = execFileSync(command, [TASK_NAME, "--silent", "--output=interleaved", "--exit-code"], { + cwd: REPO, + encoding: "utf8", + maxBuffer: 1 << 26, + shell, + stdio: ["ignore", "pipe", "pipe"], + }); + return { status: 0, output }; + } catch (error) { + return { status: error.status ?? -1, output: error.stdout ?? "" }; + } +} diff --git a/scripts/lint/comment-lint-oxlint-plugin.mjs b/scripts/lint/comment-lint-oxlint-plugin.mjs new file mode 100644 index 0000000000..045b23518b --- /dev/null +++ b/scripts/lint/comment-lint-oxlint-plugin.mjs @@ -0,0 +1,126 @@ +// oxlint JS plugin: the comment-quality rules for .ts and .tsx. +// +// This engine owns the frontend outright; comment-lint.mjs never scans TS. The +// reason to run oxlint here rather than scan lines is that comment tokens come +// from the parser, so a `//` inside a string or regex is not a comment, JSX +// `{/* … */}` is, and positions are exact. Rule decisions themselves live in +// comment-rules.mjs, shared with the Java/Python engine. +// +// Reported as one rule with the CMT id in the message, because oxlint config +// severity is per rule name and every finding here shares one on/off switch. +// +// Enabled by frontend/oxlint.comments.config.ts. `context.report` needs +// `node.range`; passing start/end throws. + +import { analyse, isTestPath, isGenerated, isExcludedPath, ruleLabel } from "./comment-rules.mjs"; + +const comments = { + create(context) { + return { + "Program:exit"() { + const sourceCode = context.sourceCode; + const filename = context.filename ?? context.getFilename?.() ?? ""; + if (isExcludedPath(filename)) return; + + const text = sourceCode.text; + if (isGenerated(text)) return; + + const lines = sourceCode.getLines(); + const tokens = sourceCode.getAllComments(); + if (tokens.length === 0) return; + + const runs = groupIntoRuns(tokens, lines); + const findings = analyse({ lines, runs, isTestFile: isTestPath(filename) }); + + // Ranges come from the run entries rather than the enclosing token, so a + // finding on the eighth line of a doc block points at that line instead + // of at the opening `/**`. + const ranges = new Map(); + for (const run of runs) { + for (const entry of run.lines) ranges.set(entry.line, entry.range); + } + + for (const finding of findings) { + context.report({ + message: `${ruleLabel(finding.rule)}: ${finding.detail}`, + node: { type: "Line", range: ranges.get(finding.line) ?? [0, 1] }, + }); + } + }, + }; + }, +}; + +// Adjacent comment lines with no code between them form one run, which is the +// unit the block-length and dead-code rules judge. A comment sharing its line +// with code is a trailing note, not part of any run. +function groupIntoRuns(tokens, lines) { + const runs = []; + let current = null; + + for (const token of tokens) { + const entries = expand(token, lines); + if (entries.length === 0) continue; + + const kind = token.type === "Line" ? "line" : token.value.startsWith("*") ? "doc" : "block"; + const startLine = entries[0].line; + const trailing = entries[0].trailing === true; + const contiguous = !trailing && current && startLine === current.endLine + 1 && current.kind === kind && !current.trailing; + + if (contiguous) { + current.lines.push(...entries); + current.endLine = entries[entries.length - 1].line; + continue; + } + current = { startLine, endLine: entries[entries.length - 1].line, kind, trailing, lines: entries }; + runs.push(current); + + // Code sits in front of a trailing comment, so nothing can continue it. + if (trailing) current = null; + } + + return runs; +} + +// One entry per physical line, with the leading `*` of a doc block stripped so +// the rules see the prose rather than the box drawing around it. Each entry +// carries its own source range so findings can be reported where they are. +function expand(token, lines) { + const start = token.loc.start.line; + const column = token.loc.start.column + 1; + const before = (lines[start - 1] ?? "").slice(0, token.loc.start.column).trim(); + + // Code in front of the comment makes it a trailing note. Marked rather than + // dropped, so CMT004 and CMT009 still see it: a TODO is a TODO wherever it + // sits. The rules that compare a comment against the code below it stay out, + // because a trailing comment usually decodes the line it sits on. + // + // A block comment counts as trailing only when it also closes on that line. + // One that runs on has its bulk on lines of its own, so it is judged as the + // block it is. + const sameLine = token.loc.start.line === token.loc.end.line; + const trailing = before.length > 0 && !before.startsWith("{") && (token.type === "Line" || sameLine); + + if (token.type === "Line") { + return [{ line: start, column, body: token.value, range: token.range, trailing }]; + } + + // token.value is the text between the delimiters, so it begins two chars in. + let offset = token.range[0] + 2; + return token.value.split("\n").map((raw, index) => { + const range = [offset, offset + Math.max(raw.length, 1)]; + offset += raw.length + 1; + return { + line: start + index, + column: index === 0 ? column : 1, + body: raw.replace(/^\s*\*+/, "").trim(), + range, + trailing, + }; + }); +} + +export default { + meta: { name: "comments" }, + rules: { quality: comments }, +}; diff --git a/scripts/lint/comment-lint.mjs b/scripts/lint/comment-lint.mjs new file mode 100644 index 0000000000..55b0cb4039 --- /dev/null +++ b/scripts/lint/comment-lint.mjs @@ -0,0 +1,743 @@ +#!/usr/bin/env node + +// comment-lint - the comment-quality gate. Standard: devGuide/CODE_COMMENTS.md +// +// Owns .java and engine .py directly, and delegates .ts/.tsx to oxlint (see +// comment-lint-oxlint-plugin.mjs) so the frontend is judged against real comment +// tokens rather than lines. Both paths share the rules in comment-rules.mjs, so +// a finding means the same thing whichever engine produced it. +// +// node scripts/lint/comment-lint.mjs default: everything this +// working tree adds over HEAD, +// or over the target branch on CI +// node scripts/lint/comment-lint.mjs --since main findings on lines this branch added +// node scripts/lint/comment-lint.mjs --all whole tree, report only, never fails +// node scripts/lint/comment-lint.mjs those files, every line +// node scripts/lint/comment-lint.mjs --selftest run the fixture corpus +// --quiet ...saying nothing unless it fails +// node scripts/lint/comment-lint.mjs --json machine-readable findings +// +// Exits non-zero for any finding on a line in scope: every rule blocks, because a +// warning is a finding nobody acts on. --all never fails, because the tree still +// has a backlog; it is the mode for working through it. + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + analyse, + commentBodiesOf, + normaliseComment, + isExcludedPath, + isGenerated, + isTestPath, + ruleLabel, + RULES, +} from "./comment-rules.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(HERE, "..", ".."); +const FRONTEND = join(REPO, "frontend"); + +// The Python this repo owns and formats: the engine service, plus the helper +// scripts that pre-commit already runs ruff over. Vendored and sample .py +// elsewhere in the tree is not ours to restyle. +const JAVA = /\.java$/; +const PYTHON = /^(engine|scripts|\.github\/scripts)\/.*\.py$/; + +// The oxlint engine parses plain JS as happily as TS, so the lint scripts and +// build tooling are held to the same rules as the app. +const TYPESCRIPT = /\.(tsx?|mts|cts|mjs|cjs|jsx?)$/; + +const FIXTURES_REL = "scripts/lint/fixtures/"; + +// oxlint rejects any path containing "..", so it is always run from the repo +// root and given repo-relative paths. Its config still lives under frontend/, +// which is what makes `oxlint` and the plugin resolvable from there. +const OXLINT_BIN = "frontend/node_modules/oxlint/bin/oxlint"; +const OXLINT_CONFIG = "frontend/oxlint.comments.config.ts"; + +// Windows caps a command line near 32k characters, which a whole-tree file list +// exceeds by a wide margin. Unbatched it dies with ENAMETOOLONG, and silently: +// oxlint exits non-zero normally, so the error reads as "no findings". +const ARGV_BUDGET = 24_000; + +// Every module constant lives in this block. The top-level run below starts +// before any function body is reached, so a `const` declared further down is +// still in its temporal dead zone when the first call touches it. +const baseComments = new Map(); + +// A Java char literal, which is the only thing a single quote can legitimately +// open: one character, or one escape. An apostrophe in prose never matches, so +// `/** The approver's team ... */` keeps its closing delimiter. Without this the +// apostrophe opened a literal that never closed, the `*/` was blanked away, and +// the scanner read the next 47 lines of code as one comment. +const CHAR_LITERAL = /^'(\\[btnfr'"\\0]|\\u[0-9a-fA-F]{4}|[^'\\])'/; + +// A docstring opens the line, optionally behind a string prefix. Anything with +// code in front of the quotes is a value, not documentation. +const DOCSTRING_OPEN = /^[rbuf]{0,2}("""|''')/; + +const argv = process.argv.slice(2); +const flags = new Set(argv.filter((a) => a.startsWith("--"))); +const positional = argv.filter((a) => !a.startsWith("--") && !isFlagValue(a)); + +if (flags.has("--selftest")) process.exit(runSelfTest()); +if (flags.has("--help")) { + process.stdout.write( + readFileSync(fileURLToPath(import.meta.url), "utf8") + .split("\n") + .slice(1, 22) + .join("\n") + .replace(/^\/\/ ?/gm, "") + "\n", + ); + process.exit(0); +} + +const scope = resolveScope(); +const findings = collect(scope); +process.exit(publish(findings, scope)); + +// A "scope" is the set of files to look at plus, when the run is diff-based, the +// set of lines that are new. Reporting a legacy finding in a file someone merely +// touched is how a gate like this gets switched off, so diff runs filter by line. + +function resolveScope() { + if (flags.has("--all")) return { mode: "all", files: trackedFiles(), added: null }; + + const paths = positional.map(toRepoPath); + + // Paths plus --since is how the editor hook asks about one file: lint it, but + // only the lines this session actually wrote. + if (paths.length > 0 && flags.has("--since")) { + const ref = mergeBase(flagValue("--since")); + return narrow(diffScope(["diff", "--unified=0", "--no-color", ref, "--", ...paths], ref, paths), paths); + } + if (paths.length > 0) return { mode: "paths", files: paths, added: null }; + + if (flags.has("--since")) { + const ref = mergeBase(flagValue("--since")); + return diffScope(["diff", "--unified=0", "--no-color", ref], ref); + } + + // Always a working-tree comparison, never `--cached`. Findings are read from + // the file on disk, so diffing the index instead would pair index line numbers + // with working-tree content and silently mismatch once the two differ. + // CI knows the target branch; a developer running this before a commit does not. + const base = process.env.GITHUB_BASE_REF; + const ref = mergeBase(base ? `origin/${base}` : "HEAD"); + return diffScope(["diff", "--unified=0", "--no-color", ref], ref); +} + +function mergeBase(ref) { + try { + return git(["merge-base", "HEAD", ref]).trim(); + } catch { + // A shallow clone or a missing remote ref: compare against the ref itself. + return ref; + } +} + +function diffScope(args, base, paths = null) { + let diff; + try { + diff = git(args); + } catch (error) { + // A shallow clone, a detached CI checkout, or a base branch that was never + // fetched. Degrading to report-only beats failing a build over plumbing. + warn(`could not resolve a diff (${firstLine(error.stderr ?? error.message)}), so nothing was checked.`); + return { mode: "all", files: [], added: null }; + } + + const added = new Map(); + let file = null; + for (const line of diff.split("\n")) { + if (line.startsWith("+++ ")) { + const path = line.slice(4).replace(/^b\//, "").trim(); + file = path === "/dev/null" ? null : path; + if (file) added.set(file, new Set()); + continue; + } + if (!file || !line.startsWith("@@")) continue; + const hunk = /\+(\d+)(?:,(\d+))?/.exec(line); + if (!hunk) continue; + const start = Number(hunk[1]); + const count = hunk[2] === undefined ? 1 : Number(hunk[2]); + for (let i = 0; i < count; i++) added.get(file).add(start + i); + } + + // git diff never mentions an untracked file, so a brand new one would be waved + // through entirely. Every line of one is new. Listing every untracked file in + // the repo costs about as much as the diff, so when the caller already named the + // paths, only those are asked about. + for (const file of untrackedFiles(paths)) { + if (added.has(file)) continue; + added.set(file, allLinesOf(file)); + } + + return { mode: "diff", files: [...added.keys()], added, base }; +} + +function untrackedFiles(paths = null) { + const args = ["ls-files", "--others", "--exclude-standard"]; + if (paths) args.push("--", ...paths); + return git(args).split("\n").filter(Boolean); +} + +function allLinesOf(file) { + const path = insideRepo(file); + if (!path || !existsSync(path)) return new Set(); + const total = readFileSync(path, "utf8").split(/\r?\n/).length; + return new Set(Array.from({ length: total }, (_, i) => i + 1)); +} + +function narrow(scope, paths) { + // A diff that could not be resolved already returned a degraded scope with no + // added map. Pass it straight through: an empty map here would read as a clean + // pass rather than as a run that checked nothing. + if (!scope.added) return scope; + + const wanted = new Set(paths); + const added = new Map([...scope.added].filter(([file]) => wanted.has(file))); + return { mode: "diff", files: [...added.keys()], added, base: scope.base }; +} + +function trackedFiles() { + return git(["ls-files"]).split("\n").filter(Boolean); +} + +function toRepoPath(path) { + return relative(REPO, resolve(process.cwd(), path)).replace(/\\/g, "/"); +} + +function collect(scope) { + const selected = scope.files.map((f) => f.replace(/\\/g, "/")).filter(isLintable); + + // A path named on the command line and then dropped has to be said out loud. + // Reporting "clean" for a file this never opened is the failure mode the rest + // of this script works to avoid. + if (scope.mode === "paths") { + for (const file of scope.files) { + if (!selected.includes(file.replace(/\\/g, "/"))) warn(`skipped ${file}: not a lintable file inside the repo.`); + } + } + const results = []; + + for (const file of selected.filter((f) => JAVA.test(f) || PYTHON.test(f))) { + results.push(...lintLineBased(file)); + } + + const ts = selected.filter((f) => TYPESCRIPT.test(f)); + if (ts.length > 0) results.push(...lintTypeScript(ts)); + + if (scope.added) { + const onAddedLine = results.filter((r) => scope.added.get(r.file)?.has(r.line)); + return onAddedLine.filter((r) => !existedAtBase(r, scope.base)); + } + return results; +} + +// git marks a reindented or moved line as added, so line membership alone reports +// comments nobody wrote. A finding only counts if its comment text is not already +// in the file at the base. +// +// Cost is one `git show` per file, memoised. It gets one case wrong: adding a +// further copy of an already-duplicated comment reads as pre-existing. That is the +// right way round for a blocking rule. + +function existedAtBase(finding, base) { + if (!base) return false; + // Findings from the oxlint plugin arrive without their comment text, because + // they cross a process boundary as a message string. Recover it from the file + // on disk at the reported line, which is the same text the rule judged. + const body = finding.body ?? currentLineBody(finding); + if (!body) return false; + const key = `${base}:${finding.file}`; + if (!baseComments.has(key)) { + let source = ""; + try { + source = git(["show", key]); + } catch { + // Not in the base at all, so the whole file is new. + } + baseComments.set(key, commentBodiesOf(source)); + } + return baseComments.get(key).has(body); +} + +function currentLineBody(finding) { + try { + const line = readFileSync(insideRepo(finding.file), "utf8").split(/\r?\n/)[finding.line - 1]; + return line === undefined ? "" : normaliseComment(line); + } catch { + return ""; + } +} + +// Everything this tool reads is named by git or by a developer on the command +// line, so a path outside the repo is a mistake rather than an attack. Resolving +// through here keeps the contract true: git show and git diff cannot answer for a +// path outside the work tree, so escaping it only produces confusing output. +function insideRepo(file) { + const target = resolve(REPO, file); + const rel = relative(REPO, target); + if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) return ""; + return target; +} + +function isLintable(file) { + if (!insideRepo(file)) return false; + if (isExcludedPath(file)) return false; + + // The corpus is deliberately full of findings. Only the selftest reads it, + // and it does so by path rather than through this filter. + if (file.startsWith(FIXTURES_REL)) return false; + if (!JAVA.test(file) && !PYTHON.test(file) && !TYPESCRIPT.test(file)) return false; + return existsSync(insideRepo(file)); +} + +function lintLineBased(file) { + const source = readFileSync(insideRepo(file), "utf8"); + if (isGenerated(source)) return []; + const lines = source.split(/\r?\n/); + const runs = readRuns(lines, PYTHON.test(file) ? "py" : "java"); + return analyse({ lines, runs, isTestFile: isTestPath(file) }).map((f) => ({ ...f, file })); +} + +// Groups comment lines into runs, the same shape the oxlint plugin builds from +// parser tokens. String literals are blanked first so a `//` inside one is not +// mistaken for a comment; without that, every URL in a string became a finding. +function readRuns(lines, language) { + const runs = []; + let current = null; + let inBlock = false; + let docstring = null; + + const push = (index, column, body, kind, trailing = false) => { + const line = index + 1; + if (!trailing && current && current.endLine === line - 1 && current.kind === kind && !current.trailing) { + current.lines.push({ line, column, body }); + current.endLine = line; + return; + } + current = { startLine: line, endLine: line, kind, trailing, lines: [{ line, column, body }] }; + runs.push(current); + if (trailing) current = null; + }; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const text = blankStrings(raw, language); + const trimmed = text.trim(); + const column = raw.length - raw.trimStart().length + 1; + + if (inBlock) { + push(i, column, stripDocPrefix(raw), "doc"); + if (trimmed.includes("*/")) inBlock = false; + continue; + } + if (trimmed.length === 0) { + current = null; + continue; + } + if (language === "py") { + // Every triple-quoted string is tracked, not just the documenting ones. + // A template assigned to a constant opens mid-line and so is not + // documentation, but its closing delimiter sits alone on a line and reads + // exactly like an opener. Ignoring those strings desynchronised the + // scanner for the rest of the file, and 35 lines of ordinary code were + // reported as commented-out. + if (docstring) { + if (docstring.isDoc) push(i, column, stripDocstringDelimiters(raw), "doc"); + else current = null; + if (raw.includes(docstring.delimiter)) docstring = null; + continue; + } + if (trimmed.startsWith("#")) { + push(i, column, raw.trim().replace(/^#+/, ""), "line"); + continue; + } + const hashAt = raw.indexOf("#"); + if (hashAt > 0 && !/["']/.test(raw.slice(0, hashAt))) { + const body = raw.slice(hashAt + 1).trim(); + if (body.length > 0) { + push(i, hashAt + 1, body, "line", true); + continue; + } + } + const quoted = tripleQuoted(trimmed); + if (quoted) { + if (quoted.isDoc) push(i, column, stripDocstringDelimiters(raw), "doc"); + else current = null; + if (!quoted.closes) docstring = quoted; + continue; + } + current = null; + continue; + } + if (trimmed.startsWith("/*")) { + push(i, column, stripDocPrefix(raw), trimmed.startsWith("/**") ? "doc" : "block"); + if (!trimmed.includes("*/")) inBlock = true; + continue; + } + if (trimmed.startsWith("//")) { + push(i, column, raw.trim().replace(/^\/\/+/, ""), "line"); + continue; + } + + // Code first, then a comment. blankStrings has already neutralised any `//` + // inside a string literal, so this index is a real comment marker. A block + // comment counts here only when it also closes on this line, matching the + // oxlint engine: one that runs on has its bulk on lines of its own. + const trailingLine = text.indexOf("//"); + const trailingBlock = text.indexOf("/*"); + const at = + trailingLine > 0 ? trailingLine : trailingBlock > 0 && text.includes("*/", trailingBlock) ? trailingBlock : -1; + if (at > 0) { + const body = raw + .slice(at + 2) + .replace(/\*\/.*$/, "") + .trim(); + if (body.length > 0) { + push(i, at + 1, body, "line", true); + continue; + } + } + current = null; + } + + return runs; +} + +// The prose inside a docstring line, with the triple quotes and any string +// prefix taken off so the rules see what a reader sees. +// Where a triple-quoted string starts on this line, and whether it counts as +// documentation. It documents when the quotes open the line, allowing a string +// prefix; a template assigned to a constant opens mid-line and is data, and +// reading JSON as prose would judge its keys as comments. An odd number of +// delimiters means the string continues onto the next line. +function tripleQuoted(trimmed) { + const found = /("""|''')/.exec(trimmed); + if (!found) return null; + const delimiter = found[1]; + const occurrences = trimmed.split(delimiter).length - 1; + return { + delimiter, + isDoc: DOCSTRING_OPEN.test(trimmed), + closes: occurrences % 2 === 0, + }; +} + +function stripDocstringDelimiters(raw) { + return raw + .trim() + .replace(/^[rbuf]{0,2}("""|''')/, "") + .replace(/("""|''')\s*$/, "") + .trim(); +} + +function stripDocPrefix(raw) { + return raw + .trim() + .replace(/^\/\*+/, "") + .replace(/\*+\/$/, "") + .replace(/^\*+/, "") + .trim(); +} + +// Replaces the contents of string and char literals with spaces, preserving +// length so columns stay correct. Escapes are honoured so "\"" does not end it. +function blankStrings(line, language) { + if (language === "py") return line; + let out = ""; + let quote = null; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (quote) { + if (ch === "\\") { + out += " "; + i++; + continue; + } + out += ch === quote ? ch : " "; + if (ch === quote) quote = null; + continue; + } + if (ch === '"') { + quote = ch; + out += ch; + continue; + } + if (ch === "'") { + const literal = CHAR_LITERAL.exec(line.slice(i)); + if (!literal) { + // Prose, not a literal. Leave it alone. + out += ch; + continue; + } + out += `'${" ".repeat(literal[0].length - 2)}'`; + i += literal[0].length - 1; + continue; + } + if (ch === "/" && line[i + 1] === "/") return out + line.slice(i); + out += ch; + } + return out; +} + +function lintTypeScript(files) { + if (!existsSync(join(REPO, OXLINT_BIN))) { + warn("frontend/node_modules/oxlint is missing, so TS/TSX was skipped. Run `task frontend:install`."); + return []; + } + return batch(files, ARGV_BUDGET).flatMap(runOxlint); +} + +function batch(files, budget) { + const batches = []; + let current = []; + let size = 0; + for (const file of files) { + if (current.length > 0 && size + file.length + 1 > budget) { + batches.push(current); + current = []; + size = 0; + } + current.push(file); + size += file.length + 1; + } + if (current.length > 0) batches.push(current); + return batches; +} + +function runOxlint(files) { + let stdout; + try { + // Invoked as `node ` rather than through npx: spawning a .cmd shim on + // Windows fails with EINVAL unless a shell is used, and a shell would mean + // quoting every path. It must also be the npm package rather than the + // standalone release binary, which accepts a jsPlugins config, skips loading + // it, and still reports success (oxc-project/oxc#25203). + stdout = execFileSync(process.execPath, [OXLINT_BIN, "--config", OXLINT_CONFIG, "--format=json", ...files], { + cwd: REPO, + encoding: "utf8", + maxBuffer: 1 << 28, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + // oxlint exits non-zero whenever it reports something, which is the normal case. + stdout = error.stdout ?? ""; + if (!stdout.trim()) { + die(`oxlint failed on ${files.length} file(s): ${firstLine(error.stderr ?? error.message)}`); + } + } + + return parseOxlint(stdout); +} + +function firstLine(value) { + return value.toString().trim().split(/\r?\n/)[0]; +} + +function parseOxlint(stdout) { + const start = stdout.indexOf("{"); + if (start < 0) die("oxlint produced no JSON report."); + let report; + try { + report = JSON.parse(stdout.slice(start)); + } catch { + die("could not parse oxlint JSON output."); + } + + // JS plugins are alpha, and their documented failure mode is being skipped + // silently while oxlint still reports success (oxc-project/oxc#25203). + // number_of_rules is the report saying whether the plugin's rule was actually + // registered. Without this check a dead plugin reads exactly like clean code. + if ((report.number_of_rules ?? 0) < 1) { + die("oxlint loaded no rules, so the comment plugin did not run. Refusing to report a pass."); + } + + return (report.diagnostics ?? []).flatMap((d) => { + const parsed = /^(CMT\d{3})\s+(\S+):\s*(.*)$/.exec(d.message); + if (!parsed) return []; + const [, rule, , detail] = parsed; + const span = d.labels?.[0]?.span; + return [ + { + file: d.filename.replace(/\\/g, "/"), + line: span?.line ?? 1, + column: span?.column ?? 1, + rule, + detail, + severity: RULES[rule].severity, + }, + ]; + }); +} + +function publish(findings, scope) { + if (flags.has("--json")) { + process.stdout.write(`${JSON.stringify({ mode: scope.mode, findings }, null, 2)}\n`); + return 0; + } + + if (findings.length === 0) { + process.stdout.write(`comment-lint: clean (${scope.files.length} file${scope.files.length === 1 ? "" : "s"} in scope)\n`); + return 0; + } + + const byFile = new Map(); + for (const f of findings) { + if (!byFile.has(f.file)) byFile.set(f.file, []); + byFile.get(f.file).push(f); + } + + for (const [file, group] of [...byFile.entries()].sort()) { + process.stdout.write(`\n${file}\n`); + for (const f of group.sort((a, b) => a.line - b.line)) { + process.stdout.write(` ${String(f.line).padStart(5)} ${ruleLabel(f.rule)} ${f.detail}\n`); + } + } + + process.stdout.write( + `\ncomment-lint: ${findings.length} finding${findings.length === 1 ? "" : "s"} across ${byFile.size} file${byFile.size === 1 ? "" : "s"}\n`, + ); + + if (scope.mode === "all") { + process.stdout.write("Report-only mode: --all never fails, so the standing backlog can be worked through in chunks.\n"); + return 0; + } + + process.stdout.write( + "\nThe standard is devGuide/CODE_COMMENTS.md. A comment must carry information the\n" + + "code cannot; if a reader could derive it from the code in front of them, delete it.\n" + + "If a finding is genuinely wrong, put `comment-lint-allow: CMT00X` on the line above.\n", + ); + return 1; +} + +function warn(message) { + process.stderr.write(`comment-lint: ${message}\n`); +} + +// A gate that cannot run must not report a pass. Reserved for the engine being +// broken, as opposed to absent: a missing oxlint install is handled by skipping +// with a warning, so the hook stays usable before `task frontend:install`. +function die(message) { + process.stderr.write(`comment-lint: ${message}\n`); + process.exit(2); +} + +// The fixture corpus is the contract between the two engines: the same rule set +// applied to .java/.py by the line scanner and to .ts/.tsx by oxlint, with +// fixtures/expected.json asserting what each file should produce. +// +// Expectations live outside the fixtures on purpose. An in-file marker would sit +// inside the very comment under test, changing its word count and its run +// length, so the fixture would stop being an example of the real thing. +// +// --selftest compare against expected.json +// --selftest --update rewrite expected.json from current behaviour +// +// A rule change is meant to show up as a reviewable diff in expected.json. + +function runSelfTest() { + const dir = join(HERE, "fixtures"); + const expectedPath = join(dir, "expected.json"); + const files = readdirSync(dir) + .filter((f) => /\.(java|py|ts|tsx)$/.test(f)) + .sort(); + if (files.length === 0) { + warn("no fixtures found"); + return 1; + } + + const asRepoPath = (name) => relative(REPO, join(dir, name)).replace(/\\/g, "/"); + const tsFixtures = files.filter((f) => TYPESCRIPT.test(f)); + const tsFindings = tsFixtures.length > 0 ? lintTypeScript(tsFixtures.map(asRepoPath)) : []; + + // A skipped engine looks exactly like a clean engine in the snapshot, so + // refuse to record or compare rather than baking in a false pass. + if (tsFixtures.length > 0 && tsFindings.length === 0) { + warn("the TS engine produced nothing, so it did not run. Install frontend deps first."); + return 1; + } + + const actual = {}; + for (const name of files) { + const found = TYPESCRIPT.test(name) + ? tsFindings.filter((f) => f.file.endsWith(`/${name}`)) + : lintLineBased(asRepoPath(name)); + actual[name] = found + .map((f) => `${f.line}:${f.rule}:${f.severity}`) + .sort((a, b) => Number(a.split(":")[0]) - Number(b.split(":")[0])); + } + + if (flags.has("--update")) { + writeFileSync(expectedPath, `${JSON.stringify(actual, null, 2)}\n`); + process.stdout.write( + `comment-lint selftest: recorded ${Object.keys(actual).length} fixtures to ${relative(REPO, expectedPath)}\n`, + ); + return 0; + } + + if (!existsSync(expectedPath)) { + warn("fixtures/expected.json is missing. Run --selftest --update to record it."); + return 1; + } + + // --quiet says nothing unless something is wrong. It is how the lint tasks run + // the corpus first without burying their own output under eleven ok lines. + const quiet = flags.has("--quiet"); + const expected = JSON.parse(readFileSync(expectedPath, "utf8")); + let failures = 0; + for (const name of files) { + const want = (expected[name] ?? []).join(" | "); + const got = actual[name].join(" | "); + if (want === got) { + if (!quiet) process.stdout.write(`ok ${name} (${actual[name].length})\n`); + continue; + } + failures++; + process.stdout.write(`FAIL ${name}\n expected: ${want || "(nothing)"}\n actual: ${got || "(nothing)"}\n`); + } + + const stale = Object.keys(expected).filter((n) => !files.includes(n)); + for (const name of stale) { + failures++; + process.stdout.write(`FAIL ${name} is in expected.json but the fixture is gone\n`); + } + + if (failures > 0) { + process.stdout.write( + `\ncomment-lint selftest: ${failures} fixture(s) differ. If intended, rerun with --update and review the diff.\n`, + ); + return 1; + } + if (!quiet) process.stdout.write("\ncomment-lint selftest: both engines match the corpus\n"); + return 0; +} + +function isFlagValue(arg) { + const index = argv.indexOf(arg); + return index > 0 && argv[index - 1] === "--since"; +} + +function flagValue(flag) { + const index = argv.indexOf(flag); + return argv[index + 1] ?? "origin/main"; +} + +function git(args) { + // stderr is captured rather than inherited so git's line-ending advice ("CRLF + // will be replaced by LF") does not print once per file on Windows. Real + // failures still surface: execFileSync throws, and the caller reads .stderr. + return execFileSync("git", args, { + cwd: REPO, + encoding: "utf8", + maxBuffer: 1 << 28, + stdio: ["ignore", "pipe", "pipe"], + }); +} diff --git a/scripts/lint/comment-rules.mjs b/scripts/lint/comment-rules.mjs new file mode 100644 index 0000000000..4211be9689 --- /dev/null +++ b/scripts/lint/comment-rules.mjs @@ -0,0 +1,500 @@ +// The comment-quality rule set, shared by both engines so a rule means the same +// thing everywhere: the oxlint JS plugin (which owns .ts/.tsx, and has real +// comment tokens and an AST) and comment-lint.mjs (which owns .java and .py, and +// has only lines). Neither engine ever scans the other's files, so the two can +// differ in precision without producing contradictory findings on one file. +// +// The standard these rules enforce is devGuide/CODE_COMMENTS.md. Changing a rule +// here without changing that document leaves the repo with two answers. +// +// Between them the engines read every comment form the repo writes: // and /* */, +// Javadoc and JSDoc, JSX comments, # and Python docstrings. + +export const SEVERITY = { ERROR: "error", WARN: "warn" }; + +// Every rule blocks. A rule that only warns is a rule nobody acts on, so a +// finding that turns out to be wrong is a bug in the rule: narrow it, or mark the +// line with comment-lint-allow and say why. Each rule below carries the readings +// it deliberately excludes, which is where to start when one misfires. +export const RULES = { + CMT001: { name: "restates-code", severity: SEVERITY.ERROR }, + CMT002: { name: "banner", severity: SEVERITY.ERROR }, + CMT003: { name: "step-narration", severity: SEVERITY.ERROR }, + CMT004: { name: "diff-narration", severity: SEVERITY.ERROR }, + CMT005: { name: "dead-code", severity: SEVERITY.ERROR }, + CMT006: { name: "block-too-long", severity: SEVERITY.ERROR }, + CMT007: { name: "doc-restates-signature", severity: SEVERITY.ERROR }, + CMT008: { name: "bad-allow", severity: SEVERITY.ERROR }, + CMT009: { name: "unowned-todo", severity: SEVERITY.ERROR }, +}; + +export const MAX_BLOCK_LINES = 12; + +// CMT001 compares a comment against the code it introduces. Both sides are +// reduced to the same shape first: lowercased, camel/snake/kebab split into +// words, stop words and short words dropped. What survives is the information +// each side actually carries, so "Handle drag start" and `handleDragStart` land +// on the same set and the comment is shown to add nothing. + +const STOP_WORDS = new Set( + ( + "a an the and or but if then else for to of in on at by with from into is are be was were this that these those it its as we our you your do" + + " does done use uses used using will would should can could may might not no yes new only also just so such via per each all any some more" + + " most other another same when while where which what who how why here there now next finally first second third let const var function" + + " return set get" + ).split(" "), +); + +const WORD_RE = /[a-z][a-z0-9]*/g; + +export function contentWords(text) { + return (text.toLowerCase().match(WORD_RE) ?? []).filter((w) => w.length > 2 && !STOP_WORDS.has(w)); +} + +export function identWords(text) { + const split = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_\-.]/g, " "); + return contentWords(split); +} + +// Sentence punctuation marks prose, which is usually saying something the code +// does not. A single trailing full stop does not count. +const PROSE_PUNCT = /[.;:?!]/; +const MAX_RESTATE_WORDS = 6; + +// Arrange/Act/Assert and Given/When/Then label the shape of a test rather than +// describe the line beneath. Exempt only as a bare marker, so +// `// Assert the cap is clamped to the tier maximum` is prose and judged on its +// merits. +const TEST_STRUCTURE = /^(arrange|act|assert|given|when|then)\b/i; +const MAX_MARKER_WORDS = 4; + +export function restatesCode(body, codeText) { + if (TEST_STRUCTURE.test(body.trim()) && body.trim().split(/\s+/).length <= MAX_MARKER_WORDS) return false; + if (PROSE_PUNCT.test(body.replace(/\.$/, ""))) return false; + const comment = contentWords(body); + if (comment.length === 0 || comment.length > MAX_RESTATE_WORDS) return false; + const code = identWords(codeText); + if (code.length === 0) return false; + + // Prefix matching either way, so "config" covers "configuration" and vice versa. + return comment.every((w) => code.some((k) => k.startsWith(w) || w.startsWith(k))); +} + +const RULE_CHARS = /^[=~_*#+\-]{4,}|[─-╿]{4,}|[=~_*+]{4,}$/; +const SECTION_LABEL = new RegExp( + "^(imports?|exports?|types?|interfaces?|constants?|config|helpers?|utils?|utilities|state|handlers?|callbacks?|effects?" + + "|render|rendering|styles?|props?|hooks?|setup|teardown|cleanup|main|public|private|internal|api|queries|mutations" + + "|selectors?|actions?|reducers?|components?|fields?|getters?|setters?|lifecycle|boilerplate)" + + "\\s*(section|area|block)?\\s*$", + "i", +); + +export function isBanner(body) { + if (RULE_CHARS.test(body.trim())) return true; + + // A label wrapped in decoration is still a label: strip the decoration first. + const bare = body + .replace(/[=~_*#+\-─-╿]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return bare.length > 0 && SECTION_LABEL.test(bare); +} + +// A bare "1." is not narration: numbered lists are how a doc block enumerates +// conditions or alternatives, and matching them buries the rule in false +// positives. Only the explicit step form and sequencing adverbs qualify, and the +// number needs a separator after it, so a wrapped line beginning "step 2 unmounts +// + remounts the panel" reads as the prose it is. +const STEP = /^(step\s*\d+(\.\d+)?\s*[:.)\-]|(then|next|finally|afterwards|lastly)\s*[,:]\s+\S)/i; + +export function isStepNarration(body) { + return STEP.test(body.trim()); +} + +// Only phrases that can be talking about the code's own past. Excluded because +// each has an innocent reading that fires constantly: +// "used to" alone - "Used to clamp the live line" means "is used to" +// "previously" alone - "re-show even if previously dismissed" is runtime state +// "was called" - collides with "verify getSession was called" +// "left over from" - "no cards left over from the unfiltered grid" +const DIFF_NARRATION = new RegExp( + "\\b((this|it|we|they|that) used to|used to (be|live|sit)" + + "|(this|it|that|which|the (class|method|field|code|file|module|palette|banner)) is no longer (needed|used)" + + "|renamed from|was (previously|formerly) (called|named|known)" + + "|instead of the old|has been (removed|replaced) )", + "i", +); +const REMOVAL_SUFFIX = /(^|\s)[-(—]\s*(removed|deleted|dropped|no longer needed)\s*\)?\s*$/i; + +export function isDiffNarration(body) { + const t = body.trim(); + return DIFF_NARRATION.test(t) || REMOVAL_SUFFIX.test(t); +} + +const CODE_KEYWORD = new RegExp( + "^(import|package|public|private|protected|static|final|abstract|class|interface|enum|record|extends|implements" + + "|def|async|await|const|let|var|function|export|return|if|else|elif|for|while|do|try|catch|finally|switch|case" + + "|throw|new|super|this|@[A-Za-z])\\b", +); +const STATEMENT_TAIL = /[;{}]\s*$/; +const CALL_ONLY = /^[\w.$]+\s*\([^)]*\)\s*;?\s*$/; +const ASSIGNMENT = /\S\s*=\s*\S/; + +export function looksLikeCode(line) { + const t = line.trim(); + if (t.length === 0) return false; + if (CODE_KEYWORD.test(t)) return true; + if (CALL_ONLY.test(t)) return true; + return STATEMENT_TAIL.test(t) && ASSIGNMENT.test(t); +} + +export const MIN_DEAD_CODE_RUN = 3; +const DEAD_CODE_SHARE = 2 / 3; + +export function isDeadCodeRun(bodies) { + if (bodies.length < MIN_DEAD_CODE_RUN) return false; + const codeish = bodies.filter(looksLikeCode).length; + return codeish / bodies.length >= DEAD_CODE_SHARE; +} + +// Every documented-parameter form this repo writes, so the rule is not quietly +// Javadoc-only: +// Javadoc / JSDoc @param blob The blob to download +// Sphinx :param blob: The blob to download +// Google docstring blob: The blob to download (under an Args: heading) +// NumPy style is deliberately absent: it splits the name and the description +// across two lines, and there is one instance of it in the tree. +const PARAM_TAG = /^@param\s+(?:\{[^}]*\}\s+)?([\w$.]+)\s*-?\s*(.+)$/; +const SPHINX_PARAM = /^:(?:param|arg|key)\s+(?:\S+\s+)?([\w.]+)\s*:\s*(.+)$/; +const GOOGLE_PARAM = /^([a-z_][\w]*)\s*(?:\([^)]*\))?\s*:\s*(.+)$/; + +const RETURN_TAG = /^@returns?\s+(.+)$/; +const SPHINX_RETURN = /^:returns?\s*:\s*(.+)$/; + +// Description adds nothing when every word in it already appears in the thing +// being described. `@param blob - The blob to download` is the canonical case. +// +// No native linter covers this. eslint-plugin-jsdoc's require-param-description, +// Checkstyle's NonEmptyAtclauseDescription and ruff's D-rules all check that a +// description exists, not whether it says anything. +export function docRestatesSignature(body, ownerName = "") { + const t = body.trim(); + + for (const pattern of [PARAM_TAG, SPHINX_PARAM, GOOGLE_PARAM]) { + const match = pattern.exec(t); + if (!match) continue; + const [, name, description] = match; + // Google form is just `name: description`, which also matches ordinary prose + // containing a colon. Require the description to be short and unpunctuated so + // "Note: the cap is clamped" is not read as a parameter called "note". + if (pattern === GOOGLE_PARAM && /[.;,]/.test(description)) return false; + return addsNothing(description, name, 5); + } + + const returns = RETURN_TAG.exec(t) ?? SPHINX_RETURN.exec(t); + if (returns && ownerName) return addsNothing(returns[1], ownerName, 4); + return false; +} + +function addsNothing(description, subject, limit) { + const words = contentWords(description); + if (words.length === 0 || words.length > limit) return false; + const known = identWords(subject); + return known.length > 0 && words.every((w) => known.some((k) => k.startsWith(w) || w.startsWith(k))); +} + +// A TODO with no reference has nothing that will ever close it. An owner is not +// accepted in its place: a username goes stale when someone leaves and means +// nothing to an outside contributor, while an issue outlives both. +// +// Anchored at the start, so this catches a comment that *is* a TODO rather than +// prose that mentions the word. +const TODO_MARKER = /^(TODO|FIXME|HACK|XXX)\b/; + +// What counts as something that will close it: an issue, a link, or a security +// advisory. Checked across the whole comment run, so the reference can sit on a +// continuation line. +const HAS_REFERENCE = /(#\d+|https?:\/\/|CVE-\d|GHSA-|[A-Z]{2,}-\d+)/; + +export function isUnownedTodo(body, runText = body) { + return TODO_MARKER.test(body) && !HAS_REFERENCE.test(runText); +} + +// A rule id is silenced by `comment-lint-allow: CMT002`, on the comment itself or +// on the line above it. There is deliberately no form that disables every rule. +// +// The whole comment must be the directive. Matching it anywhere in the text meant +// prose that merely mentions the syntax silenced a rule, which this file's own +// paragraph above did. +const DIRECTIVE = /^comment-lint-allow:\s*(.+?)\s*$/i; + +export function isDirective(body) { + return DIRECTIVE.test(body.trim()); +} + +// A directive that names nothing real, or that suppresses nothing, is dead +// configuration: it reads as a silenced rule while silencing nothing, and it +// blinds the line for whoever inherits it. Reported for the same reason ESLint +// has --report-unused-disable-directives and ruff has RUF100. +class Allowance { + constructor(directives) { + this.entries = []; + for (const directive of directives) { + for (const token of directiveTokens(directive.body)) { + this.entries.push({ token, directive, known: token in RULES, used: false }); + } + } + } + + // Called only once a rule has decided it would report, so a directive counts + // as used when it actually silenced something. Asking before the rule decided + // marked every consulted directive as used, which hid the unused ones. + suppresses(rule) { + let allowed = false; + for (const entry of this.entries) { + if (entry.token !== rule) continue; + entry.used = true; + allowed = true; + } + return allowed; + } + + reportUnused(report) { + for (const entry of this.entries) { + if (entry.used) continue; + const detail = entry.known ? `${entry.token} is allowed here but nothing reported it` : `${entry.token} is not a rule`; + report("CMT008", entry.directive.line, entry.directive.column, detail, entry.directive.body); + } + } +} + +// Every token a directive names, valid or not, so an unknown one is reported +// rather than quietly ignored. Matching only real ids would let `CMT999` through +// as a silent no-op: it looks like a rule and silences nothing. +export function directiveTokens(body) { + const match = DIRECTIVE.exec(body.trim()); + if (!match) return []; + return match[1] + .split(",") + .map((token) => token.trim().toUpperCase()) + .filter(Boolean); +} + +// Generated files carry whatever the generator emits, and editing them to +// satisfy a lint rule would be undone on the next regeneration. +const GENERATED_MARKER = /AUTO-?GENERATED|@generated|DO NOT EDIT|Code generated by/i; +const GENERATED_HEADER_LINES = 10; + +export function isGenerated(source) { + return GENERATED_MARKER.test(source.split("\n", GENERATED_HEADER_LINES).join("\n")); +} + +export const EXCLUDED_PATHS = [ + /(^|\/)node_modules\//, + /(^|\/)dist(-\w+)?\//, + /(^|\/)build\//, + /(^|\/)target\//, + /(^|\/)vendor\//, + /pdfjs/i, + /thirdParty/i, + /\.min\./, + /src-tauri\/gen\//, + /public\/locales\//, + /\.d\.ts$/, + /(^|\/)storybook-static\//, + /(^|\/)playwright-report\//, + /(^|\/)org\/apache\//, +]; + +export function isExcludedPath(file) { + const normalised = file.replace(/\\/g, "/"); + return EXCLUDED_PATHS.some((re) => re.test(normalised)); +} + +// Comment text reduced to what a reader would call "the same comment": trimmed, +// whitespace collapsed, comment markers and decoration stripped. Both sides of +// the pre-existing check normalise through here so indentation and marker style +// cannot make an unchanged comment look new. +export function normaliseComment(text) { + return String(text) + .replace(/^[\s{]*(\/\/+|\/\*+|#+|\*+)/gm, " ") + .replace(/\*+\/[\s}]*$/gm, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +// Every comment in a source file, normalised. Deliberately permissive and +// language-agnostic: it only ever decides whether a finding is pre-existing, so +// over-matching suppresses a duplicate comment and under-matching just reports +// something the author can look at. +export function commentBodiesOf(source) { + const bodies = new Set(); + for (const raw of source.split(/\r?\n/)) { + const marker = /(\/\/+|\/\*+|^\s*\*+|#+)/.exec(raw); + if (!marker) continue; + const body = normaliseComment(raw.slice(marker.index)); + if (body.length > 0) bodies.add(body); + } + return bodies; +} + +export function ruleLabel(id) { + return `${id} ${RULES[id].name}`; +} + +// Both engines funnel into this. They differ only in how they build `runs`: the +// oxlint plugin reads real comment tokens, comment-lint.mjs scans lines. Keeping +// the rule application here is what stops the two drifting apart. +// +// A "run" is a group of comment lines with no code between them, which is the +// unit CMT005 and CMT006 judge. Shape: +// { startLine, kind: "line" | "block" | "doc", lines: [{ line, column, body }] } +// `line` is 1-based to match every editor and every diff. + +export function analyse({ lines, runs, isTestFile = false }) { + const findings = []; + // `body` is the comment's own text, kept alongside the formatted detail so the + // caller can ask whether this exact comment already existed before the change. + // That is what stops a reindent or a code move reporting comments nobody wrote. + const report = (rule, line, column, detail, body) => { + if (isTestFile && SUPPRESSED_IN_TESTS.has(rule)) return; + findings.push({ rule, line, column, detail, body: normaliseComment(body ?? detail), severity: RULES[rule].severity }); + }; + + for (const run of runs) { + // A directive is scaffolding, not content. Leaving it in the run made it two + // lines long, and CMT001 only judges a one-line run, so any directive + // silenced CMT001 whatever rule it named. + const directives = run.lines.filter((l) => isDirective(l.body)); + const content = run.lines.filter((l) => !isDirective(l.body)); + const allowed = new Allowance(directives); + + if (content.length === 0) { + allowed.reportUnused(report); + continue; + } + + const bodies = content.map((l) => l.body); + const runText = bodies.join("\n"); + const first = content[0]; + + // Only CMT004 and CMT009 judge a trailing comment. The others depend on the + // comment introducing the code below it, and a trailing comment sits beside + // it: `0x25 // "%PDF"` overlaps in words while adding the decoding, which is + // the kind of lower-altitude fact the standard asks for. + if (run.trailing) { + for (const entry of content) { + const body = entry.body.trim(); + if (body.length === 0) continue; + if (isDiffNarration(body) && !allowed.suppresses("CMT004")) { + report("CMT004", entry.line, entry.column, truncate(body), body); + continue; + } + if (isUnownedTodo(body, runText) && !allowed.suppresses("CMT009")) { + report("CMT009", entry.line, entry.column, truncate(body), body); + } + } + allowed.reportUnused(report); + continue; + } + + if (isDeadCodeRun(bodies) && !allowed.suppresses("CMT005")) { + report("CMT005", run.startLine, first.column, `${bodies.length} commented-out lines`, runText); + allowed.reportUnused(report); + continue; // Every other rule would pile onto the same block of dead code. + } + + // A doc block is exempt: the standard asks for thorough contracts, so capping + // their length would argue with itself. This judges runs of implementation + // comment, where an essay means the code needs restructuring. + const essay = run.kind !== "doc" && content.length > MAX_BLOCK_LINES && run.startLine > FILE_HEADER_LINES; + if (essay && !allowed.suppresses("CMT006")) { + report("CMT006", run.startLine, first.column, `${content.length} lines, limit ${MAX_BLOCK_LINES}`, runText); + } + + const owner = run.kind === "line" ? "" : nextCodeLine(lines, run); + + for (const entry of content) { + const body = entry.body.trim(); + if (body.length === 0) continue; + + if (isBanner(body) && !allowed.suppresses("CMT002")) { + report("CMT002", entry.line, entry.column, truncate(body), body); + continue; + } + if (isStepNarration(body) && !allowed.suppresses("CMT003")) { + report("CMT003", entry.line, entry.column, truncate(body), body); + continue; + } + if (isDiffNarration(body) && !allowed.suppresses("CMT004")) { + report("CMT004", entry.line, entry.column, truncate(body), body); + continue; + } + if (docRestatesSignature(body, owner) && !allowed.suppresses("CMT007")) { + report("CMT007", entry.line, entry.column, truncate(body), body); + continue; + } + if (isUnownedTodo(body, runText) && !allowed.suppresses("CMT009")) { + report("CMT009", entry.line, entry.column, truncate(body), body); + continue; + } + } + + // CMT001 judges a whole single-line run against the code it introduces, so + // a two-line comment that happens to echo one identifier is left alone. + // A one-line `/* … */` counts, which is how JSX `{/* Cap editor */}` above + // `` is caught. A doc block does not: it is a contract, and + // CMT007 is the rule that judges those. + if (run.kind !== "doc" && content.length === 1) { + const entry = first; + const body = entry.body.trim(); + const code = nextCodeLine(lines, run); + if (code && !isBanner(body) && restatesCode(body, code) && !allowed.suppresses("CMT001")) { + report("CMT001", entry.line, entry.column, `${truncate(body)} -> ${truncate(code)}`, body); + } + } + + allowed.reportUnused(report); + } + + return findings.sort((a, b) => a.line - b.line || a.column - b.column); +} + +// A file header is allowed to be as long as it needs to be. +const FILE_HEADER_LINES = 5; +const DETAIL_WIDTH = 58; + +// Both of these say something real in a test and nothing anywhere else. A +// regression test explains itself by describing the old behaviour, and the e2e +// specs number their comments to match a written manual test procedure. +const SUPPRESSED_IN_TESTS = new Set(["CMT003", "CMT004"]); + +function precedingLine(lines, startLine) { + return lines[startLine - 2] ?? ""; +} + +function nextCodeLine(lines, run) { + const commentLines = new Set(run.lines.map((l) => l.line)); + for (let i = run.startLine; i < lines.length; i++) { + const lineNumber = i + 1; + if (commentLines.has(lineNumber)) continue; + const text = lines[i]?.trim() ?? ""; + if (text.length === 0) continue; + if (text.startsWith("//") || text.startsWith("#") || text.startsWith("*") || text.startsWith("/*")) continue; + if (text === "}" || text === "};" || text === ")" || text === ");") return ""; + return text; + } + return ""; +} + +function truncate(text) { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length > DETAIL_WIDTH ? `${flat.slice(0, DETAIL_WIDTH - 1)}…` : flat; +} + +export const TEST_FILE = /\.(test|spec)\.[jt]sx?$|(^|\/)src\/test\/|Test\.java$|Tests\.java$|(^|\/)test_[^/]+\.py$|_test\.py$/; + +export function isTestPath(file) { + return TEST_FILE.test(file.replace(/\\/g, "/")); +} diff --git a/scripts/lint/fixtures/AaaTest.java b/scripts/lint/fixtures/AaaTest.java new file mode 100644 index 0000000000..1aac398327 --- /dev/null +++ b/scripts/lint/fixtures/AaaTest.java @@ -0,0 +1,19 @@ +package fixtures; + +class AaaTest { + + void clampsToTierMaximum() { + // Arrange + var wallet = walletAt(500); + + // Act + var result = clamp(wallet); + + // Assert + assertEquals(100, result.cap()); + + // Assert the cap is clamped rather than rejected, because the tier + // downgrade path relies on it. + assertTrue(result.clamped()); + } +} diff --git a/scripts/lint/fixtures/README.md b/scripts/lint/fixtures/README.md new file mode 100644 index 0000000000..ceb8f1044b --- /dev/null +++ b/scripts/lint/fixtures/README.md @@ -0,0 +1,17 @@ +# comment-lint fixtures + +The contract between the two engines. Each file is a small, realistic example of +what a rule fires on, or of something it must leave alone. `expected.json` records +what every fixture should produce, down to the line and the severity. + +Expectations live outside the fixtures deliberately: an in-file `EXPECT:` marker +would sit inside the comment under test, changing its word count and its run +length, so the fixture would stop being an example of the real thing. + +```bash +node scripts/lint/comment-lint.mjs --selftest # compare +node scripts/lint/comment-lint.mjs --selftest --update # re-record, then review the diff +``` + +Adding a rule means adding a fixture that fires it and a line in a `clean.*` +fixture that must not. A rule with no fixture is a rule nobody can safely change. diff --git a/scripts/lint/fixtures/allow.java b/scripts/lint/fixtures/allow.java new file mode 100644 index 0000000000..50fe34d21e --- /dev/null +++ b/scripts/lint/fixtures/allow.java @@ -0,0 +1,32 @@ +package fixtures; + +class Allow { + + void keptOnPurpose() { + // comment-lint-allow: CMT002 + // ---------- kept on purpose, this fixture proves the escape hatch ---------- + run(); + } + + void unknownToken(Session session) { + // comment-lint-allow: FAKE_RULE + session.close(); + } + + void looksLikeARuleButIsNot(Session session) { + // comment-lint-allow: CMT999 + session.close(); + } + + void allowedButNothingFires(Session session) { + // comment-lint-allow: CMT001 + // Closed once the signing round trip has settled, not before. + session.close(); + } + + void directiveMustNotHideOtherRules(Registry registry) { + // comment-lint-allow: CMT002 + // Clear the registry + registry.clear(); + } +} diff --git a/scripts/lint/fixtures/apostrophes.java b/scripts/lint/fixtures/apostrophes.java new file mode 100644 index 0000000000..25d0745fd8 --- /dev/null +++ b/scripts/lint/fixtures/apostrophes.java @@ -0,0 +1,23 @@ +package fixtures; + +class Apostrophes { + + /** The approver's team, which must match the one this server already belongs to. */ + Long teamId; + + // The caller's own retry budget applies here; this method does not retry. + void submit() {} + + void literals() { + char quote = '\''; + char newline = '\n'; + char slash = '/'; + String path = "a//b"; + } + + /** Kept last: if the scanner desynchronises above, this stops being seen. */ + void canary() { + // Build document + document = build(); + } +} diff --git a/scripts/lint/fixtures/clean.java b/scripts/lint/fixtures/clean.java new file mode 100644 index 0000000000..34f21c313c --- /dev/null +++ b/scripts/lint/fixtures/clean.java @@ -0,0 +1,26 @@ +package fixtures; + +/** + * Authority on which filesystem locations a policy may read or write. Fail-closed: + * denied entirely under the saas profile, then Stirling's own config directory is + * always rejected, then the path must resolve inside an allowed root. + * + *

Compared after normalisation so {@code ..} cannot escape a root. Symlink + * escape is not defended: an operator who roots an allowlist on a symlink to a + * sensitive location is trusted. + */ +class Clean { + + /** Returns the normalised absolute path; throws if not permitted. */ + Path check(Path candidate) { + // whenComplete runs on the worker thread after the run finishes, so the + // terminal event never races the step events. + return candidate.toAbsolutePath().normalize(); + } + + void sizes() { + // Bytes, not KiB: the API contract predates the unit change and callers + // still send bytes. + long limit = 5_242_880L; + } +} diff --git a/scripts/lint/fixtures/clean.ts b/scripts/lint/fixtures/clean.ts new file mode 100644 index 0000000000..cac6895417 --- /dev/null +++ b/scripts/lint/fixtures/clean.ts @@ -0,0 +1,17 @@ +/** + * Auth/session seam. saas keeps a Supabase web session; desktop keeps a JWT in + * the Tauri secure store, and cloud code reads the token through here instead. + * Default no-op; saas/ and desktop/ shadow it. + */ +export interface SessionSeam { + /** Bearer access token for authenticated API calls, or null when signed out. */ + getAccessToken(): Promise; +} + +export function createSeam(): SessionSeam { + return { + // Resolves null rather than throwing: a signed-out caller is an ordinary + // state here, and every consumer already branches on null. + getAccessToken: async () => null, + }; +} diff --git a/scripts/lint/fixtures/deadcode.java b/scripts/lint/fixtures/deadcode.java new file mode 100644 index 0000000000..e2ad5b887e --- /dev/null +++ b/scripts/lint/fixtures/deadcode.java @@ -0,0 +1,14 @@ +package fixtures; + +class DeadCode { + + // private void oldPath(PDDocument document) { + // PDPage page = document.getPage(0); + // page.setRotation(90); + // document.save(target); + // } + + void currentPath(PDDocument document) { + document.save(target); + } +} diff --git a/scripts/lint/fixtures/docs.java b/scripts/lint/fixtures/docs.java new file mode 100644 index 0000000000..f572dc545f --- /dev/null +++ b/scripts/lint/fixtures/docs.java @@ -0,0 +1,12 @@ +package fixtures; + +class Docs { + + /** + * @param blob the blob + * @param timeoutMs how long to wait before abandoning the read; the caller + * owns retrying, because only it knows whether the operation is + * idempotent + */ + void download(Blob blob, long timeoutMs) {} +} diff --git a/scripts/lint/fixtures/docstrings.py b/scripts/lint/fixtures/docstrings.py new file mode 100644 index 0000000000..b9c67023c7 --- /dev/null +++ b/scripts/lint/fixtures/docstrings.py @@ -0,0 +1,36 @@ +"""Module docstring, which the scanner must see as documentation.""" + + +def download(blob, timeout_ms): + """Fetch the blob. + + Args: + blob: The blob + timeout_ms: How long to wait before abandoning the read; the caller owns + retrying, because only it knows whether the operation is idempotent. + + Returns: + The fetched bytes. + """ + return read(blob) + + +def sphinx(blob): + """Fetch the blob. + + :param blob: The blob + :returns: the sphinx result + """ + return read(blob) + + +def payload(): + # Not documentation: a triple-quoted value, so its contents are data. + body = """{"key": "value", "note": "Types"}""" + return body + + +def canary(): + # Build document + document = build() + return document diff --git a/scripts/lint/fixtures/expected.json b/scripts/lint/fixtures/expected.json new file mode 100644 index 0000000000..34c89482a5 --- /dev/null +++ b/scripts/lint/fixtures/expected.json @@ -0,0 +1,63 @@ +{ + "AaaTest.java": [], + "allow.java": [ + "12:CMT008:error", + "17:CMT008:error", + "22:CMT008:error", + "28:CMT008:error", + "29:CMT001:error" + ], + "apostrophes.java": [ + "20:CMT001:error" + ], + "clean.java": [], + "clean.ts": [], + "deadcode.java": [ + "5:CMT005:error" + ], + "docs.java": [ + "6:CMT007:error" + ], + "docstrings.py": [ + "8:CMT007:error", + "21:CMT007:error", + "34:CMT001:error" + ], + "narration.java": [ + "6:CMT003:error", + "9:CMT003:error" + ], + "narration.tsx": [ + "2:CMT003:error", + "5:CMT004:error", + "13:CMT001:error" + ], + "restates.java": [ + "6:CMT001:error", + "9:CMT001:error", + "17:CMT002:error", + "19:CMT002:error" + ], + "restates.py": [ + "2:CMT001:error", + "5:CMT002:error" + ], + "restates.ts": [ + "2:CMT001:error", + "14:CMT002:error", + "18:CMT002:error" + ], + "strings.java": [], + "templates.py": [ + "22:CMT001:error" + ], + "todos.java": [ + "6:CMT009:error" + ], + "trailing.java": [ + "13:CMT009:error", + "14:CMT004:error", + "19:CMT009:error", + "20:CMT004:error" + ] +} diff --git a/scripts/lint/fixtures/narration.java b/scripts/lint/fixtures/narration.java new file mode 100644 index 0000000000..6dcb69b981 --- /dev/null +++ b/scripts/lint/fixtures/narration.java @@ -0,0 +1,22 @@ +package fixtures; + +class Narration { + + void export(Document document) { + // Step 1: collect the annotations + var annotations = document.annotations(); + + // Then, flatten them onto the page + document.flatten(annotations); + + // IMPORTANT: do not reorder these + document.save(); + + // No longer needed after the storage migration + legacyCleanup(); + + // Ordering matters: flatten() reads the annotation list that save() + // clears, so a save first loses every annotation. See #6865. + document.close(); + } +} diff --git a/scripts/lint/fixtures/narration.tsx b/scripts/lint/fixtures/narration.tsx new file mode 100644 index 0000000000..fa931ce925 --- /dev/null +++ b/scripts/lint/fixtures/narration.tsx @@ -0,0 +1,17 @@ +export function Panel() { + // Step 1: read the cap + const cap = useCap(); + + // This used to be initialised by the footer, which mounted after the banner. + useConsentBanner(); + + // CRITICAL: keep this above the early return + useLayoutEffect(() => sync(cap), [cap]); + + return ( +

+ {/* Cap editor */} + +
+ ); +} diff --git a/scripts/lint/fixtures/restates.java b/scripts/lint/fixtures/restates.java new file mode 100644 index 0000000000..47b2e004b8 --- /dev/null +++ b/scripts/lint/fixtures/restates.java @@ -0,0 +1,24 @@ +package fixtures; + +class Restates { + + void run(Registry registry, Job job) { + // Clear the registry + registry.clear(); + + // Sanitize filename + String safeFilename = sanitizeFilename(job.originalFilename()); + + // Wait for the worker to drain before clearing, or an in-flight job + // re-registers its temp file after the sweep. + registry.awaitQuiescence(); + } + + // ---------- Internal helpers ---------- + + // Types + private enum Mode { + FAST, + SAFE + } +} diff --git a/scripts/lint/fixtures/restates.py b/scripts/lint/fixtures/restates.py new file mode 100644 index 0000000000..b952a934c0 --- /dev/null +++ b/scripts/lint/fixtures/restates.py @@ -0,0 +1,9 @@ +def build(request): + # Get the current status + status = request.current_status() + + # ---- helpers ---- + + # Truncated to 200 chars because the audit column is varchar(200) and a + # longer value fails the insert rather than being trimmed. + return status[:200] diff --git a/scripts/lint/fixtures/restates.ts b/scripts/lint/fixtures/restates.ts new file mode 100644 index 0000000000..3b19eb1f10 --- /dev/null +++ b/scripts/lint/fixtures/restates.ts @@ -0,0 +1,20 @@ +export function useGrid() { + // Handle drag start + const handleDragStart = (event: DragStartEvent) => event.active.id; + + // Selection state + const selection = new Set(); + + // Debounced so a fast drag does not queue a layout pass per pointer move. + const updateLayout = debounce(() => measure(), 16); + + return { handleDragStart, selection, updateLayout }; +} + +// ─── Types ──────────────────────────────────────────────────────────────── + +export type Gate = "OFFSITE_PROCESSING" | "AUTOMATION"; + +// Helpers + +export function noop() {} diff --git a/scripts/lint/fixtures/strings.java b/scripts/lint/fixtures/strings.java new file mode 100644 index 0000000000..d3600ceb3d --- /dev/null +++ b/scripts/lint/fixtures/strings.java @@ -0,0 +1,11 @@ +package fixtures; + +class Strings { + + // A `//` inside a literal is not a comment, and neither is an escaped quote. + void urls() { + String docs = "https://example.com/guide"; + String quoted = "a \" then // not a comment"; + char slash = '/'; + } +} diff --git a/scripts/lint/fixtures/templates.py b/scripts/lint/fixtures/templates.py new file mode 100644 index 0000000000..86e11edea8 --- /dev/null +++ b/scripts/lint/fixtures/templates.py @@ -0,0 +1,24 @@ +"""Templates that open mid-line, whose closing delimiter starts a line.""" + +_TOOL_IO = ''' +TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {{ +{declarations} +}} +''' + +_HEADER = """ +# Header emitted into the output file. +# Types +""" + + +def resolve(schema): + if "$ref" in schema: + return lookup(schema["$ref"]) + return schema + + +def canary(): + # Build document + document = build() + return document diff --git a/scripts/lint/fixtures/todos.java b/scripts/lint/fixtures/todos.java new file mode 100644 index 0000000000..2287ca143e --- /dev/null +++ b/scripts/lint/fixtures/todos.java @@ -0,0 +1,25 @@ +package fixtures; + +class Todos { + + void bare() { + // TODO: re-enable once account syncing lands + skip(); + } + + void referenced() { + // TODO(#1234): re-enable once account syncing lands + skip(); + } + + void linked() { + // FIXME: the upstream fix is tracked at https://example.com/issues/9 + workaround(); + } + + void mentioned() { + // Image placeholders are not scored: their body text is a TODO marker + // rather than prose, so scoring it would reward the placeholder. + score(); + } +} diff --git a/scripts/lint/fixtures/trailing.java b/scripts/lint/fixtures/trailing.java new file mode 100644 index 0000000000..58077e4561 --- /dev/null +++ b/scripts/lint/fixtures/trailing.java @@ -0,0 +1,22 @@ +package fixtures; + +class Trailing { + + void decodings() { + byte[] header = {0x25, 0x50, 0x44, 0x46}; // "%PDF" + long maxSize = 50L * 1024 * 1024; // 50 MB + double buffer = 0.10; // 10% headroom + int mode = 2; // MB + } + + void stillJudged() { + boolean supportsSign = false; // TODO make Sign work + cleanup(); // this used to run before the flush + } + + void blockFormToo() { + int mode = 2; /* MB */ + boolean ready = false; /* TODO wire this up */ + reset(); /* this used to run before the flush */ + } +} From 1c055f3d1857e19e4e70c76436e95585abe83563 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:27:05 +0000 Subject: [PATCH 21/57] Centre modals in the viewport instead of pinning them near the top (#7715) ## What Every dialog in the processor is the shared `.sui-modal` shell, and its backdrop was top-aligning the panel: ```css align-items: flex-start; padding: 5rem 1.5rem 1.5rem; /* 80px above, 24px below */ ``` On a 900px-tall viewport that started every dialog at `y=80` with ~350px of dead space beneath it. Phones already had an `align-items: center` override; desktop never got one. ## Change `frontend/editor/src/core/ui/Modal.css` only: - Symmetric block inset, `align-items: center`. - The inset is published as `--modal-inset-block`, and `.sui-modal`'s `max-height` derives from it. That coupling is the point: if the two drift apart, a tall modal overflows a centre-aligned backdrop and loses its header off the top of the screen, unreachable. - The phone breakpoint now only moves the variable. Measured at 375x812 it resolves to exactly the previous values (`16px 12px`, `max-height: 780px`), so mobile behaviour is unchanged. One shared file, so this covers flow modals, source / user / pipeline / API-key modals, billing and procurement. ## Before / After image ## Testing - `task frontend:check` passes (lint + typecheck + 2356 tests). - Phone breakpoint measured directly in the browser, values match the previous behaviour. --- frontend/editor/src/core/ui/Modal.css | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/frontend/editor/src/core/ui/Modal.css b/frontend/editor/src/core/ui/Modal.css index 2988ec920e..e41ffb0eda 100644 --- a/frontend/editor/src/core/ui/Modal.css +++ b/frontend/editor/src/core/ui/Modal.css @@ -1,11 +1,16 @@ +/* The inset is symmetric so the panel sits in the optical centre of the viewport rather than + riding the top edge. It is published as a var because .sui-modal's max-height has to be the + viewport minus both halves of it — if the two drift apart a tall modal overflows the backdrop + and, because the panel is centre-aligned, loses its header off the top of the screen. */ .sui-modal__backdrop { + --modal-inset-block: 2.5rem; position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); display: flex; - align-items: flex-start; + align-items: center; justify-content: center; - padding: 5rem 1.5rem 1.5rem; + padding: var(--modal-inset-block) 1.5rem; z-index: 100; animation: fadeIn 0.18s ease both; overscroll-behavior: contain; @@ -24,20 +29,19 @@ display: flex; flex-direction: column; width: 100%; - max-height: calc(100vh - 6.5rem); - max-height: calc(100dvh - 6.5rem); /* mobile browser chrome shrinks 100vh */ + max-height: calc(100vh - var(--modal-inset-block) * 2); + /* mobile browser chrome shrinks 100vh */ + max-height: calc(100dvh - var(--modal-inset-block) * 2); overflow: hidden; animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1) both; } -/* Phones: drop the tall top inset so the modal gets the vertical space */ +/* Phones: tighten the inset so the modal gets the vertical space. Only the variable moves — + the max-height above follows it, so the pair cannot fall out of step. */ @media (max-width: 30rem) { .sui-modal__backdrop { - padding: 1rem 0.75rem; - align-items: center; - } - .sui-modal { - max-height: calc(100dvh - 2rem); + --modal-inset-block: 1rem; + padding-inline: 0.75rem; } } From d3708c1e63f5161d1143b780f497c6f4b671f58a Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:02:47 +0000 Subject: [PATCH 22/57] Highlight the rail entry whose tool is open (#7723) --- .../shared/quickNav/QuickNavHostBridge.tsx | 3 ++ .../shared/quickNav/QuickNavRailHost.tsx | 4 +++ .../contexts/QuickNavHostContext.test.tsx | 31 +++++++++++++++++++ .../src/core/contexts/QuickNavHostContext.tsx | 7 +++++ frontend/editor/src/core/pages/HomePage.tsx | 1 + 5 files changed, 46 insertions(+) diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx index 9a9101a826..d97674464e 100644 --- a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx @@ -22,6 +22,7 @@ export interface QuickNavHostBridgeProps { requestNavigation?: (go: () => void) => void; onGoToDefaultState?: () => void; onSelectTool?: (toolId: ToolId) => void; + activeTool?: ToolId | null; /** Merged over the reasons worked out here, for what only the app can see. */ toolReasons?: QuickNavToolReasons; } @@ -34,6 +35,7 @@ export function QuickNavHostBridge({ onOpenSettings, requestNavigation, onSelectTool, + activeTool = null, onGoToDefaultState, toolReasons, }: QuickNavHostBridgeProps) { @@ -59,6 +61,7 @@ export function QuickNavHostBridge({ signingBadge, portalAccess, readerMode, + activeTool, notificationsOpen, toolReasons: mergedToolReasons, }, diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx index 3fba4d7e1f..e7a1b6e8c6 100644 --- a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx @@ -48,6 +48,8 @@ export function QuickNavRailHost() { else go(route); }; + const openingTool = (id: ToolId) => ({ current: host?.activeTool === id }); + const unusable = (id: ToolId) => { const reason = host?.toolReasons?.[id]; return { disabled: Boolean(reason), reason }; @@ -135,6 +137,7 @@ export function QuickNavRailHost() { icon: ( ), + ...openingTool("automate"), ...unusable("automate"), onClick: () => openTool("automate", "/automate"), }, @@ -146,6 +149,7 @@ export function QuickNavRailHost() { ), badge: host?.signingBadge, badgeTone: "warning", + ...openingTool("sharedSign"), ...unusable("sharedSign"), onClick: () => openTool("sharedSign", "/shared-sign"), }, diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx index fc648cbfe5..0a348262fa 100644 --- a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx @@ -13,6 +13,7 @@ function Probe({ onRead }: { onRead: (value: unknown) => void }) { appMounted: host?.appMounted, chromeless: host?.chromeless, identity: host?.identity, + activeTool: host?.activeTool, openSettings: Boolean(host?.actions.current?.openSettings), }); return null; @@ -26,6 +27,11 @@ function App() { return null; } +function AppWithTool({ tool }: { tool: "automate" | null }) { + useRegisterQuickNavHost({ activeTool: tool }, {}); + return null; +} + function LoginRoute() { useSuppressQuickNavRail(); return null; @@ -71,6 +77,31 @@ describe("QuickNavHostContext", () => { expect(after.openSettings).toBe(false); }); + it("clears the open tool when the next app registers without one", () => { + let latest: Record = {}; + const view = render( + + (latest = value as Record)} + /> + + , + ); + expect(latest.activeTool).toBe("automate"); + + act(() => { + view.rerender( + + (latest = value as Record)} + /> + + , + ); + }); + expect(latest.activeTool).toBe(null); + }); + it("hides the bar while a route with no app chrome is on screen", () => { // appMounted is sticky, so it can't answer "is an app on screen now". const { view, read } = setup(); diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx index 540ec8cd6c..1a19cffc6f 100644 --- a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx @@ -24,6 +24,7 @@ export interface QuickNavHostData { signingBadge: number; portalAccess: boolean; readerMode: boolean; + activeTool: ToolId | null; /** The app owns the panel; the rail's bell only reports its state. */ notificationsOpen: boolean; /** Translated; absent means usable. */ @@ -61,6 +62,7 @@ const EMPTY_DATA: QuickNavHostData = { signingBadge: 0, portalAccess: false, readerMode: false, + activeTool: null, notificationsOpen: false, hasSettings: false, }; @@ -90,6 +92,7 @@ export function QuickNavHostProvider({ children }: { children: ReactNode }) { merged.signingBadge === prev.signingBadge && merged.portalAccess === prev.portalAccess && merged.readerMode === prev.readerMode && + merged.activeTool === prev.activeTool && merged.notificationsOpen === prev.notificationsOpen && merged.hasSettings === prev.hasSettings && merged.identity?.displayName === prev.identity?.displayName && @@ -143,6 +146,7 @@ export function useRegisterQuickNavHost( signingBadge, portalAccess, readerMode, + activeTool, notificationsOpen, toolReasons, } = data; @@ -155,6 +159,8 @@ export function useRegisterQuickNavHost( signingBadge: signingBadge ?? 0, portalAccess: portalAccess ?? false, readerMode: readerMode ?? false, + // Cleared, not omitted as toolReasons is: a stale tool marks an entry. + activeTool: activeTool ?? null, notificationsOpen: notificationsOpen ?? false, // Omitted when unknown, so the last answer survives a re-fetch. ...(toolReasons ? { toolReasons } : {}), @@ -168,6 +174,7 @@ export function useRegisterQuickNavHost( signingBadge, portalAccess, readerMode, + activeTool, notificationsOpen, toolReasons, hasSettings, diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index efb6af40d2..6434acbc9e 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -525,6 +525,7 @@ export default function HomePage() { onSetReaderMode={setReaderMode} onGoToDefaultState={goToDefaultState} onSelectTool={handleToolSelect} + activeTool={selectedToolKey} toolReasons={quickNavToolReasons} /> From c22d9ecf5801a75c84da927788a5bce2092c56d9 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:22:05 +0000 Subject: [PATCH 23/57] feat(editor): move the admin directory onto TanStack Query (#7726) # Description of Changes Step 5 of the TanStack Query rollout, covering the admin People, Teams and Team details screens. Follows #7264, #7283, #7285. ## The problem Two separate ones, in the same three files. **Reads.** Each section fetched and held its own copy of the same resources: People read the roster and the team list, Teams read the team list plus the roster again when its add-member modal opened, Team details read all three. Cost scaled with how many screens you visited rather than with how much data exists. **Writes.** Thirteen handlers each did the same five things by hand: set a processing flag, call the service, toast the outcome, dig a message out of an axios error, and reload their own slice. Refreshing was a convention, not a mechanism, and one handler had already forgotten it. ## The fix Three shared query keys (`adminUsers`, `teams`, `teamDetails`), and one `useAdminMutation` helper that every write is declared against: ```ts const createTeam = useAdminMutation({ write: (name: string) => teamService.createTeam(name), invalidates: ["teams"], success: t("workspace.teams.createTeam.success"), errorFallback: t("workspace.teams.createTeam.error"), onDone: () => { setNewTeamName(""); setCreateModalOpened(false); }, }); ``` Each write names the slices it disturbs, which is the part that only works when reads and writes are designed together: `createTeam` invalidates the team list, while a membership move invalidates the list, both teams' detail rows and the roster, because it genuinely changes all three. Invalidation refetches only mounted queries, so this costs nothing extra. The blanket "invalidate everything" helper survives in exactly one role: child components (invite, password change, seat update) that write through their own services, where the affected scopes are not visible from the call site. ## Why it is better, measured Request counts come from one harness driving `teams -> team details -> back -> people`, run against the branch point and against this branch. The assertion is committed, so it cannot silently regress. | | Before | After | |---|---|---| | Requests | 7 | **3** | | `getTeams` | 4 | **1** | | `getUsers` | 2 | **1** | | `getTeamDetails` | 1 | 1 | | Committed renders | 17 | **15** | Three is one per distinct resource, the floor for that sequence. The four `getTeams` were the Teams table, Team details fetching the same list for its "move to team" dropdown, the explicit refresh on the back button, and People. Renders barely move, which is expected: this changes where data lives, not how often React draws. It is reported because a caching change can quietly cost renders, and this one does not. On the code itself, across the three sections: | | | |---|---| | Net lines | **-216** | | `useState`/`useEffect` removed | **11**, none added | | Duplicated `isAxiosError` blocks | 13 to **1** | | `setProcessing` calls | 19 to **0** | `isAxiosError` is no longer imported by any of the three files. ## Bug fixed `disableMfaByAdmin` showed a success toast and never refreshed. The menu item renders only when `user.mfaEnabled` is true, so an admin disabled MFA, was told it worked, and watched the option stay on screen until a manual reload. It is covered by a test that fails if the invalidation is removed. ## Behaviour worth checking in review - A write no longer blocks its handler before closing the modal. The dialog closes when the write succeeds and the table updates when the refetch lands, rather than the button spinning through both. - Modal submit buttons now track their own mutation rather than one shared flag. Team details still derives a single busy flag, now from its five mutations rather than a `useState`, so its row actions disable together as before. - The per-handler `console.error` is kept, once, in the shared error path. ## Testing Five tests, each verified by breaking the implementation and confirming that one test, and only that one, fails: | Mutation | Caught by | |---|---| | Drop the shared stale window (`staleTime: 0`) | request-count test | | Make invalidation a no-op | write-visibility test | | Ignore the login-enabled gate | login-disabled test | | Stop invalidating after the MFA write | MFA-refresh test | | Fall back to the generic error message | server-message test | The write tests drive the real flows through their modals and menus rather than calling hooks directly. `task frontend:check` passes typecheck, lint and oxfmt, and 2383 of 2385 editor tests. The two failures, `workbenchSession.test.ts` and `notificationActions.test.tsx`, are untouched here and fail identically with this branch's changes reverted. ## Scope The three services keep their current shape; nothing outside these three sections and the new hook module changes. Child modals that write through their own services still refresh via the blanket helper, and converting those is separate work. --- frontend/editor/src/core/query/keys.ts | 4 + .../config/configSections/PeopleSection.tsx | 477 +++++++----------- .../configSections/TeamDetailsSection.tsx | 393 ++++++--------- .../config/configSections/TeamsSection.tsx | 216 +++----- .../config/configSections/adminReads.test.tsx | 295 +++++++++++ .../proprietary/hooks/useAdminDirectory.ts | 151 ++++++ 6 files changed, 885 insertions(+), 651 deletions(-) create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/adminReads.test.tsx create mode 100644 frontend/editor/src/proprietary/hooks/useAdminDirectory.ts diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index 5354b56b63..d95f674011 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -1,5 +1,7 @@ /** Editor query keys: ["editor", , ...params]. */ export const qk = { + /** The admin directory payload: a different endpoint and shape to qk.users(). */ + adminUsers: () => ["editor", "adminUsers"] as const, appConfig: () => ["editor", "appConfig"] as const, endpointsAvailability: () => ["editor", "endpointsAvailability"] as const, endpointEnabled: (endpoint: string) => @@ -9,5 +11,7 @@ export const qk = { /** Keyed on the asking identity: two users must never share one answer. */ portalAccess: (userId: string | null) => ["editor", "portalAccess", userId] as const, + teamDetails: (teamId: number) => ["editor", "teamDetails", teamId] as const, + teams: () => ["editor", "teams"] as const, users: () => ["editor", "users"] as const, } as const; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index d955cb341b..6b14d2b421 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -1,5 +1,4 @@ -import { useState, useEffect } from "react"; -import { isAxiosError } from "axios"; +import { useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Stack, @@ -20,14 +19,12 @@ import { import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { alert } from "@app/components/toast"; import { userManagementService, User, } from "@app/services/userManagementService"; -import { teamService, Team } from "@app/services/teamService"; +import { type Team } from "@app/services/teamService"; import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import { useAppConfig } from "@app/contexts/AppConfigContext"; import InviteMembersModal from "@app/components/shared/InviteMembersModal"; import { useLoginRequired } from "@app/hooks/useLoginRequired"; import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; @@ -36,17 +33,109 @@ import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton"; import { useLicense } from "@app/contexts/LicenseContext"; import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal"; import { useAuth } from "@app/auth/UseSession"; +import { + useAdminUsers, + useTeams, + useAdminMutation, + useInvalidateAdminDirectory, +} from "@app/hooks/useAdminDirectory"; + +const EXAMPLE_USERS: User[] = [ + { + id: 1, + username: "admin", + email: "admin@example.com", + enabled: true, + roleName: "ROLE_ADMIN", + rolesAsString: "ROLE_ADMIN", + authenticationType: "password", + isActive: true, + lastRequest: Date.now(), + team: { id: 1, name: "Engineering" }, + }, + { + id: 2, + username: "john.doe", + email: "john.doe@example.com", + enabled: true, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "password", + isActive: false, + lastRequest: Date.now() - 86400000, + team: { id: 1, name: "Engineering" }, + }, + { + id: 3, + username: "jane.smith", + email: "jane.smith@example.com", + enabled: true, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "oauth", + isActive: true, + lastRequest: Date.now(), + team: { id: 2, name: "Marketing" }, + }, + { + id: 4, + username: "bob.wilson", + email: "bob.wilson@example.com", + enabled: false, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "password", + isActive: false, + lastRequest: Date.now() - 604800000, + team: undefined, + }, +]; + +const EXAMPLE_TEAMS: Team[] = [ + { id: 1, name: "Engineering", userCount: 3 }, + { id: 2, name: "Marketing", userCount: 2 }, +]; + +const EXAMPLE_LICENSE = { + maxAllowedUsers: 10, + availableSlots: 6, + grandfatheredUserCount: 0, + licenseMaxUsers: 5, + premiumEnabled: true, + totalUsers: 4, +}; export default function PeopleSection() { const { t } = useTranslation(); - const { config } = useAppConfig(); const { loginEnabled } = useLoginRequired(); const { user: currentUser } = useAuth(); const navigate = useNavigate(); const { licenseInfo: globalLicenseInfo } = useLicense(); - const [users, setUsers] = useState([]); - const [teams, setTeams] = useState([]); - const [loading, setLoading] = useState(true); + const admin = useAdminUsers(loginEnabled); + const { data: fetchedTeams } = useTeams(loginEnabled); + const refreshDirectory = useInvalidateAdminDirectory(); + + // Session and MFA state arrive alongside the roster, keyed by username. + const fetchedUsers = useMemo(() => { + if (!admin.data) return []; + return admin.data.users.map((user) => ({ + ...user, + isActive: admin.data.userSessions[user.username] || false, + lastRequest: admin.data.userLastRequest[user.username] || undefined, + mfaEnabled: + ( + admin.data.userSettings?.[user.username] as + | Record + | undefined + )?.mfaEnabled === "true", + })); + }, [admin.data]); + + // Login off means the endpoints are not callable, so the table shows a + // worked example instead of an empty state. + const users = loginEnabled ? fetchedUsers : EXAMPLE_USERS; + const teams = loginEnabled ? (fetchedTeams ?? []) : EXAMPLE_TEAMS; + const loading = loginEnabled && admin.isPending; const [searchQuery, setSearchQuery] = useState(""); const [inviteModalOpened, setInviteModalOpened] = useState(false); const [editUserModalOpened, setEditUserModalOpened] = useState(false); @@ -54,19 +143,20 @@ export default function PeopleSection() { useState(false); const [passwordUser, setPasswordUser] = useState(null); const [selectedUser, setSelectedUser] = useState(null); - const [processing, setProcessing] = useState(false); - const [mailEnabled, setMailEnabled] = useState(false); - const [lockedUsers, setLockedUsers] = useState([]); - - // License information - const [licenseInfo, setLicenseInfo] = useState<{ - maxAllowedUsers: number; - availableSlots: number; - grandfatheredUserCount: number; - licenseMaxUsers: number; - premiumEnabled: boolean; - totalUsers: number; - } | null>(null); + const mailEnabled = loginEnabled ? (admin.data?.mailEnabled ?? false) : false; + const lockedUsers = loginEnabled ? (admin.data?.lockedUsers ?? []) : []; + const licenseInfo = loginEnabled + ? admin.data + ? { + maxAllowedUsers: admin.data.maxAllowedUsers, + availableSlots: admin.data.availableSlots, + grandfatheredUserCount: admin.data.grandfatheredUserCount, + licenseMaxUsers: admin.data.licenseMaxUsers, + premiumEnabled: admin.data.premiumEnabled, + totalUsers: admin.data.totalUsers, + } + : null + : EXAMPLE_LICENSE; const hasNoSlots = licenseInfo ? licenseInfo.availableSlots === 0 : false; const handleAddMembersClick = () => { if (!loginEnabled) { @@ -115,253 +205,103 @@ export default function PeopleSection() { teamId: undefined as number | undefined, }); - useEffect(() => { - fetchData(); - }, []); + const updateUserRole = useAdminMutation({ + write: (payload: { username: string; role: string; teamId?: number }) => + userManagementService.updateUserRole(payload), + // A role edit can also move the user, which changes both teams' counts. + invalidates: ["users", "teams"], + success: t("workspace.people.editMember.success"), + errorFallback: t("workspace.people.editMember.error"), + onDone: () => closeEditModal(), + }); - useEffect(() => { - if (config) { - console.log( - "[PeopleSection] Email invites enabled:", - config.enableEmailInvites, - ); - } - }, [config]); + const toggleEnabled = useAdminMutation({ + write: (user: User) => + userManagementService.toggleUserEnabled(user.username, !user.enabled), + invalidates: ["users"], + success: t("workspace.people.toggleEnabled.success"), + errorFallback: t("workspace.people.toggleEnabled.error"), + }); - const fetchData = async () => { - try { - setLoading(true); + const deleteUser = useAdminMutation({ + write: (username: string) => userManagementService.deleteUser(username), + invalidates: ["users", "teams"], + success: t( + "workspace.people.deleteUserSuccess", + "User deleted successfully", + ), + errorFallback: t( + "workspace.people.deleteUserError", + "Failed to delete user", + ), + }); - if (loginEnabled) { - const [adminData, teamsData] = await Promise.all([ - userManagementService.getUsers(), - teamService.getTeams(), - ]); + const unlockUser = useAdminMutation({ + write: (username: string) => userManagementService.unlockUser(username), + invalidates: ["users"], + success: t( + "workspace.people.unlockUserSuccess", + "User account unlocked successfully", + ), + errorFallback: t( + "workspace.people.unlockUserError", + "Failed to unlock user account", + ), + }); - // Enrich users with session data - const enrichedUsers = adminData.users.map((user) => ({ - ...user, - isActive: adminData.userSessions[user.username] || false, - lastRequest: adminData.userLastRequest[user.username] || undefined, - mfaEnabled: - ( - adminData.userSettings?.[user.username] as - | Record - | undefined - )?.mfaEnabled === "true", - })); + const disableMfa = useAdminMutation({ + write: (username: string) => + userManagementService.disableMfaByAdmin(username), + invalidates: ["users"], + success: t( + "workspace.people.mfa.adminDisableSuccess", + "MFA disabled successfully for user", + ), + errorFallback: t( + "workspace.people.mfa.adminDisableError", + "Failed to disable MFA for user", + ), + }); - setUsers(enrichedUsers); - setTeams(teamsData); - - // Store license information - setLicenseInfo({ - maxAllowedUsers: adminData.maxAllowedUsers, - availableSlots: adminData.availableSlots, - grandfatheredUserCount: adminData.grandfatheredUserCount, - licenseMaxUsers: adminData.licenseMaxUsers, - premiumEnabled: adminData.premiumEnabled, - totalUsers: adminData.totalUsers, - }); - setMailEnabled(adminData.mailEnabled); - setLockedUsers(adminData.lockedUsers || []); - } else { - // Provide example data when login is disabled - const exampleUsers: User[] = [ - { - id: 1, - username: "admin", - email: "admin@example.com", - enabled: true, - roleName: "ROLE_ADMIN", - rolesAsString: "ROLE_ADMIN", - authenticationType: "password", - isActive: true, - lastRequest: Date.now(), - team: { id: 1, name: "Engineering" }, - }, - { - id: 2, - username: "john.doe", - email: "john.doe@example.com", - enabled: true, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "password", - isActive: false, - lastRequest: Date.now() - 86400000, - team: { id: 1, name: "Engineering" }, - }, - { - id: 3, - username: "jane.smith", - email: "jane.smith@example.com", - enabled: true, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "oauth", - isActive: true, - lastRequest: Date.now(), - team: { id: 2, name: "Marketing" }, - }, - { - id: 4, - username: "bob.wilson", - email: "bob.wilson@example.com", - enabled: false, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "password", - isActive: false, - lastRequest: Date.now() - 604800000, - team: undefined, - }, - ]; - - const exampleTeams: Team[] = [ - { id: 1, name: "Engineering", userCount: 3 }, - { id: 2, name: "Marketing", userCount: 2 }, - ]; - - setUsers(exampleUsers); - setTeams(exampleTeams); - setMailEnabled(false); - setLockedUsers([]); - - // Example license information - setLicenseInfo({ - maxAllowedUsers: 10, - availableSlots: 6, - grandfatheredUserCount: 0, - licenseMaxUsers: 5, - premiumEnabled: true, - totalUsers: 4, - }); - } - } catch (error) { - console.error("[PeopleSection] Failed to fetch people data:", error); - alert({ alertType: "error", title: "Failed to load people data" }); - } finally { - setLoading(false); - } - }; - - const handleUpdateUserRole = async () => { + const handleUpdateUserRole = () => { if (!selectedUser) return; - - try { - setProcessing(true); - await userManagementService.updateUserRole({ - username: selectedUser.username, - role: editForm.role, - teamId: editForm.teamId, - }); - alert({ - alertType: "success", - title: t("workspace.people.editMember.success"), - }); - closeEditModal(); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to update user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.editMember.error"); - alert({ alertType: "error", title: errorMessage }); - } finally { - setProcessing(false); - } + updateUserRole.mutate({ + username: selectedUser.username, + role: editForm.role, + teamId: editForm.teamId, + }); }; - const handleToggleEnabled = async (user: User) => { - try { - await userManagementService.toggleUserEnabled( - user.username, - !user.enabled, - ); - alert({ - alertType: "success", - title: t("workspace.people.toggleEnabled.success"), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to toggle user status:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.toggleEnabled.error"); - alert({ alertType: "error", title: errorMessage }); - } + const handleToggleEnabled = (user: User) => { + toggleEnabled.mutate(user); }; - const handleDeleteUser = async (user: User) => { + const handleDeleteUser = (user: User) => { const confirmMessage = t( "workspace.people.confirmDelete", "Are you sure you want to delete this user? This action cannot be undone.", ); - if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { - return; - } + if ( + !window.confirm(`${confirmMessage} - try { - await userManagementService.deleteUser(user.username); - alert({ - alertType: "success", - title: t( - "workspace.people.deleteUserSuccess", - "User deleted successfully", - ), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to delete user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.deleteUserError", "Failed to delete user"); - alert({ alertType: "error", title: errorMessage }); - } +User: ${user.username}`) + ) + return; + deleteUser.mutate(user.username); }; - const handleUnlockUser = async (user: User) => { + const handleUnlockUser = (user: User) => { const confirmMessage = t( "workspace.people.confirmUnlock", "Are you sure you want to unlock this user account?", ); - if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { - return; - } + if ( + !window.confirm(`${confirmMessage} - try { - await userManagementService.unlockUser(user.username); - alert({ - alertType: "success", - title: t( - "workspace.people.unlockUserSuccess", - "User account unlocked successfully", - ), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to unlock user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t( - "workspace.people.unlockUserError", - "Failed to unlock user account", - ); - alert({ alertType: "error", title: errorMessage }); - } +User: ${user.username}`) + ) + return; + unlockUser.mutate(user.username); }; const openEditModal = (user: User) => { @@ -549,7 +489,7 @@ export default function PeopleSection() { - + )} @@ -891,40 +831,7 @@ export default function PeopleSection() { height="1rem" /> } - onClick={async () => { - try { - await userManagementService.disableMfaByAdmin( - user.username, - ); - alert({ - alertType: "success", - title: t( - "workspace.people.mfa.adminDisableSuccess", - "MFA disabled successfully for user", - ), - }); - } catch (error: unknown) { - console.error( - "[PeopleSection] Failed to disable MFA for user:", - error, - ); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error - ? error.message - : undefined) || - t( - "workspace.people.mfa.adminDisableError", - "Failed to disable MFA for user", - ); - alert({ - alertType: "error", - title: errorMessage, - }); - } - }} + onClick={() => disableMfa.mutate(user.username)} disabled={!loginEnabled} > {t( @@ -968,14 +875,14 @@ export default function PeopleSection() { setInviteModalOpened(false)} - onSuccess={fetchData} + onSuccess={refreshDirectory} /> @@ -1075,7 +982,7 @@ export default function PeopleSection() { /> @@ -186,7 +188,7 @@ export function FormSaveBar({ loading={saving} disabled={applying || policyEnforcing} onClick={handleDownload} - style={{ flex: 1 }} + style={{ flex: "1 1 10rem", minWidth: 0 }} > {t("viewer.formBar.download", "Download PDF")} From 8c00fffe185d027d022befac6f8d10216fab7472 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:04:06 +0200 Subject: [PATCH 31/57] refactor(api): replace com.fasterxml.jackson with tools.jackson (Jackson 2 to Jackson 3 namespace.) (#7444) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../cluster/valkey/ValkeyJobStore.java | 14 +++--- .../storage/converter/JsonMapConverter.java | 16 +++---- .../controller/SigningSessionController.java | 4 +- .../workflow/util/WorkflowMapper.java | 4 +- .../ai/controller/AiCreateController.java | 12 ++--- .../AiCreateInternalController.java | 12 ++--- .../service/StripeUsageReportingService.java | 2 +- .../saas/legal/LegalDocumentRegistry.java | 44 +++++++++---------- .../payg/entitlement/EntitlementGuard.java | 2 +- .../api/ProcurementController.java | 2 +- .../procurement/legal/AgreementAssembler.java | 2 +- .../KeygenEnterpriseLicenseService.java | 4 +- .../service/ProcurementService.java | 6 +-- .../entitlement/EntitlementGuardTest.java | 4 +- 14 files changed, 63 insertions(+), 65 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java index 8e93871859..750abea4fc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java @@ -17,16 +17,16 @@ import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.cluster.JobStore; import stirling.software.common.cluster.JobStoreEntry; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + /** * Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId. * @@ -265,7 +265,7 @@ public class ValkeyJobStore implements JobStore { } try { return MAPPER.readValue(v.toString(), MAP_STRING); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn( "JobStore {} field 'resultMeta' is not valid JSON '{}' - treating as empty", key, @@ -277,7 +277,7 @@ public class ValkeyJobStore implements JobStore { private static String writeJson(Object value) { try { return MAPPER.writeValueAsString(value); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new IllegalStateException("Failed to JSON-serialize JobStore field", e); } } @@ -286,7 +286,7 @@ public class ValkeyJobStore implements JobStore { try { List parsed = MAPPER.readValue(json, LIST_STRING); return parsed == null ? new ArrayList<>() : parsed; - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn( "JobStore {} field 'fileIds' is not valid JSON '{}' - treating as empty", key, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java index 1c9f7ab765..5576ebb181 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java @@ -3,16 +3,16 @@ package stirling.software.proprietary.storage.converter; import java.util.HashMap; import java.util.Map; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; import lombok.extern.slf4j.Slf4j; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + /** * JPA AttributeConverter for storing Map as JSON in database columns. * @@ -33,7 +33,7 @@ public class JsonMapConverter implements AttributeConverter, try { return objectMapper.writeValueAsString(attribute); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.error("Failed to convert map to JSON", e); throw new RuntimeException("Failed to convert map to JSON", e); } @@ -48,7 +48,7 @@ public class JsonMapConverter implements AttributeConverter, try { // Try normal parsing first return objectMapper.readValue(dbData, new TypeReference>() {}); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { // Fallback: try double-parsing for legacy double-encoded data // This handles data that was stored as JSON strings instead of JSON objects log.debug("Attempting double-decode fallback for legacy metadata format"); @@ -69,7 +69,7 @@ public class JsonMapConverter implements AttributeConverter, return objectMapper.readValue( node.asText(), new TypeReference>() {}); } - } catch (JsonProcessingException e2) { + } catch (JacksonException e2) { log.error("Failed to parse metadata even with double-decode fallback", e2); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index 4e95707217..73867776e9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -20,8 +20,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import com.fasterxml.jackson.databind.ObjectMapper; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -44,6 +42,8 @@ import stirling.software.proprietary.workflow.service.CertificateSubmissionValid import stirling.software.proprietary.workflow.service.SigningFinalizationService; import stirling.software.proprietary.workflow.service.WorkflowSessionService; +import tools.jackson.databind.ObjectMapper; + @Slf4j @RestController @RequestMapping("/api/v1/security") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java index b2f53c2824..8d61ae032c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java @@ -4,14 +4,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import com.fasterxml.jackson.databind.ObjectMapper; - import stirling.software.proprietary.workflow.dto.ParticipantResponse; import stirling.software.proprietary.workflow.dto.WetSignatureMetadata; import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse; import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; +import tools.jackson.databind.ObjectMapper; + /** * Utility class for mapping workflow entities to DTOs. Centralizes conversion logic for consistent * API responses. diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java index 28bbfbdea0..77a87a27bb 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java @@ -26,8 +26,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; @@ -167,7 +167,7 @@ public class AiCreateController { if (request.constraints() != null) { try { constraintsPayload = objectMapper.writeValueAsString(request.constraints()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid constraints payload", exc); } @@ -202,7 +202,7 @@ public class AiCreateController { String payload; try { payload = objectMapper.writeValueAsString(request.draftSections()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc); } @@ -392,7 +392,7 @@ public class AiCreateController { objectMapper .getTypeFactory() .constructCollectionType(List.class, DraftSection.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse draft sections payload", exc); return null; } @@ -408,7 +408,7 @@ public class AiCreateController { objectMapper .getTypeFactory() .constructMapType(Map.class, String.class, Object.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse outline constraints payload", exc); return null; } diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java index 60c8dc4615..04b8302e7e 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java @@ -14,8 +14,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; @@ -61,7 +61,7 @@ public class AiCreateInternalController { try { outlineConstraintsPayload = objectMapper.writeValueAsString(request.outlineConstraints()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid outline constraints payload", exc); } @@ -70,7 +70,7 @@ public class AiCreateInternalController { if (request.draftSections() != null) { try { draftSectionsPayload = objectMapper.writeValueAsString(request.draftSections()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc); } @@ -136,7 +136,7 @@ public class AiCreateInternalController { .getTypeFactory() .constructCollectionType( List.class, AiCreateController.DraftSection.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse draft sections payload", exc); return null; } @@ -152,7 +152,7 @@ public class AiCreateInternalController { objectMapper .getTypeFactory() .constructMapType(Map.class, String.class, Object.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse outline constraints payload", exc); return null; } diff --git a/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java b/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java index d9445483a3..ae5d3d943a 100644 --- a/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java +++ b/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java @@ -12,7 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java index ffbe030863..48dd6a1c3b 100644 --- a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java @@ -13,8 +13,8 @@ import java.util.regex.Pattern; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -56,28 +56,26 @@ public class LegalDocumentRegistry { subprocessorUrl = root.path("subprocessorUrl").asText(""); eulaUrl = root.path("eulaUrl").asText(""); JsonNode docs = root.path("documents"); - docs.fieldNames() - .forEachRemaining( - id -> { - JsonNode d = docs.get(id); - List parts = - objectMapper.convertValue( - d.path("parts"), - objectMapper - .getTypeFactory() - .constructCollectionType( - List.class, String.class)); - documents.put( + docs.forEachEntry( + (id, d) -> { + List parts = + objectMapper.convertValue( + d.path("parts"), + objectMapper + .getTypeFactory() + .constructCollectionType( + List.class, String.class)); + documents.put( + id, + new LegalDocumentMeta( id, - new LegalDocumentMeta( - id, - d.path("label").asText(id), - d.path("displayName").asText(id), - d.path("version").asText("0"), - d.path("effectiveDate").asText(""), - d.path("status").asText("draft"), - parts == null ? List.of() : parts)); - }); + d.path("label").asText(id), + d.path("displayName").asText(id), + d.path("version").asText("0"), + d.path("effectiveDate").asText(""), + d.path("status").asText("draft"), + parts == null ? List.of() : parts)); + }); log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java index ae8474fa42..441ebf1220 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java @@ -20,7 +20,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index 1c495b3afd..c08778bdbc 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java index c641f2e596..e75f3cbb32 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java @@ -10,7 +10,7 @@ import java.util.Map; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java index a11ad05755..a1c09a9cb9 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java @@ -16,8 +16,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index a739e538b2..26bfb733dd 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -10,8 +10,8 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -707,7 +707,7 @@ public class ProcurementService { private String writeLineItems(QuoteBreakdown breakdown) { try { return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems()); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn("[procurement] failed to serialise line items", e); return "[]"; } diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java index 0a77de3e7e..a9ca7642a6 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java @@ -28,8 +28,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.method.HandlerMethod; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; From 74be5bf0ad0bbf592b8944cb2a8677f0c4055ff0 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:04:35 +0200 Subject: [PATCH 32/57] fix(forms): Fix checkbox export values and wide dropdown options (#7288) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- frontend/editor/src/core/tools/formFill/FieldInput.tsx | 7 +++++-- .../editor/src/core/tools/formFill/FormFieldOverlay.tsx | 9 ++++++--- .../core/tools/formFill/providers/PdfiumFormProvider.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/editor/src/core/tools/formFill/FieldInput.tsx b/frontend/editor/src/core/tools/formFill/FieldInput.tsx index 9662298dc4..9a8943ea47 100644 --- a/frontend/editor/src/core/tools/formFill/FieldInput.tsx +++ b/frontend/editor/src/core/tools/formFill/FieldInput.tsx @@ -68,8 +68,11 @@ function FieldInputInner({ ); case "checkbox": { - const isChecked = !!value && value !== "Off"; - const onValue = (field.widgets && field.widgets[0]?.exportValue) || "Yes"; + const exportVal = field.widgets && field.widgets[0]?.exportValue; + const isChecked = exportVal + ? value === exportVal || value === "Yes" + : !!value && value !== "Off"; + const onValue = exportVal || "Yes"; return ( Date: Sun, 30 Aug 2026 00:11:41 +0200 Subject: [PATCH 33/57] chore(crop): Remove invalid crop area message and related validation logic (#7160) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../public/locales/ar-AR/translation.toml | 1 - .../public/locales/az-AZ/translation.toml | 1 - .../public/locales/bg-BG/translation.toml | 1 - .../public/locales/bo-CN/translation.toml | 1 - .../public/locales/ca-CA/translation.toml | 1 - .../public/locales/cs-CZ/translation.toml | 1 - .../public/locales/da-DK/translation.toml | 1 - .../public/locales/de-DE/translation.toml | 1 - .../public/locales/el-GR/translation.toml | 1 - .../public/locales/en-GB/translation.toml | 1 - .../public/locales/en-US/translation.toml | 1 - .../public/locales/es-ES/translation.toml | 1 - .../public/locales/eu-ES/translation.toml | 1 - .../public/locales/fa-IR/translation.toml | 1 - .../public/locales/fr-FR/translation.toml | 1 - .../public/locales/ga-IE/translation.toml | 1 - .../public/locales/hi-IN/translation.toml | 1 - .../public/locales/hr-HR/translation.toml | 1 - .../public/locales/hu-HU/translation.toml | 1 - .../public/locales/id-ID/translation.toml | 1 - .../public/locales/it-IT/translation.toml | 1 - .../public/locales/ja-JP/translation.toml | 1 - .../public/locales/ko-KR/translation.toml | 1 - .../public/locales/ml-ML/translation.toml | 1 - .../public/locales/nl-NL/translation.toml | 1 - .../public/locales/no-NB/translation.toml | 1 - .../public/locales/pl-PL/translation.toml | 1 - .../public/locales/pt-BR/translation.toml | 1 - .../public/locales/pt-PT/translation.toml | 1 - .../public/locales/ro-RO/translation.toml | 1 - .../public/locales/ru-RU/translation.toml | 1 - .../public/locales/sk-SK/translation.toml | 1 - .../public/locales/sl-SI/translation.toml | 1 - .../locales/sr-LATN-RS/translation.toml | 1 - .../public/locales/sv-SE/translation.toml | 1 - .../public/locales/th-TH/translation.toml | 1 - .../public/locales/tr-TR/translation.toml | 1 - .../public/locales/uk-UA/translation.toml | 1 - .../public/locales/vi-VN/translation.toml | 1 - .../public/locales/zh-BO/translation.toml | 1 - .../public/locales/zh-CN/translation.toml | 1 - .../public/locales/zh-TW/translation.toml | 1 - .../components/tools/crop/CropSettings.tsx | 23 +---------- .../hooks/tools/crop/useCropParameters.ts | 39 ++++++++----------- .../editor/src/core/utils/cropCoordinates.ts | 16 +++++++- 45 files changed, 32 insertions(+), 88 deletions(-) diff --git a/frontend/editor/public/locales/ar-AR/translation.toml b/frontend/editor/public/locales/ar-AR/translation.toml index 4c4d45e2b4..4180d77811 100644 --- a/frontend/editor/public/locales/ar-AR/translation.toml +++ b/frontend/editor/public/locales/ar-AR/translation.toml @@ -3526,7 +3526,6 @@ label = "إحداثي Y" [crop.error] failed = "فشل قصّ PDF" -invalidArea = "منطقة القص تتجاوز حدود PDF" [crop.preview] title = "معاينة منطقة القص" diff --git a/frontend/editor/public/locales/az-AZ/translation.toml b/frontend/editor/public/locales/az-AZ/translation.toml index f73930133e..0033be05d5 100644 --- a/frontend/editor/public/locales/az-AZ/translation.toml +++ b/frontend/editor/public/locales/az-AZ/translation.toml @@ -3526,7 +3526,6 @@ label = "Y mövqeyi" [crop.error] failed = "PDF-i kəsmək alınmadı" -invalidArea = "Kəsmə sahəsi PDF sərhədlərini aşır" [crop.preview] title = "Kəsmə sahəsinin seçimi" diff --git a/frontend/editor/public/locales/bg-BG/translation.toml b/frontend/editor/public/locales/bg-BG/translation.toml index dc6ef92e39..07cf5a73d3 100644 --- a/frontend/editor/public/locales/bg-BG/translation.toml +++ b/frontend/editor/public/locales/bg-BG/translation.toml @@ -3526,7 +3526,6 @@ label = "Y позиция" [crop.error] failed = "Неуспешно изрязване на PDF" -invalidArea = "Областта за изрязване излиза извън границите на PDF" [crop.preview] title = "Избор на област за изрязване" diff --git a/frontend/editor/public/locales/bo-CN/translation.toml b/frontend/editor/public/locales/bo-CN/translation.toml index 86d0b4f10d..21193f5f2d 100644 --- a/frontend/editor/public/locales/bo-CN/translation.toml +++ b/frontend/editor/public/locales/bo-CN/translation.toml @@ -3526,7 +3526,6 @@ label = "Yཡི་གནས་བབ།" [crop.error] failed = "སོན་བཟང་མ་འདང་བ། PDF" -invalidArea = "སོན་འདེབས་རྒྱ་ཁྱོན་དེ་PDFམཚམས་ཐིག་ལས་བརྒལ་ཡོད།" [crop.preview] title = "སོན་བཟང་ཁུལ་འདེམས་པ།" diff --git a/frontend/editor/public/locales/ca-CA/translation.toml b/frontend/editor/public/locales/ca-CA/translation.toml index 498da94436..c22244ac98 100644 --- a/frontend/editor/public/locales/ca-CA/translation.toml +++ b/frontend/editor/public/locales/ca-CA/translation.toml @@ -3526,7 +3526,6 @@ label = "Posició Y" [crop.error] failed = "No s'ha pogut retallar el PDF" -invalidArea = "L'àrea de retall s'estén més enllà dels límits del PDF" [crop.preview] title = "Selecció de l'àrea de retall" diff --git a/frontend/editor/public/locales/cs-CZ/translation.toml b/frontend/editor/public/locales/cs-CZ/translation.toml index 7b234329c7..f2161674f4 100644 --- a/frontend/editor/public/locales/cs-CZ/translation.toml +++ b/frontend/editor/public/locales/cs-CZ/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozice Y" [crop.error] failed = "Oříznutí PDF se nezdařilo" -invalidArea = "Oblast ořezu přesahuje hranice PDF" [crop.preview] title = "Výběr oblasti ořezu" diff --git a/frontend/editor/public/locales/da-DK/translation.toml b/frontend/editor/public/locales/da-DK/translation.toml index f4c068da92..a0ddd7f4d9 100644 --- a/frontend/editor/public/locales/da-DK/translation.toml +++ b/frontend/editor/public/locales/da-DK/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-position" [crop.error] failed = "Kunne ikke beskære PDF" -invalidArea = "Beskæringsområdet strækker sig ud over PDF'ens grænser" [crop.preview] title = "Valg af beskæringsområde" diff --git a/frontend/editor/public/locales/de-DE/translation.toml b/frontend/editor/public/locales/de-DE/translation.toml index 7c1bc79a8c..81c88d7599 100644 --- a/frontend/editor/public/locales/de-DE/translation.toml +++ b/frontend/editor/public/locales/de-DE/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-Position" [crop.error] failed = "PDF zuschneiden fehlgeschlagen" -invalidArea = "Zuschneidebereich überschreitet die PDF-Grenzen" [crop.preview] title = "Zuschneidebereich-Auswahl" diff --git a/frontend/editor/public/locales/el-GR/translation.toml b/frontend/editor/public/locales/el-GR/translation.toml index 5269791aec..57ebd2eb6b 100644 --- a/frontend/editor/public/locales/el-GR/translation.toml +++ b/frontend/editor/public/locales/el-GR/translation.toml @@ -3526,7 +3526,6 @@ label = "Θέση Y" [crop.error] failed = "Αποτυχία περικοπής του PDF" -invalidArea = "Η περιοχή περικοπής εκτείνεται πέρα από τα όρια του PDF" [crop.preview] title = "Επιλογή περιοχής περικοπής" diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 8fdd452928..d2b2aa38e3 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3526,7 +3526,6 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" -invalidArea = "Crop area extends beyond PDF boundaries" [crop.preview] title = "Crop Area Selection" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 378157818c..baee16cd02 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3846,7 +3846,6 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" -invalidArea = "Crop area extends beyond PDF boundaries" [crop.preview] title = "Crop Area Selection" diff --git a/frontend/editor/public/locales/es-ES/translation.toml b/frontend/editor/public/locales/es-ES/translation.toml index 73aeb53429..5fffd6eae8 100644 --- a/frontend/editor/public/locales/es-ES/translation.toml +++ b/frontend/editor/public/locales/es-ES/translation.toml @@ -3526,7 +3526,6 @@ label = "Posición Y" [crop.error] failed = "Error al recortar PDF" -invalidArea = "El área de recorte se extiende más allá de los límites del PDF" [crop.preview] title = "Selección de Área de Recorte" diff --git a/frontend/editor/public/locales/eu-ES/translation.toml b/frontend/editor/public/locales/eu-ES/translation.toml index b5a2388e67..018bfbabc5 100644 --- a/frontend/editor/public/locales/eu-ES/translation.toml +++ b/frontend/editor/public/locales/eu-ES/translation.toml @@ -3526,7 +3526,6 @@ label = "Y posizioa" [crop.error] failed = "Huts egin du PDFa mozteak" -invalidArea = "Mozketa-area PDFaren mugak baino harago doa" [crop.preview] title = "Mozketa-arearen hautapena" diff --git a/frontend/editor/public/locales/fa-IR/translation.toml b/frontend/editor/public/locales/fa-IR/translation.toml index 14a40c4694..000d039dfa 100644 --- a/frontend/editor/public/locales/fa-IR/translation.toml +++ b/frontend/editor/public/locales/fa-IR/translation.toml @@ -3526,7 +3526,6 @@ label = "موقعیت Y" [crop.error] failed = "برش PDF ناموفق بود" -invalidArea = "ناحیه برش از مرزهای PDF فراتر رفته است" [crop.preview] title = "انتخاب ناحیه برش" diff --git a/frontend/editor/public/locales/fr-FR/translation.toml b/frontend/editor/public/locales/fr-FR/translation.toml index 5550076052..e7d8a2a9ab 100644 --- a/frontend/editor/public/locales/fr-FR/translation.toml +++ b/frontend/editor/public/locales/fr-FR/translation.toml @@ -3526,7 +3526,6 @@ label = "Position Y" [crop.error] failed = "Échec du recadrage du PDF" -invalidArea = "La zone de recadrage dépasse les limites du PDF" [crop.preview] title = "Sélection de la zone de recadrage" diff --git a/frontend/editor/public/locales/ga-IE/translation.toml b/frontend/editor/public/locales/ga-IE/translation.toml index 4d3ad56168..1507ae92df 100644 --- a/frontend/editor/public/locales/ga-IE/translation.toml +++ b/frontend/editor/public/locales/ga-IE/translation.toml @@ -3526,7 +3526,6 @@ label = "Suíomh Y" [crop.error] failed = "Theip ar an PDF a bhearradh" -invalidArea = "Téann an limistéar bearrtha thar theorainneacha an PDF" [crop.preview] title = "Roghnú Limistéir Bhearrtha" diff --git a/frontend/editor/public/locales/hi-IN/translation.toml b/frontend/editor/public/locales/hi-IN/translation.toml index ae5993b387..bd769216a8 100644 --- a/frontend/editor/public/locales/hi-IN/translation.toml +++ b/frontend/editor/public/locales/hi-IN/translation.toml @@ -3526,7 +3526,6 @@ label = "Y स्थान" [crop.error] failed = "PDF क्रॉप करने में विफल" -invalidArea = "क्रॉप क्षेत्र PDF सीमाओं से बाहर जा रहा है" [crop.preview] title = "क्रॉप क्षेत्र चयन" diff --git a/frontend/editor/public/locales/hr-HR/translation.toml b/frontend/editor/public/locales/hr-HR/translation.toml index 587933825b..371c1b782c 100644 --- a/frontend/editor/public/locales/hr-HR/translation.toml +++ b/frontend/editor/public/locales/hr-HR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y položaj" [crop.error] failed = "Izrezivanje PDF-a nije uspjelo" -invalidArea = "Područje izrezivanja prelazi granice PDF-a" [crop.preview] title = "Odabir područja izrezivanja" diff --git a/frontend/editor/public/locales/hu-HU/translation.toml b/frontend/editor/public/locales/hu-HU/translation.toml index 98d393c047..c8fb681564 100644 --- a/frontend/editor/public/locales/hu-HU/translation.toml +++ b/frontend/editor/public/locales/hu-HU/translation.toml @@ -3526,7 +3526,6 @@ label = "Y pozíció" [crop.error] failed = "A PDF vágása sikertelen" -invalidArea = "A vágási terület túlnyúlik a PDF határain" [crop.preview] title = "Vágási terület kiválasztása" diff --git a/frontend/editor/public/locales/id-ID/translation.toml b/frontend/editor/public/locales/id-ID/translation.toml index 15fe756aa6..83751f1dce 100644 --- a/frontend/editor/public/locales/id-ID/translation.toml +++ b/frontend/editor/public/locales/id-ID/translation.toml @@ -3526,7 +3526,6 @@ label = "Posisi Y" [crop.error] failed = "Gagal memangkas PDF" -invalidArea = "Area pangkas melampaui batas PDF" [crop.preview] title = "Pilihan Area Pangkas" diff --git a/frontend/editor/public/locales/it-IT/translation.toml b/frontend/editor/public/locales/it-IT/translation.toml index a99f00a343..592f644216 100644 --- a/frontend/editor/public/locales/it-IT/translation.toml +++ b/frontend/editor/public/locales/it-IT/translation.toml @@ -3526,7 +3526,6 @@ label = "Posizione Y" [crop.error] failed = "Impossibile ritagliare il PDF" -invalidArea = "L’area di ritaglio supera i limiti del PDF" [crop.preview] title = "Selezione area di ritaglio" diff --git a/frontend/editor/public/locales/ja-JP/translation.toml b/frontend/editor/public/locales/ja-JP/translation.toml index dcc5735dbd..a8d6730ba4 100644 --- a/frontend/editor/public/locales/ja-JP/translation.toml +++ b/frontend/editor/public/locales/ja-JP/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "PDF の切り抜きに失敗しました" -invalidArea = "切り抜き範囲が PDF の境界を超えています" [crop.preview] title = "切り抜き範囲の選択" diff --git a/frontend/editor/public/locales/ko-KR/translation.toml b/frontend/editor/public/locales/ko-KR/translation.toml index 833f710c7f..33ca27a071 100644 --- a/frontend/editor/public/locales/ko-KR/translation.toml +++ b/frontend/editor/public/locales/ko-KR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 위치" [crop.error] failed = "PDF 자르기에 실패했습니다" -invalidArea = "자르기 영역이 PDF 경계를 벗어났습니다" [crop.preview] title = "자르기 영역 선택" diff --git a/frontend/editor/public/locales/ml-ML/translation.toml b/frontend/editor/public/locales/ml-ML/translation.toml index 53e8ef302f..10235abe24 100644 --- a/frontend/editor/public/locales/ml-ML/translation.toml +++ b/frontend/editor/public/locales/ml-ML/translation.toml @@ -3526,7 +3526,6 @@ label = "Y സ്ഥാനം" [crop.error] failed = "PDF ക്രോപ്പ് ചെയ്യാൻ കഴിഞ്ഞില്ല" -invalidArea = "ക്രോപ്പ് ഏരിയ PDF അതിരുകൾക്ക് പുറത്തേക്ക് നീളുന്നു" [crop.preview] title = "ക്രോപ്പ് ഏരിയ തിരഞ്ഞെടുപ്പ്" diff --git a/frontend/editor/public/locales/nl-NL/translation.toml b/frontend/editor/public/locales/nl-NL/translation.toml index d4ada20e32..e5841842f2 100644 --- a/frontend/editor/public/locales/nl-NL/translation.toml +++ b/frontend/editor/public/locales/nl-NL/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-positie" [crop.error] failed = "PDF bijsnijden mislukt" -invalidArea = "Bijsnijgebied valt buiten PDF-randen" [crop.preview] title = "Selectie bijsnijgebied" diff --git a/frontend/editor/public/locales/no-NB/translation.toml b/frontend/editor/public/locales/no-NB/translation.toml index 9402f04f22..1dd1167219 100644 --- a/frontend/editor/public/locales/no-NB/translation.toml +++ b/frontend/editor/public/locales/no-NB/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-posisjon" [crop.error] failed = "Kunne ikke beskjære PDF" -invalidArea = "Beskjæringsområdet går utenfor PDF-grensene" [crop.preview] title = "Valg av beskjæringsområde" diff --git a/frontend/editor/public/locales/pl-PL/translation.toml b/frontend/editor/public/locales/pl-PL/translation.toml index 566a681f9a..c327cf2483 100644 --- a/frontend/editor/public/locales/pl-PL/translation.toml +++ b/frontend/editor/public/locales/pl-PL/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozycja Y" [crop.error] failed = "Nie udało się przyciąć PDF" -invalidArea = "Obszar przycięcia wykracza poza granice PDF" [crop.preview] title = "Wybór obszaru przycięcia" diff --git a/frontend/editor/public/locales/pt-BR/translation.toml b/frontend/editor/public/locales/pt-BR/translation.toml index d89968fdfa..8455176984 100644 --- a/frontend/editor/public/locales/pt-BR/translation.toml +++ b/frontend/editor/public/locales/pt-BR/translation.toml @@ -3526,7 +3526,6 @@ label = "Posição Y" [crop.error] failed = "Falha ao recortar o PDF" -invalidArea = "A área de corte se estende além dos limites do PDF" [crop.preview] title = "Seleção da área de corte" diff --git a/frontend/editor/public/locales/pt-PT/translation.toml b/frontend/editor/public/locales/pt-PT/translation.toml index 0f9aebeff7..0b24388d75 100644 --- a/frontend/editor/public/locales/pt-PT/translation.toml +++ b/frontend/editor/public/locales/pt-PT/translation.toml @@ -3526,7 +3526,6 @@ label = "Posição Y" [crop.error] failed = "Falha ao recortar o PDF" -invalidArea = "A área de recorte excede os limites do PDF" [crop.preview] title = "Seleção da área de recorte" diff --git a/frontend/editor/public/locales/ro-RO/translation.toml b/frontend/editor/public/locales/ro-RO/translation.toml index 0f7c1555b8..2a3f4eba7d 100644 --- a/frontend/editor/public/locales/ro-RO/translation.toml +++ b/frontend/editor/public/locales/ro-RO/translation.toml @@ -3526,7 +3526,6 @@ label = "Poziția Y" [crop.error] failed = "Nu s-a putut decupa PDF-ul" -invalidArea = "Zona de decupare depășește limitele PDF-ului" [crop.preview] title = "Selecție zonă de decupare" diff --git a/frontend/editor/public/locales/ru-RU/translation.toml b/frontend/editor/public/locales/ru-RU/translation.toml index eae673f896..7d32f58a6f 100644 --- a/frontend/editor/public/locales/ru-RU/translation.toml +++ b/frontend/editor/public/locales/ru-RU/translation.toml @@ -3526,7 +3526,6 @@ label = "Положение Y" [crop.error] failed = "Не удалось обрезать PDF" -invalidArea = "Область обрезки выходит за границы PDF" [crop.preview] title = "Выбор области обрезки" diff --git a/frontend/editor/public/locales/sk-SK/translation.toml b/frontend/editor/public/locales/sk-SK/translation.toml index 1906d101d0..9383f6a866 100644 --- a/frontend/editor/public/locales/sk-SK/translation.toml +++ b/frontend/editor/public/locales/sk-SK/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozícia Y" [crop.error] failed = "Nepodarilo sa orezať PDF" -invalidArea = "Oblasť orezania presahuje hranice PDF" [crop.preview] title = "Výber oblasti orezania" diff --git a/frontend/editor/public/locales/sl-SI/translation.toml b/frontend/editor/public/locales/sl-SI/translation.toml index 120606f790..c6c9cdbd54 100644 --- a/frontend/editor/public/locales/sl-SI/translation.toml +++ b/frontend/editor/public/locales/sl-SI/translation.toml @@ -3526,7 +3526,6 @@ label = "Položaj Y" [crop.error] failed = "Obrezovanje PDF-ja ni uspelo" -invalidArea = "Območje obrezovanja presega meje PDF-ja" [crop.preview] title = "Izbira območja obrezovanja" diff --git a/frontend/editor/public/locales/sr-LATN-RS/translation.toml b/frontend/editor/public/locales/sr-LATN-RS/translation.toml index 2e4cb53c7b..c639e19155 100644 --- a/frontend/editor/public/locales/sr-LATN-RS/translation.toml +++ b/frontend/editor/public/locales/sr-LATN-RS/translation.toml @@ -3526,7 +3526,6 @@ label = "Y pozicija" [crop.error] failed = "Nije uspelo isecanje PDF-a" -invalidArea = "Oblast isečka prelazi granice PDF-a" [crop.preview] title = "Izbor oblasti za isecanje" diff --git a/frontend/editor/public/locales/sv-SE/translation.toml b/frontend/editor/public/locales/sv-SE/translation.toml index bd61dbedff..ba2b56e5e0 100644 --- a/frontend/editor/public/locales/sv-SE/translation.toml +++ b/frontend/editor/public/locales/sv-SE/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-position" [crop.error] failed = "Det gick inte att beskära PDF" -invalidArea = "Beskärningsområdet sträcker sig utanför PDF:ens gränser" [crop.preview] title = "Val av beskärningsområde" diff --git a/frontend/editor/public/locales/th-TH/translation.toml b/frontend/editor/public/locales/th-TH/translation.toml index 6b1c927d9f..6a294505f1 100644 --- a/frontend/editor/public/locales/th-TH/translation.toml +++ b/frontend/editor/public/locales/th-TH/translation.toml @@ -3526,7 +3526,6 @@ label = "ตำแหน่ง Y" [crop.error] failed = "ครอบตัด PDF ไม่สำเร็จ" -invalidArea = "พื้นที่ครอบตัดเกินขอบเขตของ PDF" [crop.preview] title = "การเลือกพื้นที่ครอบตัด" diff --git a/frontend/editor/public/locales/tr-TR/translation.toml b/frontend/editor/public/locales/tr-TR/translation.toml index 6ac3a86d07..ae580843e5 100644 --- a/frontend/editor/public/locales/tr-TR/translation.toml +++ b/frontend/editor/public/locales/tr-TR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y Konumu" [crop.error] failed = "PDF kırpılamadı" -invalidArea = "Kırpma alanı PDF sınırlarının dışına taşıyor" [crop.preview] title = "Kırpma Alanı Seçimi" diff --git a/frontend/editor/public/locales/uk-UA/translation.toml b/frontend/editor/public/locales/uk-UA/translation.toml index d51189230b..2f9e2daeef 100644 --- a/frontend/editor/public/locales/uk-UA/translation.toml +++ b/frontend/editor/public/locales/uk-UA/translation.toml @@ -3526,7 +3526,6 @@ label = "Позиція Y" [crop.error] failed = "Не вдалося обрізати PDF" -invalidArea = "Область обрізки виходить за межі PDF" [crop.preview] title = "Вибір області обрізки" diff --git a/frontend/editor/public/locales/vi-VN/translation.toml b/frontend/editor/public/locales/vi-VN/translation.toml index a574d29833..566568dade 100644 --- a/frontend/editor/public/locales/vi-VN/translation.toml +++ b/frontend/editor/public/locales/vi-VN/translation.toml @@ -3526,7 +3526,6 @@ label = "Vị trí Y" [crop.error] failed = "Không cắt được PDF" -invalidArea = "Vùng cắt vượt quá ranh giới PDF" [crop.preview] title = "Chọn vùng cắt" diff --git a/frontend/editor/public/locales/zh-BO/translation.toml b/frontend/editor/public/locales/zh-BO/translation.toml index 895fbc5c82..7a64d7453e 100644 --- a/frontend/editor/public/locales/zh-BO/translation.toml +++ b/frontend/editor/public/locales/zh-BO/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁剪 PDF 失败" -invalidArea = "裁剪区域超出 PDF 边界" [crop.preview] title = "裁剪区域选择" diff --git a/frontend/editor/public/locales/zh-CN/translation.toml b/frontend/editor/public/locales/zh-CN/translation.toml index ccf73691ff..b29e3442d4 100644 --- a/frontend/editor/public/locales/zh-CN/translation.toml +++ b/frontend/editor/public/locales/zh-CN/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁剪 PDF 失败" -invalidArea = "裁剪区域超出 PDF 边界" [crop.preview] title = "裁剪区域选择" diff --git a/frontend/editor/public/locales/zh-TW/translation.toml b/frontend/editor/public/locales/zh-TW/translation.toml index 269d9c9d59..efb8adb8b9 100644 --- a/frontend/editor/public/locales/zh-TW/translation.toml +++ b/frontend/editor/public/locales/zh-TW/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁切 PDF 失敗" -invalidArea = "裁切區域超出 PDF 邊界" [crop.preview] title = "裁切區域選擇" diff --git a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx index 82516059b0..0229613959 100644 --- a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx +++ b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx @@ -1,13 +1,5 @@ import { useState, useEffect } from "react"; -import { - Stack, - Text, - Box, - Group, - Center, - Alert, - Checkbox, -} from "@mantine/core"; +import { Stack, Text, Box, Group, Center, Checkbox } from "@mantine/core"; import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; @@ -161,7 +153,6 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { ); } - const isCropValid = parameters.isCropAreaValid(pdfBounds); const isFullCrop = parameters.isFullPDFCrop(pdfBounds); return ( @@ -239,18 +230,6 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { showAutomationInfo={false} /> )} - - {/* Validation Alert - Only show when autoCrop is false */} - {!parameters.parameters.autoCrop && !isCropValid && ( - - - {t( - "crop.error.invalidArea", - "Crop area extends beyond PDF boundaries", - )} - - - )} ); }; diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts index cc37421cdf..62e7ede14b 100644 --- a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts +++ b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts @@ -8,6 +8,7 @@ import { Rectangle, PDFBounds, constrainCropAreaToPDF, + createDefaultCropArea, createFullPDFCropArea, roundCropArea, isRectangle, @@ -29,6 +30,8 @@ export type CropParametersHook = BaseParametersHook & { setCropArea: (cropArea: Rectangle, pdfBounds?: PDFBounds) => void; /** Get current crop area as CropArea object */ getCropArea: () => Rectangle; + /** Reset to default inset crop area inside PDF bounds */ + resetToDefaultCropArea: (pdfBounds: PDFBounds) => void; /** Reset to full PDF dimensions */ resetToFullPDF: (pdfBounds: PDFBounds) => void; /** Check if current crop area is valid for the PDF */ @@ -76,6 +79,15 @@ export const useCropParameters = (): CropParametersHook => { [baseHook], ); + // Reset to default crop area inside PDF bounds (10% inset) + const resetToDefaultCropArea = useCallback( + (pdfBounds: PDFBounds) => { + const defaultCropArea = createDefaultCropArea(pdfBounds); + setCropArea(defaultCropArea); + }, + [setCropArea], + ); + // Reset to cover entire PDF const resetToFullPDF = useCallback( (pdfBounds: PDFBounds) => { @@ -85,31 +97,11 @@ export const useCropParameters = (): CropParametersHook => { [setCropArea], ); - // Check if current crop area is valid for the given PDF bounds + // Check if current crop area is valid (dimensions must be non-zero; out-of-bounds coordinates clamp automatically) const isCropAreaValid = useCallback( - (pdfBounds?: PDFBounds): boolean => { + (_pdfBounds?: PDFBounds): boolean => { const cropArea = getCropArea(); - - // Basic validation - if ( - cropArea.x < 0 || - cropArea.y < 0 || - cropArea.width <= 0 || - cropArea.height <= 0 - ) { - return false; - } - - // PDF bounds validation if provided - if (pdfBounds) { - const tolerance = 0.01; // Small tolerance for floating point precision - return ( - cropArea.x + cropArea.width <= pdfBounds.actualWidth + tolerance && - cropArea.y + cropArea.height <= pdfBounds.actualHeight + tolerance - ); - } - - return true; + return cropArea.width > 0 && cropArea.height > 0; }, [getCropArea], ); @@ -174,6 +166,7 @@ export const useCropParameters = (): CropParametersHook => { validateParameters: () => validateParameters(), setCropArea, getCropArea, + resetToDefaultCropArea, resetToFullPDF, isCropAreaValid, isFullPDFCrop, diff --git a/frontend/editor/src/core/utils/cropCoordinates.ts b/frontend/editor/src/core/utils/cropCoordinates.ts index 5a275c85ea..4b3bf1c600 100644 --- a/frontend/editor/src/core/utils/cropCoordinates.ts +++ b/frontend/editor/src/core/utils/cropCoordinates.ts @@ -204,7 +204,21 @@ export const isPointInThumbnail = ( }; /** - * Create a default crop area that covers the entire PDF + * Create a default crop area inside PDF bounds (10% inset from each edge, centered) + */ +export const createDefaultCropArea = (pdfBounds: PDFBounds): Rectangle => { + const insetX = pdfBounds.actualWidth * 0.1; + const insetY = pdfBounds.actualHeight * 0.1; + return { + x: Math.round(insetX * 10) / 10, + y: Math.round(insetY * 10) / 10, + width: Math.round((pdfBounds.actualWidth - insetX * 2) * 10) / 10, + height: Math.round((pdfBounds.actualHeight - insetY * 2) * 10) / 10, + }; +}; + +/** + * Create a crop area that covers the entire PDF */ export const createFullPDFCropArea = (pdfBounds: PDFBounds): Rectangle => { return { From 34694c6f5ec11b286e2c557e7eddf1813a1764f3 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:19:26 +0200 Subject: [PATCH 34/57] refactor(api): standardize syntax and simplify type declarations across security, workflow, and controller modules (#7127) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../SPDF/pdf/parser/TabulaTableParser.java | 2 +- .../StringToMapPropertyEditor.java | 3 +- .../controller/api/EditTextController.java | 2 +- .../SPDF/controller/api/UIDataController.java | 3 +- .../api/form/FormPayloadParser.java | 9 +- .../api/misc/AddCommentsController.java | 4 +- .../proprietary/audit/AuditLevel.java | 2 +- .../cluster/valkey/ValkeyJobStore.java | 6 +- .../config/AuditConfigurationProperties.java | 2 +- .../controller/api/UsageRestController.java | 2 +- .../model/UserLicenseSettings.java | 3 +- .../security/CustomLogoutSuccessHandler.java | 32 +++--- .../configuration/SecurityConfiguration.java | 68 ++++++------- .../controller/api/AuthController.java | 23 ++--- .../controller/api/UserController.java | 16 +-- .../proprietary/security/model/Authority.java | 3 +- .../security/model/InviteToken.java | 3 +- ...tomOAuth2AuthenticationFailureHandler.java | 99 ++++++++++--------- ...mSaml2ResponseAuthenticationConverter.java | 7 +- .../service/CustomOAuth2UserService.java | 7 +- .../service/KeyPersistenceService.java | 4 +- .../security/service/UserService.java | 16 +-- .../session/SessionPersistentRegistry.java | 30 +++--- .../proprietary/storage/model/FileShare.java | 3 +- .../storage/model/FileShareAccess.java | 3 +- .../storage/model/StorageCleanupEntry.java | 3 +- .../proprietary/storage/model/StoredFile.java | 3 +- .../storage/model/StoredFileBlob.java | 3 +- .../proprietary/web/AuditWebFilter.java | 3 +- .../controller/SigningSessionController.java | 5 +- .../WorkflowParticipantController.java | 3 +- .../workflow/model/WorkflowParticipant.java | 3 +- .../workflow/model/WorkflowSession.java | 3 +- .../service/SigningFinalizationService.java | 14 +-- .../service/WorkflowSessionService.java | 31 +++--- 35 files changed, 229 insertions(+), 194 deletions(-) diff --git a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java index b85ddbb08e..d3c516d1fe 100644 --- a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java +++ b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java @@ -237,7 +237,7 @@ public class TabulaTableParser implements TableParser { score -= 0.3f; } - return Math.max(0f, Math.min(1f, score)); + return Math.clamp(score, 0f, 1f); } private Bounds tableBounds(Table table) { diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java index 63476d5568..7ab1013a8a 100644 --- a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java +++ b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java @@ -15,7 +15,8 @@ public class StringToMapPropertyEditor extends PropertyEditorSupport { @Override public void setAsText(String text) throws IllegalArgumentException { try { - TypeReference> typeRef = new TypeReference<>() {}; + TypeReference> typeRef = + new TypeReference>() {}; Map map = objectMapper.readValue(text, typeRef); setValue(map); } catch (Exception e) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java index 17d1d7d8a7..b76f52144a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java @@ -237,7 +237,7 @@ public class EditTextController { Matcher matcher = edit.pattern().matcher(joined); List spans = new ArrayList<>(); - StringBuffer interpolation = new StringBuffer(); + StringBuilder interpolation = new StringBuilder(); int previousAppendPosition = 0; while (matcher.find()) { if (matcher.start() == matcher.end()) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index 7f93fb3d64..b6cef0b2d6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -95,7 +95,8 @@ public class UIDataController { try (InputStream is = resource.getInputStream()) { Map> licenseData = - objectMapper.readValue(is, new TypeReference<>() {}); + objectMapper.readValue( + is, new TypeReference>>() {}); data.setDependencies(licenseData.get("dependencies")); } catch (IOException e) { log.error("Failed to load licenses data", e); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index f48f419a6d..5236706f74 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -25,12 +25,15 @@ final class FormPayloadParser { private static final String KEY_VALUE = "value"; private static final String KEY_DEFAULT_VALUE = "defaultValue"; - private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + private static final TypeReference> MAP_TYPE = + new TypeReference>() {}; private static final TypeReference> - MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + MODIFY_FIELD_LIST_TYPE = + new TypeReference>() {}; private static final TypeReference> NEW_FIELD_LIST_TYPE = new TypeReference<>() {}; - private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> STRING_LIST_TYPE = + new TypeReference>() {}; private FormPayloadParser() {} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java index dc2dd22863..09b1d282e9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java @@ -96,7 +96,9 @@ public class AddCommentsController { List dtos; try { - dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {}); + dtos = + objectMapper.readValue( + commentsJson, new TypeReference>() {}); } catch (JacksonException e) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java index 59adc2af80..c2b0e53eb7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java @@ -59,7 +59,7 @@ public enum AuditLevel { */ public static AuditLevel fromInt(int level) { // Ensure level is within valid bounds - int boundedLevel = Math.min(Math.max(level, 0), 3); + int boundedLevel = Math.clamp(level, 0, 3); for (AuditLevel auditLevel : values()) { if (auditLevel.level == boundedLevel) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java index 750abea4fc..f03992ed4d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java @@ -44,8 +44,10 @@ public class ValkeyJobStore implements JobStore { private static final String FILE_INDEX_PREFIX = "stirling:file2job:"; private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final TypeReference> LIST_STRING = new TypeReference<>() {}; - private static final TypeReference> MAP_STRING = new TypeReference<>() {}; + private static final TypeReference> LIST_STRING = + new TypeReference>() {}; + private static final TypeReference> MAP_STRING = + new TypeReference>() {}; private final StringRedisTemplate template; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java index 366d91b11c..ac6c25ac5e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java @@ -35,7 +35,7 @@ public class AuditConfigurationProperties { // Ensure level is within valid bounds (0-3) int configLevel = auditConfig.getLevel(); - this.level = Math.min(Math.max(configLevel, 0), 3); + this.level = Math.clamp(configLevel, 0, 3); // Retention days (0 means infinite) this.retentionDays = auditConfig.getRetentionDays(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java index 1230d928cc..65bf8240a4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java @@ -48,7 +48,7 @@ public class UsageRestController { @RequestParam(value = "dataType", defaultValue = "all") String dataType, @RequestParam(value = "days", defaultValue = "30") Integer days) { - int lookbackDays = Math.max(1, Math.min(days, 365)); + int lookbackDays = Math.clamp(days, 1, 365); // Get audit events filtered by type List events = getEventsByDataType(dataType, lookbackDays); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java index bb7f52142a..1683ad9134 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.model; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.*; @@ -19,7 +20,7 @@ import lombok.*; @ToString public class UserLicenseSettings implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; public static final Long SINGLETON_ID = 1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 4bfef06c9b..d83b684166 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -70,21 +70,23 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { if (!response.isCommitted()) { if (authentication != null) { - if (authentication instanceof Saml2Authentication samlAuthentication) { - // Handle SAML2 logout redirection - getRedirect_saml2(request, response, samlAuthentication); - } else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) { - // Handle OAuth2 logout redirection - getRedirect_oauth2(request, response, oAuthToken); - } else if (authentication instanceof UsernamePasswordAuthenticationToken) { - // Handle Username/Password logout - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); - } else { - // Handle unknown authentication types - log.error( - "Authentication class unknown: {}", - authentication.getClass().getSimpleName()); - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + switch (authentication) { + case Saml2Authentication samlAuthentication -> + // Handle SAML2 logout redirection + getRedirect_saml2(request, response, samlAuthentication); + case OAuth2AuthenticationToken oAuthToken -> + // Handle OAuth2 logout redirection + getRedirect_oauth2(request, response, oAuthToken); + case UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken -> + // Handle Username/Password logout + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + default -> { + // Handle unknown authentication types + log.error( + "Authentication class unknown: {}", + authentication.getClass().getSimpleName()); + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + } } } else { if (jwtService != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 0f0c7315d9..7c5b412d33 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -392,40 +392,40 @@ public class SecurityConfiguration { // Handle OAUTH2 Logins if (securityProperties.isOauth2Active()) { http.oauth2Login( - oauth2 -> { - oauth2.loginPage("/login") - .authorizationEndpoint( - authorizationEndpoint -> { - if (clientRegistrationRepository != null) { - authorizationEndpoint - .authorizationRequestResolver( - new TauriAuthorizationRequestResolver( - clientRegistrationRepository)); - } - }) - .successHandler( - new CustomOAuth2AuthenticationSuccessHandler( - loginAttemptService, - securityProperties.getOauth2(), - userService, - jwtService, - licenseSettingsService, - applicationProperties)) - .failureHandler(new CustomOAuth2AuthenticationFailureHandler()) - // Add existing Authorities from the database - .userInfoEndpoint( - userInfoEndpoint -> - userInfoEndpoint - .oidcUserService( - new CustomOAuth2UserService( - securityProperties - .getOauth2(), - userService, - loginAttemptService)) - .userAuthoritiesMapper( - oAuth2userAuthoritiesMapper)) - .permitAll(); - }); + oauth2 -> + oauth2.loginPage("/login") + .authorizationEndpoint( + authorizationEndpoint -> { + if (clientRegistrationRepository != null) { + authorizationEndpoint + .authorizationRequestResolver( + new TauriAuthorizationRequestResolver( + clientRegistrationRepository)); + } + }) + .successHandler( + new CustomOAuth2AuthenticationSuccessHandler( + loginAttemptService, + securityProperties.getOauth2(), + userService, + jwtService, + licenseSettingsService, + applicationProperties)) + .failureHandler( + new CustomOAuth2AuthenticationFailureHandler()) + // Add existing Authorities from the database + .userInfoEndpoint( + userInfoEndpoint -> + userInfoEndpoint + .oidcUserService( + new CustomOAuth2UserService( + securityProperties + .getOauth2(), + userService, + loginAttemptService)) + .userAuthoritiesMapper( + oAuth2userAuthoritiesMapper)) + .permitAll()); } // Handle SAML if (securityProperties.isSaml2Active() && runningProOrHigher) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 86a1c5fe0c..6661c86395 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -703,17 +703,18 @@ public class AuthController { } private long extractEpochMillis(Object claimValue) { - if (claimValue == null) { - return -1L; - } - - if (claimValue instanceof java.util.Date date) { - return date.getTime(); - } - - if (claimValue instanceof Number number) { - long epochSeconds = number.longValue(); - return epochSeconds * 1000L; + switch (claimValue) { + case null -> { + return -1L; + } + case java.util.Date date -> { + return date.getTime(); + } + case Number number -> { + long epochSeconds = number.longValue(); + return epochSeconds * 1000L; + } + default -> {} } return -1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index fdacda72b2..2385eb011f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -760,14 +760,14 @@ public class UserController { for (Object principal : principals) { List sessionsInformation = sessionRegistry.getAllSessions(principal, false); - if (principal instanceof UserDetails detailsUser) { - userNameP = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - userNameP = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - userNameP = saml2User.name(); - } else if (principal instanceof String stringUser) { - userNameP = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> userNameP = detailsUser.getUsername(); + case OAuth2User oAuth2User -> userNameP = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> + userNameP = saml2User.name(); + case String stringUser -> userNameP = stringUser; + default -> {} } if (userNameP.equalsIgnoreCase(username)) { for (SessionInformation sessionInfo : sessionsInformation) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java index 659f7691bd..4ffea54740 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.model; +import java.io.Serial; import java.io.Serializable; import org.springframework.security.core.GrantedAuthority; @@ -28,7 +29,7 @@ import lombok.Setter; @Setter public class Authority implements GrantedAuthority, Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java index 975220bf48..062cce058f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -18,7 +19,7 @@ import lombok.Setter; @Setter public class InviteToken implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java index 784a9f0a2f..670b08c53f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java @@ -36,57 +36,62 @@ public class CustomOAuth2AuthenticationFailureHandler AuthenticationException exception) throws IOException, ServletException { - if (exception instanceof BadCredentialsException) { - log.error("BadCredentialsException", exception); - getRedirectStrategy().sendRedirect(request, response, "/login?error=badCredentials"); - return; - } - if (exception instanceof DisabledException) { - log.error("User is deactivated: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true"); - return; - } - if (exception instanceof LockedException) { - log.error("Account locked: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); - return; - } - if (exception instanceof OAuth2AuthenticationException oAuth2Exception) { - OAuth2Error error = oAuth2Exception.getError(); - - String errorCode = error.getErrorCode(); - - if ("Password must not be null".equals(error.getErrorCode())) { - errorCode = "userAlreadyExistsWeb"; + switch (exception) { + case BadCredentialsException badCredentialsException -> { + log.error("BadCredentialsException", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/login?error=badCredentials"); + return; } + case DisabledException disabledException -> { + log.error("User is deactivated: ", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/logout?userIsDisabled=true"); + return; + } + case LockedException lockedException -> { + log.error("Account locked: ", exception); + getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); + return; + } + case OAuth2AuthenticationException oAuth2Exception -> { + OAuth2Error error = oAuth2Exception.getError(); - log.error( - "OAuth2 Authentication error: {}", - errorCode != null ? errorCode : exception.getMessage(), - exception); - String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; - clearRedirectCookie(response); - boolean tauriState = TauriOAuthUtils.isTauriState(request); - String redirectUrl; - if (tauriState) { - String basePath = - TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); - redirectUrl = basePath; - String stateParam = request.getParameter("state"); - if (stateParam != null && !stateParam.isBlank()) { - redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); - // Extract and pass nonce for CSRF validation - String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); - if (nonce != null) { - redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); - } + String errorCode = error.getErrorCode(); + + if ("Password must not be null".equals(error.getErrorCode())) { + errorCode = "userAlreadyExistsWeb"; } - redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); - } else { - redirectUrl = buildFailureRedirectUrl(request, errorValue); + + log.error( + "OAuth2 Authentication error: {}", + errorCode != null ? errorCode : exception.getMessage(), + exception); + String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; + clearRedirectCookie(response); + boolean tauriState = TauriOAuthUtils.isTauriState(request); + String redirectUrl; + if (tauriState) { + String basePath = + TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); + redirectUrl = basePath; + String stateParam = request.getParameter("state"); + if (stateParam != null && !stateParam.isBlank()) { + redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); + // Extract and pass nonce for CSRF validation + String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); + if (nonce != null) { + redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); + } + } + redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); + } else { + redirectUrl = buildFailureRedirectUrl(request, errorValue); + } + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + return; } - getRedirectStrategy().sendRedirect(request, response, redirectUrl); - return; + default -> {} } log.error("Unhandled authentication exception", exception); super.onAuthenticationFailure(request, response, exception); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java index b2ce4adb68..96dcdecd03 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java @@ -61,7 +61,12 @@ public class CustomSaml2ResponseAuthenticationConverter @Override public Saml2Authentication convert(ResponseToken responseToken) { - Assertion assertion = responseToken.getResponse().getAssertions().getFirst(); + List assertions = responseToken.getResponse().getAssertions(); + if (assertions == null || assertions.isEmpty()) { + log.error("SAML response contains no assertions"); + return null; + } + Assertion assertion = assertions.getFirst(); Map> attributes = extractAttributes(assertion); // Debug log with actual values diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java index c1057c7e36..b8054c89d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java @@ -213,8 +213,11 @@ public class CustomOAuth2UserService implements OAuth2UserService {} + case UserDetails detailsUser -> usernameP = detailsUser.getUsername(); + case OAuth2User oAuth2User -> usernameP = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> + usernameP = saml2User.name(); + case String stringUser -> usernameP = stringUser; + default -> {} } if (usernameP.equalsIgnoreCase(username)) { sessionRegistry.expireSession(sessionsInformation.getSessionId()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java index e615416e59..1f3a4e84ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java @@ -47,14 +47,13 @@ public class SessionPersistentRegistry implements SessionRegistry { List sessionInformations = new ArrayList<>(); String principalName = null; - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> principalName = detailsUser.getUsername(); + case OAuth2User oAuth2User -> principalName = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name(); + case String stringUser -> principalName = stringUser; + default -> {} } if (principalName != null) { @@ -78,14 +77,13 @@ public class SessionPersistentRegistry implements SessionRegistry { public void registerNewSession(String sessionId, Object principal) { String principalName = null; - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> principalName = detailsUser.getUsername(); + case OAuth2User oAuth2User -> principalName = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name(); + case String stringUser -> principalName = stringUser; + default -> {} } if (principalName != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java index 1b0fd86f78..6ddd0c8a86 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -46,7 +47,7 @@ import stirling.software.proprietary.security.model.User; @Setter public class FileShare implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java index 49f75a4a4c..cb2f5d5209 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -39,7 +40,7 @@ import stirling.software.proprietary.security.model.User; @Setter public class FileShareAccess implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java index 3158f4c041..68afe20173 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -24,7 +25,7 @@ import lombok.Setter; @Setter public class StorageCleanupEntry implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java index db80bd1e91..1b098672b6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.HashSet; @@ -45,7 +46,7 @@ import stirling.software.proprietary.workflow.model.WorkflowSession; @Setter public class StoredFile implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java index 52ef1107fc..4abcffd3e6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.Column; @@ -19,7 +20,7 @@ import lombok.Setter; @Setter public class StoredFileBlob implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @Column(name = "storage_key", nullable = false, length = 128) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java index b6f5b47f3b..70847a0702 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java @@ -7,6 +7,7 @@ import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -64,7 +65,7 @@ public class AuditWebFilter extends OncePerRequestFilter { if (auth != null && auth.getAuthorities() != null) { String roles = auth.getAuthorities().stream() - .map(a -> a.getAuthority()) + .map(GrantedAuthority::getAuthority) .reduce((a, b) -> a + "," + b) .orElse(""); MDC.put("userRoles", roles); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index 73867776e9..b224a09841 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -37,6 +37,7 @@ import stirling.software.proprietary.workflow.dto.CertificateInfo; import stirling.software.proprietary.workflow.dto.CertificateValidationResponse; import stirling.software.proprietary.workflow.dto.ParticipantRequest; import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator; import stirling.software.proprietary.workflow.service.SigningFinalizationService; @@ -259,7 +260,9 @@ public class SigningSessionController { + "database until manual cleanup.", sessionId, session.getParticipants() != null - ? session.getParticipants().stream().map(p -> p.getEmail()).toList() + ? session.getParticipants().stream() + .map(WorkflowParticipant::getEmail) + .toList() : "unknown", e); throw new ResponseStatusException( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java index 5f903e4b56..4df0c93e1d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java @@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.http.ContentDisposition; @@ -429,7 +430,7 @@ public class WorkflowParticipantController { java.util.List> wetSigs = objectMapper.readValue( request.getWetSignaturesData(), - new TypeReference>>() {}); + new TypeReference>>() {}); if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Too many wet signatures submitted"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java index 2e6091b963..b119565c13 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; @@ -51,7 +52,7 @@ import stirling.software.proprietary.storage.model.ShareAccessRole; @Setter public class WorkflowParticipant implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java index 3fc6b53b44..7df5af710f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; @@ -53,7 +54,7 @@ import stirling.software.proprietary.storage.model.StoredFile; @Setter public class WorkflowSession implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java index e5e122df45..3fce8c69dd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java @@ -217,16 +217,13 @@ public class SigningFinalizationService { wetSignatures.size(), session.getSessionId()); - PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes)); - try { + try (PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes))) { for (WetSignatureMetadata wetSig : wetSignatures) { applyWetSignatureToPage(document, wetSig); } ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); return baos.toByteArray(); - } finally { - document.close(); } } @@ -242,11 +239,10 @@ public class SigningFinalizationService { } PDPage page = document.getPage(pageIndex); - PDPageContentStream contentStream = - new PDPageContentStream( - document, page, PDPageContentStream.AppendMode.APPEND, true, true); - try { + try (PDPageContentStream contentStream = + new PDPageContentStream( + document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { // Use WetSignatureMetadata.extractBase64Data() to strip data URL prefix String base64Data = wetSig.extractBase64Data(); if (base64Data == null || base64Data.isBlank()) { @@ -279,8 +275,6 @@ public class SigningFinalizationService { pdfY, width, height); - } finally { - contentStream.close(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index 4c60c60df2..a2db5deb5a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -954,21 +954,22 @@ public class WorkflowSessionService { Object pemObject = pemParser.readObject(); JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); PrivateKeyInfo keyInfo; - if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo encrypted) { - InputDecryptorProvider decryptor = - new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); - keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); - } else if (pemObject instanceof PEMEncryptedKeyPair encryptedKeyPair) { - PEMDecryptorProvider decryptor = - new JcePEMDecryptorProviderBuilder().build(password); - keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); - } else if (pemObject instanceof PEMKeyPair keyPair) { - keyInfo = keyPair.getPrivateKeyInfo(); - } else if (pemObject instanceof PrivateKeyInfo info) { - keyInfo = info; - } else { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); + switch (pemObject) { + case PKCS8EncryptedPrivateKeyInfo encrypted -> { + InputDecryptorProvider decryptor = + new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); + keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); + } + case PEMEncryptedKeyPair encryptedKeyPair -> { + PEMDecryptorProvider decryptor = + new JcePEMDecryptorProviderBuilder().build(password); + keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); + } + case PEMKeyPair keyPair -> keyInfo = keyPair.getPrivateKeyInfo(); + case PrivateKeyInfo info -> keyInfo = info; + case null, default -> + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); } return converter.getPrivateKey(keyInfo); } From 1bb6961414f96ee00031c4de77359f78761b9dd1 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:12:56 +0000 Subject: [PATCH 35/57] Update Frontend 3rd Party Licenses (#7738) Auto-generated by stirlingbot[bot] This PR updates the frontend license report based on changes to package.json dependencies. Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- frontend/editor/src/assets/3rdPartyLicenses.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/editor/src/assets/3rdPartyLicenses.json b/frontend/editor/src/assets/3rdPartyLicenses.json index 1cd5f1f8bc..b2ec99d368 100644 --- a/frontend/editor/src/assets/3rdPartyLicenses.json +++ b/frontend/editor/src/assets/3rdPartyLicenses.json @@ -227,14 +227,14 @@ { "moduleName": "@mui/icons-material", "moduleUrl": "https://github.com/mui/material-ui", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.1", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "@mui/material", "moduleUrl": "https://github.com/mui/material-ui", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.1", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -297,7 +297,7 @@ { "moduleName": "@tanstack/react-virtual", "moduleUrl": "https://github.com/TanStack/virtual", - "moduleVersion": "3.13.23", + "moduleVersion": "3.14.10", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, From e539eb1ab12f0e4616169175709e979723152adb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:13:31 +0000 Subject: [PATCH 36/57] build(deps): bump the ubuntu group across 2 directories with 1 update (#7698) > [!WARNING] > Cooldown could not be applied because no publication date was available from the registry. > Bumps the ubuntu group with 1 update in the /docker/base directory: ubuntu. Bumps the ubuntu group with 1 update in the /docker/unoserver directory: ubuntu. Updates `ubuntu` from `561618e` to `33ceb71` Updates `ubuntu` from `561618e` to `33ceb71` Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/base/Dockerfile | 8 ++++---- docker/unoserver/Dockerfile | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 06e1b6e601..9205814a31 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -5,7 +5,7 @@ ARG TARGETPLATFORM # Stage 1: Build and strip Calibre -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS calibre-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS calibre-build ARG TARGETPLATFORM ARG CALIBRE_VERSION=9.13.0 ARG CALIBRE_STRIP_WEBENGINE=false @@ -274,7 +274,7 @@ RUN if [ "${CALIBRE_STRIP_WEBENGINE}" = "true" ]; then \ # Stage 2: Build Ghostscript from source -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS gs-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS gs-build ARG TARGETPLATFORM ARG GS_VERSION=10.07.1 @@ -298,7 +298,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ # Stage 3: Build PDF Tools (QPDF and ImageMagick 7) -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS pdf-tools-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS pdf-tools-build ARG TARGETPLATFORM ARG QPDF_VERSION=12.4.0 ARG IM_VERSION=7.1.2-29 @@ -343,7 +343,7 @@ RUN mkdir -p /magick-export/usr/bin \ # Stage 4: Build Python venv -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS python-venv-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS python-venv-build ARG TARGETPLATFORM ARG UNOSERVER_VERSION=3.7 diff --git a/docker/unoserver/Dockerfile b/docker/unoserver/Dockerfile index da5ba27371..3667c4bf5a 100644 --- a/docker/unoserver/Dockerfile +++ b/docker/unoserver/Dockerfile @@ -1,7 +1,7 @@ # Standalone unoserver image for Stirling-PDF remote UNO mode. # Pinned to unoserver 3.7 to match Stirling-PDF's client (avoids wire mismatch). -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 ARG UNOSERVER_VERSION=3.7 # ~120 MB of CJK fonts — opt-in. From 1f4cc2612df838769eb20893a6f1171a67108e48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:12:18 +0000 Subject: [PATCH 37/57] build(deps): bump the eclipse-temurin group across 3 directories with 1 update (#7740) > [!WARNING] > Cooldown could not be applied because no publication date was available from the registry. > Bumps the eclipse-temurin group with 1 update in the /docker/backend directory: eclipse-temurin. Bumps the eclipse-temurin group with 1 update in the /docker/base directory: eclipse-temurin. Bumps the eclipse-temurin group with 1 update in the /docker/embedded directory: eclipse-temurin. Updates `eclipse-temurin` from `fbcf915` to `b4c93a5` Updates `eclipse-temurin` from `fbcf915` to `b4c93a5` Updates `eclipse-temurin` from `fbcf915` to `b4c93a5` Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/backend/Dockerfile | 2 +- docker/base/Dockerfile | 2 +- docker/embedded/Dockerfile | 2 +- docker/embedded/Dockerfile.fat | 2 +- docker/embedded/Dockerfile.ultra-lite | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 39bb60bdef..ec7d7a7304 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -45,7 +45,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 9205814a31..0c73738214 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -368,7 +368,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ # Final runtime image - the actual base image -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS runtime +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS runtime SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index cac30d88b1..80e163dd9a 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -61,7 +61,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 42c9a16af2..38c8bcc8f1 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -61,7 +61,7 @@ RUN --mount=type=cache,id=stirling-pdf-npm-cache,target=/root/.npm,sharing=locke --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index f1389c1600..104bfd8937 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -62,7 +62,7 @@ RUN --mount=type=cache,id=stirling-pdf-npm-cache,target=/root/.npm,sharing=locke # Stage 2: Runtime image # glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked. -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ From 3235645203cf723c7770274f2ffdfc54a48f5d0f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:13:30 +0000 Subject: [PATCH 38/57] build(deps): bump step-security/harden-runner from 2.20.0 to 2.21.0 (#7746) Bumps [step-security/harden-runner](https://github.com/step-security/harden-runner) from 2.20.0 to 2.21.0.
Release notes

Sourced from step-security/harden-runner's releases.

v2.21.0

What's Changed

  • Support for denied endpoints in block mode. This is included in the enterprise tier. Customers can deny outbound calls, for example, to public package registries.
  • Improved Support for AWS CodeBuild GitHub Actions Runners.
  • Bug fixes.

Full Changelog: https://github.com/step-security/harden-runner/compare/v2.20.1...v2.21.0

v2.20.1

What's Changed

  • AWS CodeBuild-hosted runner support
  • Implicitly allow single-labeled (internal) domains in block-mode

Full Changelog: https://github.com/step-security/harden-runner/compare/v2.20.0...v2.20.1

Commits
  • 05e3151 Merge pull request #684 from step-security/rc-42
  • 0f37afa fix: ignore denied-endpoints on non-enterprise tier
  • 93b58ee fix: resolve cache host read-first and never downgrade egress policy
  • e7399dd fix: align deny-list mode detection with agent and log when both endpoint inp...
  • c16689f test: add denied_endpoints to Configuration fixtures and cover deny-list merge
  • 40b99cf Merge pull request #682 from rohan-stepsecurity/rp/feat/codebuild-self-v2
  • fedec02 Merge branch 'rc-42' into rp/feat/codebuild-self-v2
  • 5361fb1 feat: add build artifacts
  • 286474f feat: Support Bravo agent install on CodeBuild runners
  • 051ec05 Merge pull request #683 from h0x0er/jatin/deny-list
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=step-security/harden-runner&package-manager=github_actions&previous-version=2.20.0&new-version=2.21.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/Saas-Dev-Deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Saas-Dev-Deploy.yml b/.github/workflows/Saas-Dev-Deploy.yml index 733b92bca2..dba916b243 100644 --- a/.github/workflows/Saas-Dev-Deploy.yml +++ b/.github/workflows/Saas-Dev-Deploy.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit From f6124223e433f3288245ff6c8c461f6d8b12709e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:14:20 +0000 Subject: [PATCH 39/57] build(deps): bump github/codeql-action/upload-sarif from 4.37.7 to 4.37.8 (#7750) Bumps [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) from 4.37.7 to 4.37.8.
Release notes

Sourced from github/codeql-action/upload-sarif's releases.

v4.37.8

No user facing changes.

Changelog

Sourced from github/codeql-action/upload-sarif's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.9 - 26 Aug 2026

  • Update default CodeQL bundle version to 2.26.4. #4106

4.37.8 - 21 Aug 2026

No user facing changes.

4.37.7 - 13 Aug 2026

  • Update default CodeQL bundle version to 2.26.3. #4085

4.37.6 - 04 Aug 2026

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

4.37.5 - 03 Aug 2026

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995

... (truncated)

Commits
  • db488dd Merge pull request #4102 from github/update-v4.37.8-9ee088e13
  • 1845f5b Update changelog for v4.37.8
  • 9ee088e Merge pull request #4080 from github/henrymercer/studious-giggle
  • 1aef003 Address review feedback on overlay disk flags
  • 508b83b Merge main into overlay minimum disk feature branch
  • d97b342 Merge pull request #4098 from github/mbg/permission-error-as-configuration-error
  • 47fa622 Make EACCES a ConfigurationError
  • 45693cc Refactor ENOSPC check into isDiskConfigurationError function
  • c2fd8f5 Merge pull request #4081 from github/mario-campos/version-cache-to-disk
  • c56f48e Log unexpected conditions during caching CLI output
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action/upload-sarif&package-manager=github_actions&previous-version=4.37.7&new-version=4.37.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index dbda06764b..8c7169e856 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -75,6 +75,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: sarif_file: results.sarif From b92f88361ec8837d82f2a24af036cc72d0e20ed2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:14:30 +0000 Subject: [PATCH 40/57] build(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#7711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.2.0 to 4.3.0.
Release notes

Sourced from docker/setup-buildx-action's releases.

v4.3.0

Full Changelog: https://github.com/docker/setup-buildx-action/compare/v4.2.0...v4.3.0

Commits
  • 37fe631 Merge pull request #595 from docker/dependabot/npm_and_yarn/docker/actions-to...
  • b5c4f91 [dependabot skip] chore: update generated content
  • 3e93b63 build(deps): bump @​docker/actions-toolkit from 0.92.0 to 0.95.0
  • e527031 Merge pull request #600 from docker/dependabot/npm_and_yarn/brace-expansion-1...
  • c68814b [dependabot skip] chore: update generated content
  • 3f891b0 build(deps): bump brace-expansion from 1.1.13 to 1.1.18
  • 787db26 Merge pull request #585 from docker/dependabot/npm_and_yarn/js-yaml-5.2.1
  • f779368 [dependabot skip] chore: update generated content
  • 7d5e604 build(deps): bump js-yaml from 5.2.0 to 5.3.0
  • 292c2fb Merge pull request #590 from docker/dependabot/github_actions/actions/setup-n...
  • Additional commits viewable in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/PR-Auto-Deploy-V2.yml | 2 +- .github/workflows/PR-Demo-Comment-with-react.yml | 2 +- .github/workflows/Saas-Dev-Deploy.yml | 2 +- .github/workflows/docker-compose-tests.yml | 2 +- .github/workflows/push-docker-base.yml | 2 +- .github/workflows/push-docker.yml | 2 +- .github/workflows/test-build-docker.yml | 4 ++-- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 6420712640..c27fb6b5f2 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -182,7 +182,7 @@ jobs: fetch-depth: 0 # Fetch full history for commit hash detection - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Get version number id: versionNumber diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 111dba441f..b43020ceb0 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -222,7 +222,7 @@ jobs: STIRLING_PDF_DESKTOP_UI: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/Saas-Dev-Deploy.yml b/.github/workflows/Saas-Dev-Deploy.yml index dba916b243..84aa57d5f2 100644 --- a/.github/workflows/Saas-Dev-Deploy.yml +++ b/.github/workflows/Saas-Dev-Deploy.yml @@ -45,7 +45,7 @@ jobs: fetch-depth: 0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index cf5887a205..b37babfdf6 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -57,7 +57,7 @@ jobs: # runtime token isn't exposed) since the docker driver can't use it. - name: Set up Docker Buildx if: inputs.docker-base-changed != 'true' - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 # Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend. - name: Expose GitHub runtime for Buildx cache diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml index 9da49ad7ae..6bfae2b300 100644 --- a/.github/workflows/push-docker-base.yml +++ b/.github/workflows/push-docker-base.yml @@ -69,7 +69,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 844a77b489..8e8b26be91 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -85,7 +85,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 4717c9c387..e38fcb4cd6 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -142,7 +142,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Set base image and platform for this build id: build-params @@ -229,7 +229,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Build docker/unoserver/Dockerfile uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 From b1e857fd01e8e3aca55f8903ac07d9502bd25c2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:13:47 +0100 Subject: [PATCH 41/57] build(deps): bump com.tngtech.archunit:archunit-junit5 from 1.4.2 to 1.5.0 (#7704) Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index fdc59f3680..9dbfcf9b24 100644 --- a/build.gradle +++ b/build.gradle @@ -40,7 +40,7 @@ ext { jinjavaVersion = "2.8.4" jackson2Version = "2.22.1" bucket4jVersion = "8.19.0" - archunitVersion = "1.4.2" + archunitVersion = "1.5.0" batikVersion = "1.19" jpdfiumVersion = "1.1.3" jwtVersion = "0.13.0" From 5fe7df393364f39e5e96941ce12ccf807c76563a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:14:04 +0100 Subject: [PATCH 42/57] build(deps): bump jackson2Version from 2.22.1 to 2.22.2 (#7703) Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9dbfcf9b24..6ee8baca47 100644 --- a/build.gradle +++ b/build.gradle @@ -38,7 +38,7 @@ ext { gsonVersion = "2.14.0" guavaVersion = "33.6.0-jre" jinjavaVersion = "2.8.4" - jackson2Version = "2.22.1" + jackson2Version = "2.22.2" bucket4jVersion = "8.19.0" archunitVersion = "1.5.0" batikVersion = "1.19" From 3aca7a26f6b1b1f37c7cd1627be0b163200351ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:14:19 +0100 Subject: [PATCH 43/57] build(deps-dev): bump python-dotenv from 1.2.2 to 1.2.3 in /engine (#7702) Signed-off-by: dependabot[bot] --- engine/pyproject.toml | 2 +- engine/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 23c3bc078c..3fe7ccdb22 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -20,7 +20,7 @@ engine = [ # No `voyageai` extra either; stirling.documents.voyage speaks its API directly. "pydantic-ai-slim[anthropic,openai]>=1.107.2,<2.0.0", "pydantic-settings>=2.15.0", - "python-dotenv>=1.2.2", + "python-dotenv>=1.2.3", "sqlite-vec>=0.1.9", "uvicorn>=0.52.3", ] diff --git a/engine/uv.lock b/engine/uv.lock index a3286b100d..4681861fe3 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -479,7 +479,7 @@ engine = [ { name = "pydantic", specifier = ">=2.13.4" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], specifier = ">=1.107.2,<2.0.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, - { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "python-dotenv", specifier = ">=1.2.3" }, { name = "sqlite-vec", specifier = ">=0.1.9" }, { name = "uvicorn", specifier = ">=0.52.3" }, ] @@ -1262,11 +1262,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] From 54e839ae65e7968e51c42214a790c054bf67628d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:14:31 +0100 Subject: [PATCH 44/57] build(deps-dev): bump reportlab from 5.0.0 to 5.0.1 in /engine (#7699) Signed-off-by: dependabot[bot] --- engine/pyproject.toml | 2 +- engine/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 3fe7ccdb22..179fe8c1d1 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -42,7 +42,7 @@ cucumber = [ "pillow>=12.3.0", "pypdf[crypto]>=6.15.0", "qrcode[pil]>=8.2", - "reportlab>=5.0.0", + "reportlab>=5.0.1", "requests>=2.34.2", ] # Shared Python utilities used by repository scripts and CI workflows. diff --git a/engine/uv.lock b/engine/uv.lock index 4681861fe3..8c76b57b3b 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -466,7 +466,7 @@ cucumber = [ { name = "pillow", specifier = ">=12.3.0" }, { name = "pypdf", extras = ["crypto"], specifier = ">=6.15.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, - { name = "reportlab", specifier = ">=5.0.0" }, + { name = "reportlab", specifier = ">=5.0.1" }, { name = "requests", specifier = ">=2.34.2" }, ] engine = [ @@ -1373,15 +1373,15 @@ wheels = [ [[package]] name = "reportlab" -version = "5.0.0" +version = "5.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, { name = "pillow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/d6/4b7b0cf56880eb96533e607967be6a939e344675601e033d113a0bfa1f4e/reportlab-5.0.0.tar.gz", hash = "sha256:e4494a0c6623ae213bb856fba523171b2b54a7bf629fda02d5e525a7b899a784", size = 3701928, upload-time = "2026-06-18T11:34:31.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/51/dbe28534ae12c852f61be91f039f343305fd1f34f1c66b8de75afae7a525/reportlab-5.0.1.tar.gz", hash = "sha256:ebd13154be1c8515e665de70bd2d303ae9ddc3ef47e44afd5116441ca0283a26", size = 3945711, upload-time = "2026-08-20T13:48:16.461Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/07/70085c17a369605f15e301d10ab902115019b1126c7253d964afc230c7d6/reportlab-5.0.0-py3-none-any.whl", hash = "sha256:9d5a3affa84919e1111ede580031266a570e93b1ce388219621347965ff1d93c", size = 1956710, upload-time = "2026-06-18T11:34:29.07Z" }, + { url = "https://files.pythonhosted.org/packages/db/cb/dacbc268cb68d0428ea2cbd85266195a9ab3e677449589ddae59bd7542ac/reportlab-5.0.1-py3-none-any.whl", hash = "sha256:1c36e6bb0e71780c72331eba60da7f602e8d4389a8723825af71342e49d791e8", size = 1957258, upload-time = "2026-08-20T13:48:14.026Z" }, ] [[package]] From d93049db9f4f01981a5fc922d3fb2cff64ddc7db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:14:56 +0100 Subject: [PATCH 45/57] build(deps): bump log from 0.4.33 to 0.4.34 in /frontend/editor/src-tauri (#7747) Signed-off-by: dependabot[bot] --- frontend/editor/src-tauri/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/editor/src-tauri/Cargo.lock b/frontend/editor/src-tauri/Cargo.lock index fe8352f5a7..a875adc740 100644 --- a/frontend/editor/src-tauri/Cargo.lock +++ b/frontend/editor/src-tauri/Cargo.lock @@ -2429,9 +2429,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "value-bag", ] From 96207a7304078e3323e419581d30bc0b667795c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:15:11 +0100 Subject: [PATCH 46/57] build(deps): bump @tanstack/react-query from 5.101.4 to 5.102.0 in /frontend in the tanstack group across 1 directory (#7749) Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 16 ++++++++-------- frontend/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 198751f05d..85d011f49b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -49,7 +49,7 @@ "@stripe/stripe-js": "^9.10.0", "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", - "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query": "^5.102.0", "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.14.10", "@tauri-apps/api": "^2.10.1", @@ -5219,9 +5219,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.101.4", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", - "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "version": "5.102.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.0.tgz", + "integrity": "sha512-tvBzr11Q7StuMCEsIJdqX8TAWt6WZIzfw/yrSAjObZDerwTTPeCxeLXN2R8ZSn4ZxFpQV819Xn8QmoO+vtnDvw==", "license": "MIT", "funding": { "type": "github", @@ -5229,12 +5229,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.101.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", - "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "version": "5.102.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.0.tgz", + "integrity": "sha512-0GyVyEcGt9M7jHPCua16hNVtstUXE2R4HsnNubFYcDs7DJaLzh1dSiXsADDU/cNn/SqrLfa0vRKyjUmbx4rZLA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.4" + "@tanstack/query-core": "5.102.0" }, "funding": { "type": "github", diff --git a/frontend/package.json b/frontend/package.json index 0389baaf32..877c9f1b3b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,7 +46,7 @@ "@stripe/stripe-js": "^9.10.0", "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", - "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query": "^5.102.0", "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.14.10", "@tauri-apps/api": "^2.10.1", From 0920ea9493598c40e893728117f94c458a51d63e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:17:05 +0100 Subject: [PATCH 47/57] build(deps): bump go-task/setup-task from 2.1.0 to 2.2.0 (#7532) Signed-off-by: dependabot[bot] --- .github/workflows/PR-Auto-Deploy-V2.yml | 2 +- .github/workflows/PR-Demo-Comment-with-react.yml | 2 +- .github/workflows/ai-engine.yml | 2 +- .github/workflows/backend-build.yml | 2 +- .github/workflows/build-enterprise.yml | 2 +- .github/workflows/check-generated-models.yml | 2 +- .github/workflows/check-licence.yml | 2 +- .github/workflows/check-openapi.yml | 2 +- .github/workflows/e2e-live.yml | 2 +- .github/workflows/e2e-stubbed.yml | 2 +- .github/workflows/frontend-a11y.yml | 2 +- .github/workflows/frontend-backend-licenses-update.yml | 4 ++-- .github/workflows/frontend-validation.yml | 2 +- .github/workflows/multiOSReleases.yml | 6 +++--- .github/workflows/nightly.yml | 6 +++--- .github/workflows/pre_commit.yml | 2 +- .github/workflows/push-docker.yml | 2 +- .github/workflows/swagger.yml | 2 +- .github/workflows/sync_files_v2.yml | 2 +- .github/workflows/tauri-build.yml | 2 +- .github/workflows/test-build-docker.yml | 2 +- 21 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index c27fb6b5f2..c55d70fd4e 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -353,7 +353,7 @@ jobs: - name: Install Task for Storybook if: steps.sb-changes.outputs.storybook == 'true' - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build and deploy Storybook id: storybook diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index b43020ceb0..5a4fcc8053 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -206,7 +206,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Run Gradle Command run: | if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 357dfa1e49..29b8312f4f 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -36,7 +36,7 @@ jobs: engine/uv.lock - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Quality-check engine id: engine-check diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 09c6bbc9a8..33b631ef86 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -52,7 +52,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Check Java formatting (Spotless) # Runs once per matrix combination - pick the cheapest leg # (core - no proprietary, no saas) so we don't wait for the diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index a5a346db88..a3f10d82d4 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -95,7 +95,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Install Playwright (chromium only) run: task e2e:install -- chromium - name: Build frontend (needed for playwright's vite preview webServer) diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index f917a4c602..276a1d7530 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -75,7 +75,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Verify generated models are up to date id: models-check diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index 7626122884..38b49097ed 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -38,7 +38,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Check licenses for compatibility run: task backend:licenses:check env: diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 47341834a3..5046c18e8c 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -39,7 +39,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Generate OpenAPI documentation run: task backend:swagger env: diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index b04f5022cc..505addd3d7 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -45,7 +45,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Install Playwright (chromium only) run: task e2e:install -- chromium - name: Build frontend (production bundle for vite preview) diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index 5038a7585a..2553f38f84 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -44,7 +44,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build frontend (production bundle for vite preview) env: VITE_BUILD_FOR_PREVIEW: "1" diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index 247d8375fc..f96496e181 100644 --- a/.github/workflows/frontend-a11y.yml +++ b/.github/workflows/frontend-a11y.yml @@ -36,7 +36,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: a11y gate (changed stories) run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }} - name: Upload scan reports diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 96e0edd8ac..44cf4afb75 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -97,7 +97,7 @@ jobs: run: npm ci --ignore-scripts --audit=false --fund=false - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Generate frontend license report (Push only) if: github.event_name == 'push' @@ -367,7 +367,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Check licenses and generate report id: license-check diff --git a/.github/workflows/frontend-validation.yml b/.github/workflows/frontend-validation.yml index 2650a945d6..a553c48280 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -27,7 +27,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Quality-check frontend id: frontend-check run: task frontend:check:all diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 9071997ad7..6d4258d085 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -69,7 +69,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Get version number id: versionNumber run: | @@ -169,7 +169,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build JAR run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube @@ -268,7 +268,7 @@ jobs: distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 # Build the universal JRE before desktop:prepare so the jlink:runtime # task short-circuits on its `test -d runtime/jre` status check. diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5daa60f56f..63484ee7c0 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -38,7 +38,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Install all Playwright browsers run: task e2e:install @@ -89,7 +89,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: a11y gate (every story, ${{ matrix.theme }}) run: task frontend:storybook:a11y:${{ matrix.theme }} @@ -162,7 +162,7 @@ jobs: engine/uv.lock - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Start the fat image with login and storage enabled run: docker compose -f docker/embedded/compose/test_cicd.yml up -d --build diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index fbd9efc474..f3442086a0 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -33,7 +33,7 @@ jobs: engine/uv.lock - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Run pre-commit checks run: task pre-commit diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 8e8b26be91..b3c6d442b6 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -88,7 +88,7 @@ jobs: uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Get version number id: versionNumber run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 1f53edd17b..d7408dcfde 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -63,7 +63,7 @@ jobs: SWAGGERHUB_USER: "Frooodle" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Get version number id: versionNumber run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index f688476159..d38c14ddba 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -65,7 +65,7 @@ jobs: uv sync --project engine --locked --group tools - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Sync translation TOML files run: | diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 3b7a0b04fa..157c4ead0c 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -212,7 +212,7 @@ jobs: distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - name: Setup Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build universal macOS JRE if: matrix.platform == 'macos-15' diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index e38fcb4cd6..04a6380586 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -127,7 +127,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build application run: task backend:build env: From 01c908e95d6d0ee062f9467fb4942cf27f712986 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:37:12 +0100 Subject: [PATCH 48/57] build(deps-dev): bump openai from 2.53.0 to 3.3.1 in /engine (#7700) Signed-off-by: dependabot[bot] --- engine/pyproject.toml | 2 +- engine/uv.lock | 24 +++++------------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 179fe8c1d1..d924eab264 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -51,7 +51,7 @@ tools = [ "defusedxml>=0.7.1", "fonttools>=4.63.0", "fpdf2>=2.8.8", - "openai>=2.53.0", + "openai>=3.3.1", "requests>=2.34.2", "tomli-w>=1.2.0", "tomlkit>=0.15.1", diff --git a/engine/uv.lock b/engine/uv.lock index 8c76b57b3b..ae023292b7 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -501,7 +501,7 @@ tools = [ { name = "defusedxml", specifier = ">=0.7.1" }, { name = "fonttools", specifier = ">=4.63.0" }, { name = "fpdf2", specifier = ">=2.8.8" }, - { name = "openai", specifier = ">=2.53.0" }, + { name = "openai", specifier = ">=3.3.1" }, { name = "requests", specifier = ">=2.34.2" }, { name = "tomli-w", specifier = ">=1.2.0" }, { name = "tomlkit", specifier = ">=0.15.1" }, @@ -844,21 +844,19 @@ wheels = [ [[package]] name = "openai" -version = "2.53.0" +version = "3.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, - { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116", size = 1099435, upload-time = "2026-08-03T21:42:01.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/9c/ba0c292b4032ede74c249ca314ad64eb1bb5a03a843f6e01facb02f80cd8/openai-3.3.1.tar.gz", hash = "sha256:6f22807de1a976c932cecda620e8172a8c3fdbaeed29c7f21564e0c2410edf56", size = 1282113, upload-time = "2026-08-19T16:31:35.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618", size = 1659829, upload-time = "2026-08-03T21:41:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/2b7a1b3de659bb82aef979116c74e809982b13e42c057759767552b5155f/openai-3.3.1-py3-none-any.whl", hash = "sha256:9652df7fdf8ee6f5bd58e0a12f2b1d414a18e0f06bb7a9a57c8643a5f5469bd3", size = 1690337, upload-time = "2026-08-19T16:31:32.812Z" }, ] [[package]] @@ -1566,18 +1564,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, ] -[[package]] -name = "tqdm" -version = "4.70.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, -] - [[package]] name = "truststore" version = "0.10.4" From af97e1b27bca7ba84bfb7448a6fc3512f9873670 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:45:42 +0100 Subject: [PATCH 49/57] Update Backend 3rd Party Licenses (#7713) Signed-off-by: stirlingbot[bot] --- .../resources/static/3rdPartyLicenses.json | 101 +----------------- 1 file changed, 5 insertions(+), 96 deletions(-) diff --git a/app/core/src/main/resources/static/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index a0dae640e0..6b846053e4 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -94,7 +94,7 @@ { "moduleName": "com.fasterxml.jackson.core:jackson-core", "moduleUrl": "https://github.com/FasterXML/jackson-core", - "moduleVersion": "2.22.1", + "moduleVersion": "2.22.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -108,7 +108,7 @@ { "moduleName": "com.fasterxml.jackson.core:jackson-databind", "moduleUrl": "https://github.com/FasterXML/jackson", - "moduleVersion": "2.22.1", + "moduleVersion": "2.22.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -143,7 +143,7 @@ { "moduleName": "com.fasterxml.jackson:jackson-bom", "moduleUrl": "https://github.com/FasterXML/jackson-bom", - "moduleVersion": "2.22.1", + "moduleVersion": "2.22.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -440,42 +440,14 @@ { "moduleName": "com.stirling:jpdfium", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-darwin-arm64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-darwin-x64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-linux-arm64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", + "moduleVersion": "1.1.3", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "com.stirling:jpdfium-natives-linux-x64", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-windows-x64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", + "moduleVersion": "1.1.3", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -521,36 +493,18 @@ "moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception", "moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html" }, - { - "moduleName": "com.twelvemonkeys.common:common-image", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.common:common-image", "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.common:common-io", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.common:common-io", "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.common:common-lang", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.common:common-lang", "moduleVersion": "3.14.0", @@ -569,12 +523,6 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-core", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-core", "moduleVersion": "3.14.0", @@ -587,12 +535,6 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-metadata", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-metadata", "moduleVersion": "3.14.0", @@ -605,24 +547,12 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-tiff", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-tiff", "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-webp", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-webp", "moduleVersion": "3.14.0", @@ -769,13 +699,6 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "commons-beanutils:commons-beanutils", - "moduleUrl": "https://commons.apache.org/proper/commons-beanutils", - "moduleVersion": "1.11.0", - "moduleLicense": "Apache-2.0", - "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "commons-cli:commons-cli", "moduleUrl": "http://commons.apache.org/proper/commons-cli/", @@ -790,13 +713,6 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "commons-collections:commons-collections", - "moduleUrl": "http://commons.apache.org/collections/", - "moduleVersion": "3.2.2", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "commons-io:commons-io", "moduleUrl": "https://commons.apache.org/proper/commons-io/", @@ -1360,13 +1276,6 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "org.apache.commons:commons-math3", - "moduleUrl": "http://commons.apache.org/proper/commons-math/", - "moduleVersion": "3.6.1", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "org.apache.commons:commons-text", "moduleUrl": "https://commons.apache.org/proper/commons-text", From d55d8acbfa5f56174bf7ee00b7c6f53944180099 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:56:35 +0000 Subject: [PATCH 50/57] fix(portal): keep the processor's cache across a trip to the editor (#7729) # Description of Changes ## The problem The portal's query client was created per mount: ```ts const [queryClient] = useState(createPortalQueryClient); ``` The portal is a route (`/processor/*`, a lazy element), and the switch to the editor is a client-side `navigate()`. So leaving the processor unmounts `PortalApp`, the client goes with the component, and the cache goes with the client. Coming back refetches everything, whether or not anything changed: four requests for the Users page alone (roster, grants, teams, auth config), and 21 `useQuery` sites across the portal. The editor's client sits above the router in `AppProviders` and survives the same trip. The round trip only ever cost in one direction. ## The fix The module already kept the instance in a module-level slot so `tryGetPortalQueryClient()` could find it. It just replaced it on every mount instead of reusing it, so the change is to create it lazily and hand out the same one: ```ts export function getPortalQueryClient(): QueryClient { current ??= new QueryClient({ defaultOptions: { queries: baseQueryOptions } }); return current; } ``` Still a separate instance from the editor's. The two namespace their keys apart (`["portal", ...]` against `["editor", ...]`) and invalidate independently, which this does not change. ## What this does not do `gcTime` is 5 minutes, from the shared `baseQueryOptions`. An entry with no observer is still collected on that timer, so this warms a quick trip to the editor and back, not a return after a long editing session. Raising the portal's `gcTime` is a separate decision and is not made here. ## Why it is safe **Signing out.** A cache that outlives a mount must not outlive a session, because the portal's holds the admin roster, emails and roles. Logout goes through `window.location.assign`, a full page load, so the whole JS context is discarded and no cache can survive it. Nothing in the codebase calls `queryClient.clear()` on sign-out, and nothing needs to. If logout ever becomes a client-side navigation, this needs an explicit reset, and `resetPortalQueryClient()` is the hook for it. **The one caller of the null check.** `resolveTeam` in `saas/portal/usersBackend.ts` uses `tryGetPortalQueryClient()` and falls back to a direct fetch when there is no client, which its comment describes as the unit-test path; the cache path is preferred because it honours both `staleTime` and invalidation. A longer-lived client means the preferred path is taken more often, not less. ## Testing Three tests in `queryClient.test.tsx`, and the first two fail if the client goes back to being created per call: | | | |---|---| | A remount is served from cache rather than refetching | the behaviour this changes | | Every caller gets the same instance | the mechanism | | No client is reported until the portal first mounts | the contract `resolveTeam` reads | The three existing portal caching suites called the factory expecting a fresh client per case. They now call `resetPortalQueryClient()` in a `beforeEach`, which is what keeps `sharing.test.tsx`'s "a later screen refetches nothing" case honest rather than passing on a leaked cache. `task frontend:check` passes typecheck, lint and oxfmt, and 2402 of 2404 editor tests. The two failures, `workbenchSession.test.ts` and `notificationActions.test.tsx`, are untouched here and fail the same way on `main`. --- frontend/editor/src/portal/PortalApp.tsx | 6 +- .../src/portal/queries/sharing.test.tsx | 12 +++- .../editor/src/portal/queryClient.test.tsx | 64 +++++++++++++++++++ frontend/editor/src/portal/queryClient.ts | 27 ++++++-- .../src/portal/views/Users.caching.test.tsx | 12 +++- .../src/portal/views/teamMyCache.saas.test.ts | 10 ++- 6 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 frontend/editor/src/portal/queryClient.test.tsx diff --git a/frontend/editor/src/portal/PortalApp.tsx b/frontend/editor/src/portal/PortalApp.tsx index 3122016436..9109946929 100644 --- a/frontend/editor/src/portal/PortalApp.tsx +++ b/frontend/editor/src/portal/PortalApp.tsx @@ -1,11 +1,11 @@ -import { useState, type ReactNode } from "react"; +import { type ReactNode } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; import { PortalProviders } from "@portal/PortalProviders"; import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; -import { createPortalQueryClient } from "@portal/queryClient"; +import { getPortalQueryClient } from "@portal/queryClient"; // Reset + typography, scoped to .portal-scope below. import "@portal/theme/base.css"; @@ -30,7 +30,7 @@ function ThemedSuiProvider({ children }: { children: ReactNode }) { * self-hosted mounts the account-link layer, SaaS does not. */ export function PortalApp() { - const [queryClient] = useState(createPortalQueryClient); + const queryClient = getPortalQueryClient(); return ( diff --git a/frontend/editor/src/portal/queries/sharing.test.tsx b/frontend/editor/src/portal/queries/sharing.test.tsx index 5cfb9d1629..97e23f9244 100644 --- a/frontend/editor/src/portal/queries/sharing.test.tsx +++ b/frontend/editor/src/portal/queries/sharing.test.tsx @@ -12,7 +12,10 @@ import { render, waitFor } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; -import { createPortalQueryClient } from "@portal/queryClient"; +import { + getPortalQueryClient, + resetPortalQueryClient, +} from "@portal/queryClient"; import { usePoliciesOverview } from "@portal/queries/policies"; import { useProcessorFlow } from "@portal/queries/processorFlow"; @@ -70,9 +73,12 @@ function PoliciesConsumer() { return null; } +// The client outlives a mount now, so each case starts from a cold one. +beforeEach(resetPortalQueryClient); + describe("portal query sharing", () => { it("in-view: multiple consumers of the same endpoints fetch each once", async () => { - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); render( @@ -86,7 +92,7 @@ describe("portal query sharing", () => { }); it("cross-view: a later screen reusing the data refetches nothing", async () => { - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); const home = render( diff --git a/frontend/editor/src/portal/queryClient.test.tsx b/frontend/editor/src/portal/queryClient.test.tsx new file mode 100644 index 0000000000..a750eccc0e --- /dev/null +++ b/frontend/editor/src/portal/queryClient.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import { QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { + getPortalQueryClient, + resetPortalQueryClient, + tryGetPortalQueryClient, +} from "@portal/queryClient"; + +const fetchThing = vi.fn(async () => "loaded"); + +/** Stands in for any portal view: mounts, reads one key, unmounts with the route. */ +function PortalRoute() { + const { data } = useQuery({ + queryKey: ["portal", "thing"], + queryFn: fetchThing, + }); + return {data ?? "pending"}; +} + +function mountRoute() { + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return render( + + + , + ); +} + +describe("portal query client lifetime", () => { + beforeEach(() => { + resetPortalQueryClient(); + fetchThing.mockClear(); + }); + + it("serves a remount from cache instead of refetching", async () => { + const first = mountRoute(); + await screen.findByText("loaded"); + expect(fetchThing).toHaveBeenCalledTimes(1); + + // Switching to the editor unmounts the portal route. + first.unmount(); + + mountRoute(); + // Painted from cache, not after a round trip. + expect(screen.getByText("loaded")).toBeInTheDocument(); + expect(fetchThing).toHaveBeenCalledTimes(1); + }); + + it("hands every caller the same instance", () => { + expect(getPortalQueryClient()).toBe(getPortalQueryClient()); + }); + + it("reports no client until the portal first mounts", () => { + expect(tryGetPortalQueryClient()).toBeNull(); + const client = getPortalQueryClient(); + expect(tryGetPortalQueryClient()).toBe(client); + }); +}); diff --git a/frontend/editor/src/portal/queryClient.ts b/frontend/editor/src/portal/queryClient.ts index 5c0404f17d..bdd6b3b2ec 100644 --- a/frontend/editor/src/portal/queryClient.ts +++ b/frontend/editor/src/portal/queryClient.ts @@ -3,13 +3,32 @@ import { baseQueryOptions } from "@app/query/queryClient"; let current: QueryClient | null = null; -/** Own instance, shared defaults — the portal and editor are sibling routes. */ -export function createPortalQueryClient(): QueryClient { - current = new QueryClient({ defaultOptions: { queries: baseQueryOptions } }); +/** + * One client for the session, not one per mount. The portal is a route, so + * switching to the editor unmounts it, and a per-mount client would throw the + * cache away and refetch everything on the way back. The editor's own client + * sits above the router and never pays that. + * + * Still a separate instance from the editor's: the two namespace their keys + * apart and invalidate independently. + */ +export function getPortalQueryClient(): QueryClient { + current ??= new QueryClient({ + defaultOptions: { queries: baseQueryOptions }, + }); return current; } -/** Null until the portal mounts, so resolveTeam can fall back to a direct fetch. */ +/** Null until the portal first mounts, so resolveTeam can fall back to a direct fetch. */ export function tryGetPortalQueryClient(): QueryClient | null { return current; } + +/** + * Drops the cache and the instance holding it. For tests, which need a cold + * start between cases; the app never calls it, because signing out is a full + * page load. + */ +export function resetPortalQueryClient(): void { + current = null; +} diff --git a/frontend/editor/src/portal/views/Users.caching.test.tsx b/frontend/editor/src/portal/views/Users.caching.test.tsx index 64d839ac62..a619d883b7 100644 --- a/frontend/editor/src/portal/views/Users.caching.test.tsx +++ b/frontend/editor/src/portal/views/Users.caching.test.tsx @@ -17,7 +17,10 @@ import { teamSaasHandlers, resetTeamSaasStore, } from "@portal/mocks/handlers/teamSaas"; -import { createPortalQueryClient } from "@portal/queryClient"; +import { + getPortalQueryClient, + resetPortalQueryClient, +} from "@portal/queryClient"; import { qk } from "@portal/queries/keys"; /** @@ -98,11 +101,14 @@ function renderUsers(client: QueryClient): RenderResult { ); } +// The client outlives a mount now, so each case starts from a cold one. +beforeEach(resetPortalQueryClient); + describe("Users view caching", () => { it("serves the roster from cache on remount (no refetch)", async () => { // One client across both mounts — the real app keeps it at the portal root, // above the router, for exactly this reason. - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); const first = renderUsers(client); expect(await screen.findByText("leader@acme.com")).toBeInTheDocument(); @@ -116,7 +122,7 @@ describe("Users view caching", () => { }); it("collapses the SaaS /team/my call to one per mount", async () => { - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); renderUsers(client); await screen.findByText("leader@acme.com"); diff --git a/frontend/editor/src/portal/views/teamMyCache.saas.test.ts b/frontend/editor/src/portal/views/teamMyCache.saas.test.ts index a4ba296535..8534624c52 100644 --- a/frontend/editor/src/portal/views/teamMyCache.saas.test.ts +++ b/frontend/editor/src/portal/views/teamMyCache.saas.test.ts @@ -10,7 +10,10 @@ import { } from "vitest"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; -import { createPortalQueryClient } from "@portal/queryClient"; +import { + getPortalQueryClient, + resetPortalQueryClient, +} from "@portal/queryClient"; import { qk } from "@portal/queries/keys"; import { usersBackend } from "@app/portal/usersBackend"; @@ -68,9 +71,12 @@ beforeEach(() => { teamName = "Old name"; }); +// The client outlives a mount now, so each case starts from a cold one. +beforeEach(resetPortalQueryClient); + describe("SaaS /team/my resolution cache", () => { it("dedupes within staleTime but re-resolves after invalidation", async () => { - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); // Two resolves within staleTime → one network call (the collapse). expect((await usersBackend.fetchTeams())[0]?.name).toBe("Old name"); From 31d52d4c327810450f575889ffccdd74a1e49593 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:39:57 +0000 Subject: [PATCH 51/57] Connect flow for self-hosted account linking, and the triggers that drive it (#7415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bare account-link login box with a guided Connect flow, and wires up the triggers that actually put it in front of someone. ## Top bar image ## The modal Three steps on the portal's own `FlowModal` + `StepModalHeader`, the shells procurement and prepay already wear: 1. **What you unlock** — six benefits as a plain list. image 2. **Sign in** — the existing `SupabaseLoginForm`, reseated. image 3. **Connected** — confirms, then deep links into Users, Pipelines and Policies. image Re-auth stays a single step with no pitch and no success screen. ## The triggers **`LinkGate` stops being dead code.** It was built as the drop-anywhere "link to unlock" wrapper and was imported by nothing. It is now a blocking empty state that replaces the feature it guards, wired into Pipelines, Policies, Users, Sources and Integrations. **Scoped to creating and editing, never viewing.** Existing pipelines, policies, sources and connections keep listing and running, so upgrading an unlinked instance cannot take away something that already works. The clicks that would open a builder or a create modal ask for the connection first, which is the moment an admin has already declared intent. ## Capability signal `accountLinkAvailable` on `/api/v1/config/app-config`. Gating needs two facts: whether the instance is linked (`LinkContext`) and whether it *could* be (this flag). The account-link endpoints 404 when the feature flag is off, which the client cannot distinguish from "not linked yet" — so gating on link state alone would lock all five views on every default install with no way out. `useConnectGate` holds that decision in one place and shares the app-config query key, so it costs no extra request. Read from the environment rather than `AccountLinkProperties` because `:core` cannot depend on `:proprietary`. --- .../controller/api/misc/ConfigController.java | 13 + .../public/locales/en-US/translation.toml | 72 ++-- frontend/editor/src/core/types/appConfig.ts | 6 + .../editor/src/portal/PortalProviders.tsx | 15 +- frontend/editor/src/portal/ViewRouter.tsx | 16 +- .../portal/components/ConnectAccountRail.css | 53 +++ .../components/ConnectAccountRail.stories.tsx | 46 +++ .../portal/components/ConnectAccountRail.tsx | 57 ++++ .../editor/src/portal/components/HomeHero.tsx | 2 + .../editor/src/portal/components/Sidebar.tsx | 5 + .../account-link/ConnectCallbackHost.tsx | 144 +++----- .../account-link/ConnectCallbackView.tsx | 66 ++-- .../account-link/ConnectGuardedRoute.tsx | 26 ++ .../account-link/LinkAccountModal.css | 26 -- .../account-link/LinkAccountModal.test.tsx | 186 +++++++++-- .../account-link/LinkAccountModal.tsx | 310 +++++++++++------- .../components/account-link/LinkGate.tsx | 52 --- .../account-link/connect/ConnectAskStep.tsx | 52 +++ .../connect/ConnectBenefitsSlide.stories.tsx | 12 + .../connect/ConnectBenefitsSlide.tsx | 55 ++++ .../connect/ConnectDoneSlide.test.tsx | 84 +++++ .../account-link/connect/ConnectDoneSlide.tsx | 157 +++++++++ .../connect/ConnectHandoffGhost.tsx | 28 ++ .../account-link/connect/connect.css | 112 +++++++ .../billing/LinkAccountPrompt.stories.tsx | 14 - .../components/billing/LinkAccountPrompt.tsx | 33 -- .../billing/PortalBillingGate.test.tsx | 77 ++++- .../components/billing/PortalBillingGate.tsx | 26 +- .../components/shared/StepModalHeader.css | 2 +- .../components/shared/StepModalHeader.tsx | 4 +- .../src/portal/components/sidebarGroups.tsx | 4 +- .../src/portal/contexts/LinkContext.tsx | 8 + .../editor/src/portal/contexts/UIContext.tsx | 22 ++ .../src/portal/hooks/useConnectGate.test.tsx | 78 +++++ .../editor/src/portal/hooks/useConnectGate.ts | 63 ++++ .../src/portal/hooks/useConnectHandoff.ts | 64 ++++ .../portal/hooks/useConnectPrompt.test.tsx | 74 +++++ .../src/portal/hooks/useConnectPrompt.ts | 37 +++ .../portal/hooks/useDevConnectBypass.test.tsx | 53 +++ .../src/portal/hooks/useDevConnectBypass.ts | 39 +++ .../src/portal/test/TestQueryProvider.tsx | 19 ++ .../src/portal/views/ConnectCallback.css | 31 +- .../src/portal/views/ConnectCallback.test.tsx | 88 +++-- .../src/portal/views/Integrations.test.tsx | 10 + .../editor/src/portal/views/Integrations.tsx | 24 +- .../src/portal/views/Pipelines.gated.test.tsx | 117 +++++++ .../src/portal/views/Pipelines.test.tsx | 10 + .../editor/src/portal/views/Pipelines.tsx | 19 +- frontend/editor/src/portal/views/Policies.tsx | 26 +- .../src/portal/views/Sources.gated.test.tsx | 111 +++++++ .../editor/src/portal/views/Sources.test.tsx | 10 + frontend/editor/src/portal/views/Sources.tsx | 23 +- .../src/portal/views/Users.caching.test.tsx | 10 + .../src/portal/views/Users.gated.test.tsx | 96 ++++++ .../src/portal/views/Users.saas.test.tsx | 10 + frontend/editor/src/portal/views/Users.tsx | 25 +- .../src/saas/routes/ConnectApproveView.tsx | 90 ++++- frontend/editor/src/saas/routes/connect.css | 9 +- 58 files changed, 2346 insertions(+), 575 deletions(-) create mode 100644 frontend/editor/src/portal/components/ConnectAccountRail.css create mode 100644 frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx create mode 100644 frontend/editor/src/portal/components/ConnectAccountRail.tsx create mode 100644 frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx delete mode 100644 frontend/editor/src/portal/components/account-link/LinkAccountModal.css delete mode 100644 frontend/editor/src/portal/components/account-link/LinkGate.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/connect.css delete mode 100644 frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx delete mode 100644 frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx create mode 100644 frontend/editor/src/portal/hooks/useConnectGate.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useConnectGate.ts create mode 100644 frontend/editor/src/portal/hooks/useConnectHandoff.ts create mode 100644 frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useConnectPrompt.ts create mode 100644 frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useDevConnectBypass.ts create mode 100644 frontend/editor/src/portal/views/Pipelines.gated.test.tsx create mode 100644 frontend/editor/src/portal/views/Sources.gated.test.tsx create mode 100644 frontend/editor/src/portal/views/Users.gated.test.tsx diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 36beb6610c..618d5d642d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -338,6 +338,19 @@ public class ConfigController { // Premium/Enterprise settings configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); + // Whether this instance can link a Stirling (SaaS) account at all. The account-link + // beans live in :proprietary and are @ConditionalOnProperty on this same key, so when + // it is off they are absent and /api/v1/account-link/* returns 404. The frontend cannot + // tell that 404 apart from "not linked yet", so it needs this told to it explicitly + // before it can prompt anyone to link. Read from the environment rather than + // AccountLinkProperties because :core must not depend on :proprietary. + configData.put( + "accountLinkAvailable", + applicationContext + .getEnvironment() + .getProperty( + "stirling.billing.account-link.enabled", Boolean.class, false)); + // AI Engine settings ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine(); configData.put("aiEngineEnabled", aiEngineConfig.isEnabled()); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index baee16cd02..df996b23c0 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3641,6 +3641,7 @@ system = "System Configuration" [connect] loading = "Checking this request." redirecting = "Returning you to your server." +step = "Step {{current}} of {{total}}" [connect.confirm] acknowledge = "I recognise this address and want to connect it to my team" @@ -6804,10 +6805,22 @@ after = "to enable account linking against the hosted Stirling account. In dev y before = "Set" title = "SaaS login not configured" +[portal.accountLink.connect] +close = "Close" +notNow = "Not now" +start = "Connect Stirling account" +step = "Step {{current}} of {{total}}" + +[portal.accountLink.connect.benefits] +creditsDetail = "500 free per month" +creditsLabel = "Credits" +processorDetail = "Pipelines, policies, sources and audit" +processorLabel = "Processor" +teamsDetail = "Free for up to 5 users" +teamsLabel = "Teams" + [portal.accountLink.connect.callback] -continue = "Continue" linkedNotSignedIn = "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in." -modalTitle = "Connecting this server" retry = "Try again" signedInAnyway = "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete." working = "Finishing the connection." @@ -6816,10 +6829,6 @@ working = "Finishing the connection." body = "Connection requests are short lived. Start another one." title = "Request expired" -[portal.accountLink.connect.callback.linked] -body = "This server is connected to your Stirling account." -title = "Server connected" - [portal.accountLink.connect.callback.malformed] body = "This page was opened without a valid connection response. Start the connection from settings." title = "Could not read the response" @@ -6832,11 +6841,23 @@ title = "Connection not completed" body = "Stirling did not confirm the connection. This is usually temporary." title = "Not finished yet" -[portal.accountLink.gate] -action = "Link account" -description = "Link this org's Stirling account to use billable features." -title = "Link to unlock" -titleFeature = "Link to unlock {{feature}}" +[portal.accountLink.connect.done] +accountLabel = "Account" +addPolicy = "Add a policy" +buildPipeline = "Set up a pipeline" +creditsBarLabel = "Free credits remaining" +creditsSuffix = "of {{allowance}} free credits left" +cta = "Done" +inviteTeam = "Invite your team" +lede = "This server now runs against your Stirling account." +pendingTitle = "Almost there" +switchOnProcessor = "Switch on the Processor" +title = "Connected" + +[portal.accountLink.connect.handoff] +going = "Taking you to stirling.com" +reauthLede = "Your Stirling session expired. Signing in again keeps usage and billing visible. This server stays connected either way." +title = "Connecting" [portal.accountLink.instances] active = "Active" @@ -6866,17 +6887,11 @@ never = "never" [portal.accountLink.modal] cancel = "Cancel" -continueLink = "Continue to Stirling" continueReauth = "Sign in again" -linkSubtitle = "Connect this server to the Stirling account it should bill against." linkTitle = "Connect your Stirling account" noAuthorizeUrl = "Stirling did not return somewhere to continue. Try again in a moment." -reauthSubtitle = "Your Stirling session expired. Sign in again to keep seeing usage and billing. This server stays connected either way." reauthTitle = "Sign in again" startFailed = "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again." -step1 = "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on." -step2 = "You check this server's address and approve it. A team owner has to do this the first time." -step3 = "Stirling brings you straight back here and finishes up." [portal.accountLink.modal.loginNotConfigured] after = "so this server can finish the connection when you come back." @@ -6895,6 +6910,12 @@ forbidden = "Only the team owner can view the org's linked instances." generic = "Couldn't load the team's linked instances. Try again in a moment." title = "Couldn't load linked instances" +[portal.accountLink.rail] +cta = "Connect" +later = "Not now" +sub = "Unlocks teams, PDF processor, pipelines, and policies. PDF editing stays free." +title = "Connect your Stirling account" + [portal.accountLink.state] free = "Editor plan" subscribed = "Processor plan" @@ -7018,11 +7039,6 @@ title = "Invoice history" viewAriaLabel = "View invoice {{number}} in Stripe" viewLink = "View ↗" -[portal.billing.linkPrompt] -cta = "Link Stirling account" -description = "Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use." -title = "Link your Stirling account" - [portal.billing.paymentMethod] billedMonthly = "Billed monthly" cardEnding = "{{brand}} ending {{last4}}" @@ -7178,8 +7194,8 @@ label = "Projected to exceed." [portal.billing.spendThisMonth] eyebrow = "Spend this month" -freeRemaining_one = "{{formatted}} free PDF remaining" -freeRemaining_other = "{{formatted}} free PDFs remaining" +freeRemaining_one = "{{formatted}} free credit remaining" +freeRemaining_other = "{{formatted}} free credits remaining" processed_one = "{{formattedCount}} PDF processed." processed_other = "{{formattedCount}} PDFs processed." processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each." @@ -7203,10 +7219,10 @@ eyebrow = "Processor trial" statusLabel_one = "{{used}} used" statusLabel_other = "{{used}} used" sub = "Use the PDF Editor for free. Pay to process PDFs automatically." -title_one = "Process {{allowance}} PDFs free" -title_other = "Process {{allowance}} PDFs free" -titleWithRate_one = "Process {{allowance}} PDFs free, then {{rate}}/PDF" -titleWithRate_other = "Process {{allowance}} PDFs free, then {{rate}}/PDF" +title_one = "{{allowance}} free credit to start" +title_other = "{{allowance}} free credits to start" +titleWithRate_one = "{{allowance}} free credit, then {{rate}} per PDF" +titleWithRate_other = "{{allowance}} free credits, then {{rate}} per PDF" [portal.components.billingUnit] approval = "approval" diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index 2dafb3d07a..ef31ed4f4e 100644 --- a/frontend/editor/src/core/types/appConfig.ts +++ b/frontend/editor/src/core/types/appConfig.ts @@ -22,6 +22,12 @@ export interface AppConfig { premiumEnabled?: boolean; premiumKey?: string; paygEnabled?: boolean; + /** + * Whether this instance can link a Stirling (SaaS) account. False means the account-link + * endpoints are absent (404), which is indistinguishable from "not linked" on the client, so + * anything that prompts to link must gate on this first. + */ + accountLinkAvailable?: boolean; termsAndConditions?: string; privacyPolicy?: string; cookiePolicy?: string; diff --git a/frontend/editor/src/portal/PortalProviders.tsx b/frontend/editor/src/portal/PortalProviders.tsx index a82f1c7405..5008dd8537 100644 --- a/frontend/editor/src/portal/PortalProviders.tsx +++ b/frontend/editor/src/portal/PortalProviders.tsx @@ -5,15 +5,24 @@ import { LinkAccountModal } from "@portal/components/account-link/LinkAccountMod import { AccountLinkProvider } from "@portal/contexts/AccountLinkContext"; import { ConnectCallbackHost } from "@portal/components/account-link/ConnectCallbackHost"; import { PortalChrome } from "@portal/components/PortalChrome"; +import { useConnectPrompt } from "@portal/hooks/useConnectPrompt"; -/** The one and only account-link modal. */ +/** The one and only account-link modal, whichever step it is on. */ function LinkModalHost() { - const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); + const { linkModalOpen, linkModalMode, closeLinkModal, connectOutcome } = + useUI(); + // Ask once a session while the instance is unlinked, rather than waiting to be found. + useConnectPrompt(); + + // Mounted only while open, so closing discards the flow. Kept mounted, an interrupted hand-off + // stays flagged and every later open resumes on the ghost step with no way forward. + if (!linkModalOpen) return null; return ( ); } diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 8e77c20830..c7b87f7388 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -11,6 +11,7 @@ import { Policies } from "@portal/views/Policies"; import { EditorAdmin } from "@portal/views/EditorAdmin"; import { Infrastructure } from "@portal/views/Infrastructure"; import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; +import { ConnectGuardedRoute } from "@portal/components/account-link/ConnectGuardedRoute"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; // Lazy so the generated docs manifest (bundled JSON) lands in its own chunk. @@ -32,13 +33,24 @@ export function ViewRouter() { } /> } /> } /> + {/* Building and editing need a linked account. Gated at the route so every way in is + covered: the list, the Documents review queue, the Connect flow's next steps, and a + typed URL. */} } + element={ + + + + } /> } + element={ + + + + } /> } /> {/* Source create/edit is a modal on the list now; old deep links land there. */} diff --git a/frontend/editor/src/portal/components/ConnectAccountRail.css b/frontend/editor/src/portal/components/ConnectAccountRail.css new file mode 100644 index 0000000000..9f4ad4a77e --- /dev/null +++ b/frontend/editor/src/portal/components/ConnectAccountRail.css @@ -0,0 +1,53 @@ +/* ──────────────────────────────────────────────────────────────────────── */ +/* Connect rail — the ambient prompt above Home's deployment card */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/* Flat and quiet: it sits above the hero every visit until the account is + connected, so it has to be legible without competing with the card below it. */ + +.portal-connect-rail { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.875rem 1rem; + margin-bottom: 0.75rem; + border: 1px solid var(--c-border); + border-radius: 0.5rem; + background: var(--c-surface); +} + +.portal-connect-rail__text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.portal-connect-rail__title { + font-size: 0.875rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-connect-rail__sub { + font-size: 0.8125rem; + color: var(--c-text-muted); +} + +.portal-connect-rail__actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + flex: none; +} + +@media (max-width: 40rem) { + .portal-connect-rail { + flex-direction: column; + align-items: flex-start; + } + + .portal-connect-rail__actions { + margin-left: 0; + } +} diff --git a/frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx b/frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx new file mode 100644 index 0000000000..c813db9b3c --- /dev/null +++ b/frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { ConnectAccountRail } from "@portal/components/ConnectAccountRail"; + +/** + * Both halves of "can link but has not" come from outside the component, and the global mock answers + * app-config without the flag — so a story that wants the rail has to ask for it. + */ +const canLink = { + msw: { + handlers: [ + http.get("/api/v1/config/app-config", () => + HttpResponse.json({ accountLinkAvailable: true }), + ), + ], + }, +}; + +const withLinkState = (state: LinkState) => [ + (Story: () => React.JSX.Element) => ( + + + + ), +]; + +const meta: Meta = { + title: "Portal/AccountLink/ConnectAccountRail", + component: ConnectAccountRail, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** On Home: the ask, plus a way to defer it for this session. */ +export const Default: Story = { + parameters: canLink, + decorators: withLinkState("unlinked"), +}; + +/** Connected, so the rail removes itself. Renders nothing on purpose. */ +export const Hidden: Story = { + parameters: canLink, + decorators: withLinkState("linked-free"), +}; diff --git a/frontend/editor/src/portal/components/ConnectAccountRail.tsx b/frontend/editor/src/portal/components/ConnectAccountRail.tsx new file mode 100644 index 0000000000..9f1e09c0ce --- /dev/null +++ b/frontend/editor/src/portal/components/ConnectAccountRail.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; +import "@portal/components/ConnectAccountRail.css"; + +const DISMISSED_KEY = "portal::connect-rail-dismissed"; + +function readDismissed(): boolean { + try { + return sessionStorage.getItem(DISMISSED_KEY) === "true"; + } catch { + return false; + } +} + +/** Session-scoped dismissal, so the ask comes back until it is answered rather than for good. */ +export function ConnectAccountRail() { + const { t } = useTranslation(); + const { gated, loading, connect } = useConnectGate(); + const [dismissed, setDismissed] = useState(readDismissed); + + if (loading || !gated || dismissed) return null; + + const dismiss = () => { + try { + sessionStorage.setItem(DISMISSED_KEY, "true"); + } catch { + // Storage refusing is no reason to leave the rail stuck on screen. + } + setDismissed(true); + }; + + return ( +
+
+ + {t("portal.accountLink.rail.title", "Connect your Stirling account")} + + + {t( + "portal.accountLink.rail.sub", + "Unlocks teams, PDF processor, pipelines, and policies. PDF editing stays free.", + )} + +
+
+ + +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/HomeHero.tsx b/frontend/editor/src/portal/components/HomeHero.tsx index 954eac9908..f2fdd36b51 100644 --- a/frontend/editor/src/portal/components/HomeHero.tsx +++ b/frontend/editor/src/portal/components/HomeHero.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { Skeleton } from "@app/ui"; import { useUI } from "@portal/contexts/UIContext"; import { EditorStatusCard } from "@portal/components/EditorStatusCard"; +import { ConnectAccountRail } from "@portal/components/ConnectAccountRail"; import { ControlledDealStatusHero } from "@portal/components/procurement/ProcurementBanner"; import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; import { useProcurement } from "@portal/components/procurement/useProcurement"; @@ -28,6 +29,7 @@ export function HomeHero() { return ( <> + {procurement.loading ? ( // Hold the rail's shape rather than committing to a footer: branching before the snapshot // lands paints the no-deal rail first, flashing on every refresh of an active deal. diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 2895ad4ec4..6d4cad1945 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, @@ -43,6 +44,7 @@ export function Sidebar() { const { displayName, profilePictureUrl } = useAccountIdentity(); const credits = useFreeCreditsSummary(); const openPlan = useOpenPlan(); + const { gated, connect } = useConnectGate(); // Collapse is a desktop-only affordance: on mobile the sidebar is an // off-canvas drawer, so the icon-rail state never applies there. @@ -67,6 +69,9 @@ export function Sidebar() { closeMobileNav(); if (entry.externalUrl) { window.open(entry.externalUrl, "_blank", "noopener,noreferrer"); + } else if (entry.requiresLink && gated) { + // Ask here: navigating first strands them on a page with nothing on it. + connect(); } else { setActiveView(id as ViewId); } diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx index fab501da4a..2fcbfdea0f 100644 --- a/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx @@ -1,21 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { Modal } from "@app/ui"; -import { PORTAL_BASENAME } from "@app/routes/portalBasename"; -import { withBasePath } from "@app/constants/app"; -import { - completeConnect, - startConnect, - type ConnectPhase, -} from "@portal/api/link"; +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { completeConnect, type ConnectPhase } from "@portal/api/link"; import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; import { useAccountLinkContext } from "@portal/contexts/AccountLinkContext"; -import { - ConnectCallbackView, - type ConnectCallbackState, -} from "@portal/components/account-link/ConnectCallbackView"; -import "@portal/views/ConnectCallback.css"; +import { useUI } from "@portal/contexts/UIContext"; +import type { ConnectCallbackState } from "@portal/components/account-link/ConnectCallbackView"; /** What the callback route hands over, read from the URL fragment before stripping it. */ export interface AccountLinkReturn { @@ -30,41 +19,23 @@ interface LocationState { } /** - * Finishes the handshake and reports the outcome, over the portal the admin - * started from. - * - * Mounted alongside the other portal-wide modal rather than being its own route: - * the result is a step in a task, so the page behind it should still be there. + * Renders nothing: the result belongs on step 3 of the dialog the admin left, so this publishes the + * outcome and the single dialog host reopens there. Mounted app-wide because the callback route + * only reads the fragment and navigates, so it is gone by the time there is an outcome. */ export function ConnectCallbackHost() { const location = useLocation(); - const navigate = useNavigate(); - const { t } = useTranslation(); const { refresh } = useAccountLinkContext(); + const { publishConnectOutcome } = useUI(); const handover = (location.state as LocationState | null)?.accountLinkReturn; - const [state, setState] = useState(null); - const [sessionRestored, setSessionRestored] = useState(false); - const nonceRef = useRef(null); const startedRef = useRef(false); - const finish = useCallback( - async (nonce: string) => { - setState("working"); - try { - const outcome = toViewState((await completeConnect(nonce)).phase); - setState(outcome); - // The portal read its status on mount, before this existed. Without this - // the page behind the modal still says unlinked until a reload. - if (outcome === "linked") await refresh(); - } catch { - // Could not reach our own backend. The handshake is still open, so this - // is worth another attempt rather than a restart. - setState("retry"); - } - }, - [refresh], - ); + // Refs so the effect runs on the hand-over alone: it consumes a single-use nonce. + const publishRef = useRef(publishConnectOutcome); + publishRef.current = publishConnectOutcome; + const refreshRef = useRef(refresh); + refreshRef.current = refresh; useEffect(() => { if (!handover || startedRef.current) return; @@ -72,17 +43,16 @@ export function ConnectCallbackHost() { const { type, nonce, accessToken, refreshToken } = handover; if (type !== "link" || !nonce) { - setState("malformed"); + publishRef.current({ state: "malformed", sessionRestored: false }); return; } - nonceRef.current = nonce; void (async () => { + let sessionRestored = false; if (accessToken && refreshToken) { try { const supabase = ensureSaasSupabase(); - // Logged, not swallowed: silently this resurfaces later as "session - // expired" on the usage page, with nothing tying it back here. + // Logged, not swallowed: this resurfaces later as "session expired" otherwise. if (!supabase) { console.warn( "[account-link] no Supabase client: VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY are not set for this build", @@ -95,7 +65,7 @@ export function ConnectCallbackHost() { if (error) { console.warn("[account-link] setSession failed:", error.message); } else { - setSessionRestored(true); + sessionRestored = true; } } } catch (e) { @@ -106,68 +76,36 @@ export function ConnectCallbackHost() { "[account-link] callback carried no tokens; the approval page had no session to pass", ); } - await finish(nonce); + + await claim(nonce, sessionRestored); })(); - }, [handover, finish]); - /** - * Retry means different things either side of a still-valid handshake: finish the one we have, or open a new one when it is past saving. - */ - const onRetry = useCallback(() => { - if (state === "retry" && nonceRef.current) { - void finish(nonceRef.current); - return; + /** Passes itself as {@code reclaim} so Try again re-claims rather than opening a handshake. */ + async function claim(nonce: string, sessionRestored: boolean) { + publishRef.current({ state: "working", sessionRestored }); + const again = () => void claim(nonce, sessionRestored); + try { + const state = toViewState((await completeConnect(nonce)).phase); + publishRef.current({ + state, + sessionRestored, + reclaim: state === "retry" ? again : undefined, + }); + // Without this the page behind the dialog says unlinked until a reload. + if (state === "linked") await refreshRef.current(); + } catch { + // Our own backend is unreachable; the handshake is untouched, so retrying beats restarting. + publishRef.current({ state: "retry", sessionRestored, reclaim: again }); + } } - setState("working"); - // Same callback the modal sends. Without it the backend falls back to the bare - // origin, which drops the app's base path and lands the return on nothing. - void startConnect( - window.location.hostname, - new URL( - withBasePath("/account-link/callback"), - window.location.origin, - ).toString(), - ) - .then((status) => { - if (status.authorizeUrl) { - window.location.assign(status.authorizeUrl); - } else { - setState("rejected"); - } - }) - .catch(() => setState("retry")); - }, [state, finish]); + }, [handover]); - // Drops the handover with it, so a back navigation does not reopen the result. - const done = useCallback(() => { - setState(null); - navigate(PORTAL_BASENAME, { replace: true }); - }, [navigate]); - - if (!state) return null; - - return ( - - - - ); + return null; } /** - * PENDING and UNAVAILABLE collapse into one "try again" state: both mean the handshake is intact but unfinished, which is the same thing to do about it. + * PENDING and UNAVAILABLE collapse into one "try again" state: both mean the handshake is intact but + * unfinished, which is the same thing to do about it. */ function toViewState(phase: ConnectPhase): ConnectCallbackState { switch (phase) { diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx index 4a22592e45..ae9e268849 100644 --- a/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx @@ -1,5 +1,7 @@ import { useTranslation } from "react-i18next"; -import { Banner, Button, Spinner } from "@app/ui"; +import { Banner, Spinner } from "@app/ui"; +import { ConnectDoneSlide } from "@portal/components/account-link/connect/ConnectDoneSlide"; +import "@portal/components/account-link/connect/connect.css"; /** Outcomes of returning from the approval page. */ export type ConnectCallbackState = @@ -10,26 +12,39 @@ export type ConnectCallbackState = | "rejected" | "malformed"; -export interface ConnectCallbackViewProps { +export interface ConnectOutcome { state: ConnectCallbackState; /** True once the SaaS session landed, regardless of how the link itself went. */ sessionRestored: boolean; - onRetry: () => void; + /** Present only while the handshake is still open, so a retry re-claims rather than opening one. */ + reclaim?: () => void; +} + +export function isRetryableOutcome(state: ConnectCallbackState): boolean { + return state !== "linked" && state !== "malformed"; +} + +export interface ConnectCallbackViewProps { + state: ConnectCallbackState; + sessionRestored: boolean; onDone: () => void; } -/** Presentation for the account-link callback. */ +/** + * Five states in one step: a failed link is still step 3 of the flow they started, and a separate + * error dialog would discard the progress bar that says where they are. Actions live in the + * dialog's footer, not here, so they stay where steps 1 and 2 put them. + */ export function ConnectCallbackView({ state, sessionRestored, - onRetry, onDone, }: ConnectCallbackViewProps) { const { t } = useTranslation(); if (state === "working") { return ( -
+

{t( @@ -44,21 +59,14 @@ export function ConnectCallbackView({ if (state === "linked") { return (

- +

{t( - "portal.accountLink.connect.callback.linked.body", - "This server is connected to your Stirling account.", + "portal.accountLink.connect.done.lede", + "This server now runs against your Stirling account.", )} - - {/* The inverse of the failure note below: the link took but the sign-in did - not, which otherwise only shows up later as "session expired" on a page - that gives no hint the two are related. */} +

+ {/* Link took, sign-in did not: otherwise this resurfaces later as "session expired" with + nothing tying it back here. */} {sessionRestored ? null : (

{t( @@ -67,22 +75,19 @@ export function ConnectCallbackView({ )}

)} - +
); } - const { tone, title, body, retryable } = failure(state, t); + const { tone, title, body } = failure(state, t); return (
{body} - {/* The SaaS sign-in and the server link are separate outcomes. Say so when - one worked and the other did not, or the admin re-runs the whole thing - to fix a problem that is already half solved. */} + {/* Two separate outcomes: without this the admin re-runs the lot to fix a half-solved + problem. */} {sessionRestored ? (

{t( @@ -91,11 +96,6 @@ export function ConnectCallbackView({ )}

) : null} -
); } @@ -115,7 +115,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.expired.body", "Connection requests are short lived. Start another one.", ), - retryable: true, }; case "rejected": return { @@ -128,7 +127,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.rejected.body", "This request was declined or has already been used. Start another one if that was not intended.", ), - retryable: true, }; case "malformed": return { @@ -141,7 +139,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.malformed.body", "This page was opened without a valid connection response. Start the connection from settings.", ), - retryable: false, }; default: return { @@ -156,7 +153,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.unfinished.body", "Stirling did not confirm the connection. This is usually temporary.", ), - retryable: true, }; } } diff --git a/frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx b/frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx new file mode 100644 index 0000000000..e5bd45eff9 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx @@ -0,0 +1,26 @@ +import { useEffect, type ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +interface Props { + children: ReactNode; + fallback: string; +} + +/** + * At the route, not on the buttons: the pipeline builder is reachable from its list, the Documents + * queue, the connect flow's next steps and a typed URL, and a guard per entry point is one more to + * remember each time someone adds a link. + */ +export function ConnectGuardedRoute({ children, fallback }: Props) { + const { gated, loading, connect } = useConnectGate(); + + useEffect(() => { + if (gated) connect(); + }, [gated, connect]); + + // Unknown is not gated: bouncing first would throw a linked admin off a page they are entitled to. + if (loading) return null; + if (gated) return ; + return <>{children}; +} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.css b/frontend/editor/src/portal/components/account-link/LinkAccountModal.css deleted file mode 100644 index bc96172f0a..0000000000 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.css +++ /dev/null @@ -1,26 +0,0 @@ -/* Connect-account modal. Imported by the component rather than relying on the - account-link view's stylesheet: this modal is mounted at the app root, so it - renders on pages that never import that view. */ - -.portal-link__modal-body { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.portal-link__steps { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin: 0; - padding-left: 1.25rem; - font-size: 0.875rem; - line-height: 1.5; - color: var(--c-text-muted); -} - -.portal-link__modal-actions { - display: flex; - justify-content: flex-end; - gap: 0.5rem; -} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx index 018d435b31..515b740e59 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx @@ -1,34 +1,66 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { act, render, waitFor } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; -/** The modal every "link account" CTA in the portal opens. */ -const { startConnect, startReauth } = vi.hoisted(() => ({ +/** The step machine: what drives each step, and what must not skip or repeat one. */ +const { startConnect, startReauth, fetchWallet, EMAIL } = vi.hoisted(() => ({ startConnect: vi.fn(), startReauth: vi.fn(), + fetchWallet: vi.fn(), + EMAIL: "admin@acme.example", })); vi.mock("@portal/api/link", () => ({ startConnect, startReauth })); +vi.mock("@portal/api/billing", () => ({ fetchWallet })); vi.mock("@portal/auth/saasSupabase", () => ({ isSaasSupabaseConfigured: true, + // Step 3 reads the connected account's email off this session. + ensureSaasSupabase: () => ({ + auth: { + getSession: () => + Promise.resolve({ data: { session: { user: { email: EMAIL } } } }), + }, + }), })); import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; +import type { ConnectOutcome } from "@portal/components/account-link/ConnectCallbackView"; +import { freeWallet } from "@portal/components/billing/walletFixtures"; const AUTHORIZE = "http://localhost:5174/link?request=req-1"; -function renderModal(mode?: "link" | "reauth") { +const BENEFITS = "Pipelines, policies, sources and audit"; +const GHOST = /Taking you to stirling\.com/; +const CONNECT = /Connect Stirling account/; + +function renderModal( + mode?: "link" | "reauth", + outcome: ConnectOutcome | null = null, +) { return render( - - {}} mode={mode} /> - , + + + {}} + mode={mode} + outcome={outcome} + /> + + , ); } -/** Clicks the primary action (the secondary one is Cancel). */ -function clickContinue(getAllByRole: (role: string) => HTMLElement[]) { - const buttons = getAllByRole("button"); - act(() => buttons[buttons.length - 1].click()); +function click(label: string | RegExp) { + act(() => screen.getByRole("button", { name: label }).click()); +} + +/** Read off the body because the dialog portals out; the badge itself is uninterpolated here. */ +function filledSteps(): number { + return document.body.querySelectorAll( + ".portal-stepmodal__progress .is-filled", + ).length; } describe("LinkAccountModal", () => { @@ -36,6 +68,7 @@ describe("LinkAccountModal", () => { beforeEach(() => { vi.clearAllMocks(); + fetchWallet.mockResolvedValue(freeWallet); startConnect.mockResolvedValue({ phase: "PENDING", authorizeUrl: AUTHORIZE, @@ -54,28 +87,39 @@ describe("LinkAccountModal", () => { value: { origin: "http://localhost:5173", hostname: "localhost", + href: "http://localhost:5173/app", + search: "", assign, }, }); }); + it("opens on the pitch, and asks nothing of the backend until told", () => { + renderModal(); + + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(screen.getByRole("button", { name: CONNECT })).toBeTruthy(); + expect(filledSteps()).toBe(1); + expect(startConnect).not.toHaveBeenCalled(); + }); + it("offers no sign-in form, because a sign-in started here cannot complete", () => { const { container } = renderModal(); + click(CONNECT); - // The provider buttons this modal used to carry sent the admin to Stirling and - // abandoned them there. Nothing should collect credentials on this origin. + // A sign-in started on this origin cannot complete, so nothing here may collect credentials. expect(container.querySelector("input[type=password]")).toBeNull(); expect(container.querySelector("input[type=email]")).toBeNull(); }); - it("starts a link handshake and hands the browser to Stirling", async () => { - const { getAllByRole } = renderModal(); - - clickContinue(getAllByRole); + it("hands over on the first click, showing the ghost while it goes", async () => { + renderModal(); + click(CONNECT); await waitFor(() => expect(startConnect).toHaveBeenCalled()); - // Callback built from this page's own origin, which the backend then checks - // against the request's Origin header. + expect(screen.getByText(GHOST)).toBeTruthy(); + expect(filledSteps()).toBe(2); + // The backend checks this against the request's Origin header. expect(startConnect).toHaveBeenCalledWith( "localhost", "http://localhost:5173/account-link/callback", @@ -84,13 +128,16 @@ describe("LinkAccountModal", () => { expect(startReauth).not.toHaveBeenCalled(); }); - it("uses the reauth endpoint when only the session needs renewing", async () => { - const { getAllByRole } = renderModal("reauth"); + it("uses the reauth endpoint, with no pitch and no steps", async () => { + renderModal("reauth"); - clickContinue(getAllByRole); + // A server that is already connected is not sold anything. + expect(screen.queryByText(BENEFITS)).toBeNull(); + expect(screen.queryByText(/Step 1 of 3/)).toBeNull(); - // A different endpoint on purpose: reauth presents the device credential so - // Stirling pins the handshake to the team that already owns this server. + click(/Sign in again/); + + // A different endpoint: reauth presents the credential, so the team is pinned server-side. await waitFor(() => expect(startReauth).toHaveBeenCalledWith( "http://localhost:5173/account-link/callback", @@ -100,14 +147,18 @@ describe("LinkAccountModal", () => { await waitFor(() => expect(assign).toHaveBeenCalledWith(AUTHORIZE)); }); - it("stays put and explains itself when the handshake cannot start", async () => { + it("falls back to step 1 with the reason when the handshake cannot start", async () => { startConnect.mockRejectedValue(new Error("offline")); - const { getAllByRole } = renderModal(); - clickContinue(getAllByRole); + renderModal(); + click(CONNECT); await waitFor(() => expect(startConnect).toHaveBeenCalled()); expect(assign).not.toHaveBeenCalled(); + // The ghost unmounts when the request settles, so the reason lands on step 1. + expect(await screen.findByText(/outbound network access/)).toBeTruthy(); + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(filledSteps()).toBe(1); }); it("does not navigate when there is nothing to navigate to", async () => { @@ -119,10 +170,85 @@ describe("LinkAccountModal", () => { teamId: 7, }); - const { getAllByRole } = renderModal(); - clickContinue(getAllByRole); + renderModal(); + click(CONNECT); await waitFor(() => expect(startConnect).toHaveBeenCalled()); expect(assign).not.toHaveBeenCalled(); }); + + /** Busy is never cleared on success, because the page was meant to be gone. */ + describe("coming back from a hand-off that never completed", () => { + it("clears the in-flight flag when the page is shown again", async () => { + renderModal(); + click(CONNECT); + + await waitFor(() => expect(screen.getByText(GHOST)).toBeTruthy()); + + act(() => { + window.dispatchEvent(new Event("pageshow")); + }); + + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(filledSteps()).toBe(1); + }); + + /** + * Not the close path itself (the host unmounts, so a fresh mount is clean by construction) but + * the property behind it: hoist the flag into UIContext and the trap returns, failing here. + */ + it("keeps the in-flight flag local, so a fresh mount cannot inherit one", async () => { + const first = renderModal(); + click(CONNECT); + await waitFor(() => expect(screen.getByText(GHOST)).toBeTruthy()); + + first.unmount(); + renderModal(); + + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(screen.queryByText(GHOST)).toBeNull(); + }); + }); + + describe("resuming after the round trip", () => { + it("lands on step 3 rather than restarting the pitch", async () => { + renderModal("link", { state: "linked", sessionRestored: true }); + + expect( + await screen.findByText(/now runs against your Stirling account/), + ).toBeTruthy(); + expect(screen.queryByText(BENEFITS)).toBeNull(); + // Left on 2 of 3, arrived on 3: the whole reason the bar spans the redirect. + expect(filledSteps()).toBe(3); + expect(await screen.findByText(EMAIL)).toBeTruthy(); + expect(await screen.findByText("Invite your team")).toBeTruthy(); + }); + + it("opens a fresh handshake for a spent one, showing the ghost again", async () => { + renderModal("link", { state: "expired", sessionRestored: false }); + + expect(await screen.findByText("Request expired")).toBeTruthy(); + click(/Try again/); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + // Busy outranks the stale outcome, or they sit on "Request expired" until the browser goes. + expect(screen.getByText(GHOST)).toBeTruthy(); + }); + + it("offers no retry while the claim is still in flight", async () => { + renderModal("link", { state: "working", sessionRestored: false }); + + expect(await screen.findByText(/Finishing the connection/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Try again/ })).toBeNull(); + }); + + it("does not offer a retry for a response it could not read", async () => { + renderModal("link", { state: "malformed", sessionRestored: false }); + + expect( + await screen.findByText(/Could not read the response/), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Try again/ })).toBeNull(); + }); + }); }); diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx index 8bd47a9eb4..2b6315eb17 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx @@ -1,143 +1,205 @@ -import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Banner, Button, Modal } from "@app/ui"; -import { withBasePath } from "@app/constants/app"; -import { startConnect, startReauth } from "@portal/api/link"; -import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; -import "@portal/components/account-link/LinkAccountModal.css"; +import { Button } from "@app/ui"; +import { FlowModal } from "@portal/components/shared/FlowModal"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; +import { ConnectAskStep } from "@portal/components/account-link/connect/ConnectAskStep"; +import { ConnectHandoffGhost } from "@portal/components/account-link/connect/ConnectHandoffGhost"; +import { + ConnectCallbackView, + isRetryableOutcome, + type ConnectOutcome, +} from "@portal/components/account-link/ConnectCallbackView"; +import { useConnectHandoff } from "@portal/hooks/useConnectHandoff"; +import "@portal/views/ConnectCallback.css"; + +/** + * Ordered, so a step's position in this list is its number and the list's length is the total. + * Adding or removing a step means editing this and its arm of `stepBody`, nothing else. + */ +const STEP_ORDER = ["ask", "handoff", "outcome"] as const; + +type StepId = (typeof STEP_ORDER)[number]; interface Props { open: boolean; onClose: () => void; - /** - * "link" connects this server to a team for the first time; "reauth" only re-establishes the browser's Stirling session for a server that is already linked. - */ + /** "reauth" only re-establishes the browser session, so it stays one step with no pitch. */ mode?: "link" | "reauth"; + /** Published by the callback route; present means the admin is returning from Stirling. */ + outcome?: ConnectOutcome | null; } -/** Sends the admin off to Stirling to connect this server. */ -export function LinkAccountModal({ open, onClose, mode = "link" }: Props) { +/** + * The progress bar spans the redirect on purpose: the admin leaves on the hand-off and returns on + * the outcome step of the dialog they left, rather than being greeted by a different one. + */ +export function LinkAccountModal({ + open, + onClose, + mode = "link", + outcome = null, +}: Props) { const { t } = useTranslation(); const reauth = mode === "reauth"; - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const handoff = useConnectHandoff(reauth); - const begin = useCallback(async () => { - setBusy(true); - setError(null); - try { - const callbackUrl = new URL( - withBasePath("/account-link/callback"), - window.location.origin, - ).toString(); - const status = reauth - ? await startReauth(callbackUrl) - : await startConnect(window.location.hostname, callbackUrl); - if (status.authorizeUrl) { - window.location.assign(status.authorizeUrl); - return; - } - // Already linked, or a handshake we cannot act on. Nothing to navigate to. - setError( - t( - "portal.accountLink.modal.noAuthorizeUrl", - "Stirling did not return somewhere to continue. Try again in a moment.", + // Busy outranks a stale outcome, or a retry sits on the old result until the browser leaves. + let step: StepId = "ask"; + if (handoff.busy) step = "handoff"; + else if (outcome) step = "outcome"; + + const title = stepTitle(); + const current = STEP_ORDER.indexOf(step) + 1; + + // Re-auth is one step, so it carries no count and no progress bar. + const stepChrome = reauth + ? {} + : { + step: current, + total: STEP_ORDER.length, + stepLabel: t( + "portal.accountLink.connect.step", + "Step {{current}} of {{total}}", + { current, total: STEP_ORDER.length }, ), - ); - } catch { - setError( - t( - "portal.accountLink.modal.startFailed", - "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again.", - ), - ); - } finally { - setBusy(false); - } - }, [reauth, t]); + }; return ( - -
-
    -
  1. - {t( - "portal.accountLink.modal.step1", - "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on.", - )} -
  2. -
  3. - {t( - "portal.accountLink.modal.step2", - "You check this server's address and approve it. A team owner has to do this the first time.", - )} -
  4. -
  5. - {t( - "portal.accountLink.modal.step3", - "Stirling brings you straight back here and finishes up.", - )} -
  6. -
- - {!isSaasSupabaseConfigured && ( - - {t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "} - VITE_SUPABASE_URL{" "} - {t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "} - VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "} - {t( - "portal.accountLink.modal.loginNotConfigured.after", - "so this server can finish the connection when you come back.", - )} - - )} - - {error && {error}} - -
- - -
-
-
+ + {stepBody()} + ); + + function stepTitle(): string { + if (reauth) { + return t("portal.accountLink.modal.reauthTitle", "Sign in again"); + } + if (step === "ask") { + return t( + "portal.accountLink.modal.linkTitle", + "Connect your Stirling account", + ); + } + if (step === "handoff") { + return t("portal.accountLink.connect.handoff.title", "Connecting"); + } + if (outcome?.state === "linked") { + return t("portal.accountLink.connect.done.title", "Connected"); + } + return t("portal.accountLink.connect.done.pendingTitle", "Almost there"); + } + + function stepBody() { + switch (step) { + case "ask": + return ; + case "handoff": + return ; + case "outcome": + return outcome ? ( + + ) : null; + } + } + + function closeButton() { + return ( + + ); + } + + function retryButton(onRetry: () => void) { + return ( + + ); + } + + function stepFooter() { + if (step === "ask") { + const dismiss = reauth + ? t("portal.accountLink.modal.cancel", "Cancel") + : t("portal.accountLink.connect.notNow", "Not now"); + const start = reauth + ? t("portal.accountLink.modal.continueReauth", "Sign in again") + : t("portal.accountLink.connect.start", "Connect Stirling account"); + return ( + <> + + + + ); + } + + // The request is out and the browser is leaving; Close so a stall is not a dead end. + if (step === "handoff") { + return ( + <> + + {closeButton()} + + ); + } + + // A retry over a call that has not answered is how you get two handshakes. + if (outcome?.state === "working") { + return ( + <> + + {closeButton()} + + ); + } + + // Still open: re-claim rather than spend the approval a leader gave by hand. + if (outcome?.reclaim) { + return ( + <> + {closeButton()} + {retryButton(outcome.reclaim)} + + ); + } + + if (outcome && isRetryableOutcome(outcome.state)) { + return ( + <> + {closeButton()} + {retryButton(handoff.begin)} + + ); + } + + return ( + <> + + + + ); + } } diff --git a/frontend/editor/src/portal/components/account-link/LinkGate.tsx b/frontend/editor/src/portal/components/account-link/LinkGate.tsx deleted file mode 100644 index d5743d0041..0000000000 --- a/frontend/editor/src/portal/components/account-link/LinkGate.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import { Banner, Button } from "@app/ui"; -import { useLink } from "@portal/contexts/LinkContext"; -import { useUI } from "@portal/contexts/UIContext"; - -interface Props { - /** The billable feature — rendered only when the org is linked. */ - children: ReactNode; - /** Feature name for the lock copy, e.g. "AI extraction". */ - feature?: string; -} - -/** - * Gates billable features on the account-link state. When the org is unlinked it - * renders a "link to unlock" prompt instead of the feature; once linked (free or - * subscribed) the children render. Drop this around any surface that should only - * work against a linked SaaS wallet. - */ -export function LinkGate({ children, feature }: Props) { - const { t } = useTranslation(); - const { featuresUnlocked } = useLink(); - const { openLinkModal } = useUI(); - - if (featuresUnlocked) return <>{children}; - - return ( - openLinkModal()}> - {t("portal.accountLink.gate.action", "Link account")} - - } - /> - ); -} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx new file mode 100644 index 0000000000..7c795645b7 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; +import { Banner } from "@app/ui"; +import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; +import { ConnectBenefitsSlide } from "@portal/components/account-link/connect/ConnectBenefitsSlide"; +import "@portal/components/account-link/connect/connect.css"; + +interface Props { + /** Re-auth says why it is being asked; a first link is pitched instead. */ + reauth: boolean; + /** A hand-off that failed to start drops back here, so this is where its reason belongs. */ + error?: string | null; +} + +export function ConnectAskStep({ reauth, error }: Props) { + const { t } = useTranslation(); + + return ( + <> + {reauth ? ( +

+ {t( + "portal.accountLink.connect.handoff.reauthLede", + "Your Stirling session expired. Signing in again keeps usage and billing visible. This server stays connected either way.", + )} +

+ ) : ( + + )} + + {!isSaasSupabaseConfigured && ( + + {t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "} + VITE_SUPABASE_URL{" "} + {t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "} + {t( + "portal.accountLink.modal.loginNotConfigured.after", + "so this server can finish the connection when you come back.", + )} + + )} + + {error && {error}} + + ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx new file mode 100644 index 0000000000..bdad6f32b0 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConnectBenefitsSlide } from "@portal/components/account-link/connect/ConnectBenefitsSlide"; + +const meta: Meta = { + title: "Portal/AccountLink/Connect/BenefitsSlide", + component: ConnectBenefitsSlide, +}; +export default meta; +type Story = StoryObj; + +/** Step 1 of the Connect flow: the case for linking. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx new file mode 100644 index 0000000000..a0258f7802 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from "react-i18next"; +import "@portal/components/account-link/connect/connect.css"; + +/** + * Processor is one row naming its parts rather than four competing ones, and credits come last: + * first, and the screen reads as a price list. + * + *

TODO(#7712): the credits row promises a monthly allowance the billing model does not grant — + * {@code freeGrantUnits} is a one-time lifetime pool — so either the grant becomes recurring or the + * copy drops "per month" before this reaches customers. + */ +export function ConnectBenefitsSlide() { + const { t } = useTranslation(); + + const unlocks: { key: string; label: string; detail: string }[] = [ + { + key: "processor", + label: t( + "portal.accountLink.connect.benefits.processorLabel", + "Processor", + ), + detail: t( + "portal.accountLink.connect.benefits.processorDetail", + "Pipelines, policies, sources and audit", + ), + }, + { + key: "teams", + label: t("portal.accountLink.connect.benefits.teamsLabel", "Teams"), + detail: t( + "portal.accountLink.connect.benefits.teamsDetail", + "Free for up to 5 users", + ), + }, + { + key: "credits", + label: t("portal.accountLink.connect.benefits.creditsLabel", "Credits"), + detail: t( + "portal.accountLink.connect.benefits.creditsDetail", + "500 free per month", + ), + }, + ]; + + return ( +

+ {unlocks.map((unlock) => ( +
+
{unlock.label}
+
{unlock.detail}
+
+ ))} +
+ ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx new file mode 100644 index 0000000000..fa57d33fbf --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; + +/** + * The figure is checkable against a real account, so it must be the wallet's and never a hardcoded + * 500. The upgrade row appears only on a nearly spent trial: a prompt to pay is the wrong note on a + * screen confirming a free connection. + */ +const { fetchWallet } = vi.hoisted(() => ({ fetchWallet: vi.fn() })); +vi.mock("@portal/api/billing", () => ({ fetchWallet })); + +import { ConnectDoneSlide } from "@portal/components/account-link/connect/ConnectDoneSlide"; +import { freeWallet } from "@portal/components/billing/walletFixtures"; + +/** The shared fixture, wound down to the balance under test. */ +const wallet = (freeRemaining: number) => ({ + ...freeWallet, + freeRemaining, + billableUsed: freeWallet.freeAllowance - freeRemaining, +}); + +const SWITCH_ON = /Switch on the Processor/; + +const renderDone = () => + render( + + + {}} /> + + , + ); + +describe("ConnectDoneSlide", () => { + it("shows the wallet's remaining balance, not a fixed grant", async () => { + fetchWallet.mockResolvedValue(wallet(128)); + renderDone(); + await waitFor(() => expect(screen.getByText("128")).toBeTruthy()); + }); + + it("shows a spent grant as zero rather than hiding it", async () => { + fetchWallet.mockResolvedValue(wallet(0)); + renderDone(); + await waitFor(() => expect(screen.getByText("0")).toBeTruthy()); + }); + + it("omits the meter entirely when the wallet cannot be read", async () => { + fetchWallet.mockRejectedValue(new Error("not linked")); + renderDone(); + await waitFor(() => + expect(screen.getByText("Invite your team")).toBeTruthy(), + ); + expect(document.querySelector(".paygf-meter")).toBeNull(); + expect(screen.queryByText(SWITCH_ON)).toBeNull(); + }); + + it("asks the admin to switch the Processor on once the trial is nearly gone", async () => { + fetchWallet.mockResolvedValue(wallet(40)); + renderDone(); + await waitFor(() => expect(screen.getByText(SWITCH_ON)).toBeTruthy()); + const rows = [...document.querySelectorAll(".portal-connect__next-item")]; + expect(rows[0]?.textContent).toMatch(SWITCH_ON); + }); + + it("leaves the upgrade unmentioned while there is trial left to use", async () => { + fetchWallet.mockResolvedValue(wallet(500)); + renderDone(); + await waitFor(() => + expect(document.querySelector(".paygf-meter")).toBeTruthy(), + ); + expect(screen.queryByText(SWITCH_ON)).toBeNull(); + }); + + it("always offers the next steps", async () => { + fetchWallet.mockResolvedValue(wallet(500)); + renderDone(); + await waitFor(() => + expect(screen.getByText("Invite your team")).toBeTruthy(), + ); + expect(screen.getByText("Set up a pipeline")).toBeTruthy(); + expect(screen.getByText("Add a policy")).toBeTruthy(); + }); +}); diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx new file mode 100644 index 0000000000..4854048ab8 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { MeterBar, remainingMeter } from "@app/billing"; +import { fetchWallet, type Wallet } from "@portal/api/billing"; +import { useLinkedAccountEmail } from "@portal/hooks/useLinkedAccountEmail"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +import "@portal/components/billing/billing.css"; +import "@portal/components/account-link/connect/connect.css"; + +interface Props { + /** Closes the dialog first, so a next step does not land behind the overlay. */ + onNavigate: () => void; +} + +/** Below this the meter grows an upgrade row; above it the meter is information, not a prompt. */ +const LOW_CREDITS = 100; + +function Chevron() { + return ( + + + + ); +} + +/** + * The bar without the plan card around it: at dialog width that card's headline and price wrapped + * over four lines. Upgrading is a row rather than a button on the bar for the same reason, and it + * goes to Usage rather than starting checkout, which already lives there with its quotes and + * resumable bundle. + */ +export function ConnectDoneSlide({ onNavigate }: Props) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const email = useLinkedAccountEmail(); + const [wallet, setWallet] = useState(null); + + useEffect(() => { + let cancelled = false; + void fetchWallet() + .then((w) => { + if (!cancelled) setWallet(w); + }) + .catch(() => { + // The wallet needs a live SaaS session and a team that has finished provisioning. Neither + // is guaranteed the instant a link completes, and neither is worth blocking this screen on. + }); + return () => { + cancelled = true; + }; + }, []); + + const go = (path: string) => { + onNavigate(); + navigate(path); + }; + + const lowOnCredits = wallet != null && wallet.freeRemaining < LOW_CREDITS; + + const nextSteps: { key: string; label: string; path: string }[] = [ + ...(lowOnCredits + ? [ + { + key: "processor", + label: t( + "portal.accountLink.connect.done.switchOnProcessor", + "Switch on the Processor", + ), + path: toPortalPath(VIEW_PATHS.usage), + }, + ] + : []), + { + key: "team", + label: t( + "portal.accountLink.connect.done.inviteTeam", + "Invite your team", + ), + path: toPortalPath(VIEW_PATHS.users), + }, + { + key: "pipeline", + label: t( + "portal.accountLink.connect.done.buildPipeline", + "Set up a pipeline", + ), + path: `${toPortalPath(VIEW_PATHS.pipelines)}/new`, + }, + { + key: "policy", + label: t("portal.accountLink.connect.done.addPolicy", "Add a policy"), + path: toPortalPath(VIEW_PATHS.policies), + }, + ]; + + return ( + <> + {wallet && ( +
+ {/* No status chip: its tone goes red on an exhausted trial, which on a success screen + reads as something having gone wrong. */} + +
+ )} + + {/* Proves it landed on the account they meant. Read from the session the callback deposited, + so it is absent exactly when that hand-off failed — which the note above already says. */} + {email && ( +
+ + {t("portal.accountLink.connect.done.accountLabel", "Account")} + + {email} +
+ )} + +
    + {nextSteps.map((step) => ( +
  • + +
  • + ))} +
+ + ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx new file mode 100644 index 0000000000..251652de73 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next"; +import { Skeleton } from "@app/ui"; +import "@portal/components/account-link/connect/connect.css"; + +/** + * A ghost rather than a screen: the admin has already decided. It earns its place when the local + * backend is slow to open the handshake, where a blank dialog would look broken. + */ +export function ConnectHandoffGhost() { + const { t } = useTranslation(); + + return ( +
+

+ {t( + "portal.accountLink.connect.handoff.going", + "Taking you to stirling.com", + )} +

+ +
+ + + +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/connect.css b/frontend/editor/src/portal/components/account-link/connect/connect.css new file mode 100644 index 0000000000..393695bb0a --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/connect.css @@ -0,0 +1,112 @@ +/* Connect flow, the three account-link steps. + + Deliberately flat: bordered rows and plain type, not icon tiles or coloured + cards. FlowModal stacks and spaces the body, so these blocks own only their + own internals. */ + +.portal-connect__lede { + margin: 0; + font-size: 0.875rem; + line-height: 1.55; + color: var(--c-text-muted); +} + +.portal-connect__list { + margin: 0; + border-top: 1px solid var(--c-border); +} + +.portal-connect__row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--c-border); +} + +.portal-connect__row-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-connect__row-detail { + margin: 0; + font-size: 0.8125rem; + color: var(--c-text-muted); + text-align: right; +} + +/* ── Step 2: the ghost ──────────────────────────────────────────────────── */ + +.portal-connect__ghost { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-connect__ghost-bars { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* ── Step 3 ─────────────────────────────────────────────────────────────── */ + +/* Standalone, so it needs the top edge the benefits list would have given it. */ +.portal-connect__row--standalone { + border-top: 1px solid var(--c-border); +} + +/* The shared bar without the plan card it wears on Usage; padded into the row rhythm. */ +.portal-connect__meter { + padding: 0.25rem 0 0.5rem; +} + +/* Rows, not full-width buttons: three competing primaries were the loudest thing + on a screen whose job is to confirm. Same flat row as step 1, so both ends match. */ +.portal-connect__next { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; + border-top: 1px solid var(--c-border); +} + +/* The Account row above draws a bottom edge; both would render as a double line. */ +.portal-connect__row + .portal-connect__next { + border-top: 0; +} + +.portal-connect__next-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + padding: 0.625rem 0.25rem; + border: 0; + border-bottom: 1px solid var(--c-border); + background: none; + font: inherit; + font-size: 0.875rem; + font-weight: 500; + color: var(--c-text); + text-align: left; + cursor: pointer; +} + +.portal-connect__next-item:hover { + color: var(--c-accent-text); +} + +.portal-connect__next-chevron { + flex: none; + color: var(--c-text-subtle); +} + +.portal-connect__next-item:hover .portal-connect__next-chevron { + color: var(--c-accent-text); +} diff --git a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx deleted file mode 100644 index 20a9ab2e80..0000000000 --- a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; -import "@portal/components/billing/billing.css"; - -const meta: Meta = { - title: "Portal/Billing/LinkAccountPrompt", - component: LinkAccountPrompt, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** Unlinked billing page — CTA opens the login modal (UIProvider from the preview decorator). */ -export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx deleted file mode 100644 index 394668c56b..0000000000 --- a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card, EmptyState } from "@app/ui"; -import { useUI } from "@portal/contexts/UIContext"; - -/** - * Unlinked state — the billing page asks the admin to link their Stirling - * account to claim the 500-PDF free grant. The CTA opens the login modal - * directly (no detour through Settings). - */ -export function LinkAccountPrompt() { - const { t } = useTranslation(); - const { openLinkModal } = useUI(); - return ( - - openLinkModal()}> - {t("portal.billing.linkPrompt.cta", "Link Stirling account")} - - } - /> - - ); -} diff --git a/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx b/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx index f15d8ade51..097eebd52e 100644 --- a/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx +++ b/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx @@ -1,39 +1,82 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; -const linkState = { isLinked: false }; +/** + * Usage must not render while unlinked: it reports `linked` as a fact from its wallet read, so a + * browser holding a SaaS session with no link to this server would flip the whole portal to linked. + */ +const gate = { gated: false, loading: false, available: true }; +const connect = vi.fn(); +const applyLinkFacts = vi.fn(); + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ ...gate, connect, guard: (f: unknown) => f }), +})); vi.mock("@portal/contexts/LinkContext", () => ({ - useLink: () => linkState, - useApplyLinkFacts: () => vi.fn(), + useApplyLinkFacts: () => applyLinkFacts, })); vi.mock("@portal/contexts/UIContext", () => ({ useUI: () => ({ openLinkModal: vi.fn() }), })); -vi.mock("@portal/components/billing/LinkAccountPrompt", () => ({ - LinkAccountPrompt: () =>
, -})); vi.mock("@portal/views/Usage", () => ({ - Usage: () =>
, + Usage: ({ onWalletLoaded }: { onWalletLoaded?: (w: unknown) => void }) => { + onWalletLoaded?.({ status: "free" }); + return
; + }, })); import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; +const renderGate = () => + render( + + + } /> + } /> + + , + ); + describe("PortalBillingGate — self-hosted", () => { beforeEach(() => { - linkState.isLinked = false; + connect.mockReset(); + applyLinkFacts.mockReset(); + gate.gated = false; + gate.loading = false; }); - it("shows the link prompt when unlinked (billing gated on link)", () => { - linkState.isLinked = false; - render(); - expect(screen.getByTestId("link-prompt")).toBeInTheDocument(); - expect(screen.queryByTestId("usage")).not.toBeInTheDocument(); + it("asks for the connection when reached unconnected", () => { + gate.gated = true; + renderGate(); + expect(connect).toHaveBeenCalledTimes(1); }); - it("renders the Usage page once linked", () => { - linkState.isLinked = true; - render(); + it("sends them back rather than onto a page about an account they lack", () => { + gate.gated = true; + renderGate(); + expect(screen.queryByTestId("usage")).toBeNull(); + expect(screen.getByTestId("home")).toBeInTheDocument(); + }); + + it("never reports the instance as linked while it is not", () => { + gate.gated = true; + renderGate(); + // Not rendering the page is what stops the claim. + expect(applyLinkFacts).not.toHaveBeenCalled(); + }); + + it("holds while the capability is still unknown, rather than bouncing", () => { + gate.loading = true; + renderGate(); + expect(screen.queryByTestId("usage")).toBeNull(); + expect(screen.queryByTestId("home")).toBeNull(); + }); + + it("renders the page once connected", () => { + renderGate(); + expect(connect).not.toHaveBeenCalled(); expect(screen.getByTestId("usage")).toBeInTheDocument(); - expect(screen.queryByTestId("link-prompt")).not.toBeInTheDocument(); + expect(applyLinkFacts).toHaveBeenCalledWith(true, false); }); }); diff --git a/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx b/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx index e448cfb93a..b02ed7db29 100644 --- a/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx +++ b/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx @@ -1,23 +1,20 @@ import { useCallback } from "react"; -import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext"; +import { useApplyLinkFacts } from "@portal/contexts/LinkContext"; import { useUI } from "@portal/contexts/UIContext"; -import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; +import { ConnectGuardedRoute } from "@portal/components/account-link/ConnectGuardedRoute"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { Usage } from "@portal/views/Usage"; import type { Wallet } from "@portal/api/billing"; /** - * Billing access gate — the seam the SaaS build overrides. + * The seam the SaaS build shadows, and the backstop for a typed URL — the nav already refuses to + * come here unlinked (the sidebar's requiresLink). * - *

Self-hosted (this base): billing only makes sense once the instance has - * linked its SaaS account, so gate on link state — unlinked shows the link prompt; - * linked renders the (flavor-agnostic) Usage page and maps its callbacks onto the - * link/tier dimension: the wallet's subscription status refines the plan/tier - * badge, and a lapsed SaaS session re-opens the account-link re-auth. This keeps - * the "link" concept entirely out of the Usage page. The SaaS build shadows this - * with a passthrough — there is no linking there. + *

Usage must not render while unlinked: {@link onWalletLoaded} reports linked as a fact, and the + * browser can hold a SaaS session with no link to this server, so rendering it flipped the portal + * to linked. */ export function PortalBillingGate() { - const { isLinked } = useLink(); const applyLinkFacts = useApplyLinkFacts(); const { openLinkModal } = useUI(); @@ -27,6 +24,9 @@ export function PortalBillingGate() { ); const onReauth = useCallback(() => openLinkModal("reauth"), [openLinkModal]); - if (!isLinked) return ; - return ; + return ( + + + + ); } diff --git a/frontend/editor/src/portal/components/shared/StepModalHeader.css b/frontend/editor/src/portal/components/shared/StepModalHeader.css index 7296e9752f..fd918f1c62 100644 --- a/frontend/editor/src/portal/components/shared/StepModalHeader.css +++ b/frontend/editor/src/portal/components/shared/StepModalHeader.css @@ -26,7 +26,7 @@ color: var(--c-text); } -/* Trademarked wordmark SVG (theme-switched via .wordmark-light-only/.wordmark-dark-only). Height +/* Trademarked wordmark SVG (theme-switched via .wordmark/.wordmark-dark-only). Height matches the portal nav's 22px wordmark so the modal and app read as one brand. No `display` here — the theme-switch utilities own visibility. */ .portal-stepmodal__wordmark { diff --git a/frontend/editor/src/portal/components/shared/StepModalHeader.tsx b/frontend/editor/src/portal/components/shared/StepModalHeader.tsx index 3156d17a9e..9190893745 100644 --- a/frontend/editor/src/portal/components/shared/StepModalHeader.tsx +++ b/frontend/editor/src/portal/components/shared/StepModalHeader.tsx @@ -55,10 +55,12 @@ export function StepModalHeader({

{brand ? (
+ {/* `wordmark`, not `wordmark-light-only`: theme.css hides the former in dark mode and + has no rule for the latter, so both used to render at once. */} Stirling }, { id: "integrations", icon: }, { id: "infrastructure", icon: }, - { id: "usage", icon: }, + { id: "usage", icon: , requiresLink: true }, { id: "docs", icon: }, ]; diff --git a/frontend/editor/src/portal/contexts/LinkContext.tsx b/frontend/editor/src/portal/contexts/LinkContext.tsx index 7fd7cfe536..8e36b80cee 100644 --- a/frontend/editor/src/portal/contexts/LinkContext.tsx +++ b/frontend/editor/src/portal/contexts/LinkContext.tsx @@ -89,6 +89,14 @@ export function useLink(): LinkContextValue { return v; } +/** + * Null rather than throwing where there is no provider. The SaaS portal mounts none on purpose, so + * absent means "linking does not apply here" — a real answer, not a mistake. + */ +export function useLinkOptional(): LinkContextValue | null { + return useContext(LinkContext); +} + /** * Derives the linked state from raw facts: whether the org has linked its SaaS * account and whether it carries a live subscription. Keeps the unlinked / diff --git a/frontend/editor/src/portal/contexts/UIContext.tsx b/frontend/editor/src/portal/contexts/UIContext.tsx index b5ebada257..b116486271 100644 --- a/frontend/editor/src/portal/contexts/UIContext.tsx +++ b/frontend/editor/src/portal/contexts/UIContext.tsx @@ -5,6 +5,7 @@ import { useState, type ReactNode, } from "react"; +import type { ConnectOutcome } from "@portal/components/account-link/ConnectCallbackView"; interface UIContextValue { /** Off-canvas sidebar drawer on small screens (no-op chrome on desktop). */ @@ -46,6 +47,13 @@ interface UIContextValue { linkModalMode: "link" | "reauth"; openLinkModal: (mode?: "link" | "reauth") => void; closeLinkModal: () => void; + /** + * A one-shot signal like {@link UIContextValue.trialSetupRequested}: the callback route and the + * dialog mount separately, and there must only ever be one link dialog. + */ + connectOutcome: ConnectOutcome | null; + publishConnectOutcome: (outcome: ConnectOutcome) => void; + clearConnectOutcome: () => void; /** * A request to begin the enterprise trial, raised from wherever the buyer said yes (the billing * upsell, a sales link). The deal controller lives on Home, so this is a one-shot signal rather @@ -91,6 +99,9 @@ export function UIProvider({ children }: { children: ReactNode }) { const [linkModalOpen, setLinkModalOpen] = useState(false); const [trialSetupRequested, setTrialSetupRequested] = useState(false); const [linkModalMode, setLinkModalMode] = useState<"link" | "reauth">("link"); + const [connectOutcome, setConnectOutcome] = useState( + null, + ); // When the link modal is opened from inside Settings, remember the section to // restore so closing the modal returns the admin to where they were. const [reopenSettingsAfterLink, setReopenSettingsAfterLink] = useState< @@ -155,9 +166,19 @@ export function UIProvider({ children }: { children: ReactNode }) { setTrialSetupRequested(true); }, clearTrialSetupRequest: () => setTrialSetupRequested(false), + connectOutcome, + publishConnectOutcome: (outcome: ConnectOutcome) => { + setMobileNavOpen(false); + setConnectOutcome(outcome); + setLinkModalMode("link"); + setLinkModalOpen(true); + }, + clearConnectOutcome: () => setConnectOutcome(null), closeLinkModal: () => { setLinkModalOpen(false); setLinkModalMode("link"); + // A reopen from a CTA is a fresh flow, not a handshake already dismissed. + setConnectOutcome(null); if (reopenSettingsAfterLink) { setSettingsInitialSection(reopenSettingsAfterLink); setSettingsInitialFocus(null); @@ -177,6 +198,7 @@ export function UIProvider({ children }: { children: ReactNode }) { linkModalMode, reopenSettingsAfterLink, trialSetupRequested, + connectOutcome, ], ); diff --git a/frontend/editor/src/portal/hooks/useConnectGate.test.tsx b/frontend/editor/src/portal/hooks/useConnectGate.test.tsx new file mode 100644 index 0000000000..00cd34d643 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectGate.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { UIProvider } from "@portal/contexts/UIContext"; + +/** + * The gate answers two questions, and conflating them is the failure that matters: an instance + * running with the account-link flag off CANNOT link, so gating on link state alone would lock + * Pipelines, Policies, Users, Sources and Integrations on every default install with no way out. + */ +const { json } = vi.hoisted(() => ({ json: vi.fn() })); +vi.mock("@portal/api/http", () => ({ + apiClient: { local: { json } }, + errorMessage: (e: unknown) => String(e), +})); + +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +function Probe() { + const { gated, loading, available } = useConnectGate(); + return ( + + {loading + ? "loading" + : `${available ? "available" : "unavailable"}:${gated ? "gated" : "open"}`} + + ); +} + +function renderProbe(linkState: LinkState) { + return render( + + + + + + + , + ); +} + +const settled = async (expected: string) => + waitFor(() => expect(screen.getByTestId("state").textContent).toBe(expected)); + +describe("useConnectGate", () => { + beforeEach(() => json.mockReset()); + + it("gates an unlinked instance that can link", async () => { + json.mockResolvedValue({ accountLinkAvailable: true }); + renderProbe("unlinked"); + await settled("available:gated"); + }); + + it("does not gate when linking is unavailable, whatever the link state", async () => { + json.mockResolvedValue({ accountLinkAvailable: false }); + renderProbe("unlinked"); + await settled("unavailable:open"); + }); + + it("does not gate a linked instance", async () => { + json.mockResolvedValue({ accountLinkAvailable: true }); + renderProbe("linked-free"); + await settled("available:open"); + }); + + it("treats a missing flag as unavailable rather than gating on a guess", async () => { + json.mockResolvedValue({}); + renderProbe("unlinked"); + await settled("unavailable:open"); + }); + + it("does not gate while the capability is still unknown", async () => { + json.mockResolvedValue({ accountLinkAvailable: true }); + renderProbe("unlinked"); + expect(screen.getByTestId("state").textContent).toBe("loading"); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useConnectGate.ts b/frontend/editor/src/portal/hooks/useConnectGate.ts new file mode 100644 index 0000000000..253ee26212 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectGate.ts @@ -0,0 +1,63 @@ +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@portal/api/http"; +import { qk } from "@portal/queries/keys"; +import { useLinkOptional } from "@portal/contexts/LinkContext"; +import { useUI } from "@portal/contexts/UIContext"; +import { useDevConnectBypass } from "@portal/hooks/useDevConnectBypass"; + +interface AppConfigShape { + accountLinkAvailable?: boolean; +} + +interface ConnectGate { + /** Can link but has not, so gated features must ask first. */ + gated: boolean; + /** Capability still unknown; hold the decision rather than flash a gate. */ + loading: boolean; + /** Whether linking is possible here at all, i.e. the feature flag is on. */ + available: boolean; + connect: () => void; + /** Wraps a create or edit handler so the click asks for a connection instead. */ + guard: ( + action: (...args: A) => void, + ) => (...args: A) => void; +} + +/** + * Two facts, not one: linked, and *could* be linked. The account-link endpoints 404 with the flag + * off, which the client cannot tell from "not linked yet", so gating on link state alone would lock + * these features on every default install. + */ +export function useConnectGate(): ConnectGate { + // Optional: the SaaS portal mounts no LinkProvider, and no provider means nothing to gate. + const link = useLinkOptional(); + const { openLinkModal } = useUI(); + const devBypass = useDevConnectBypass(); + + const query = useQuery({ + queryKey: qk.appConfig(), + queryFn: () => + apiClient.local.json("/api/v1/config/app-config"), + }); + + const available = Boolean(query.data?.accountLinkAvailable) && link != null; + const loading = query.isPending; + const gated = available && !link?.isLinked && !devBypass; + + const connect = useCallback(() => openLinkModal(), [openLinkModal]); + + const guard = useCallback( + (action: (...args: A) => void) => + (...args: A) => { + if (gated) { + openLinkModal(); + return; + } + action(...args); + }, + [gated, openLinkModal], + ); + + return { gated, loading, available, connect, guard }; +} diff --git a/frontend/editor/src/portal/hooks/useConnectHandoff.ts b/frontend/editor/src/portal/hooks/useConnectHandoff.ts new file mode 100644 index 0000000000..1349086085 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectHandoff.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { withBasePath } from "@app/constants/app"; +import { startConnect, startReauth } from "@portal/api/link"; + +interface ConnectHandoff { + /** Stays true through a successful hand-off: the page is leaving, so nothing resolves. */ + busy: boolean; + error: string | null; + begin: () => void; +} + +export function useConnectHandoff(reauth: boolean): ConnectHandoff { + const { t } = useTranslation(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + // Back from Stirling can restore this page with its heap intact, leaving busy stuck on and the + // dialog pinned to the ghost step. Being shown at all means we are not mid-navigation. + const shown = () => setBusy(false); + window.addEventListener("pageshow", shown); + return () => window.removeEventListener("pageshow", shown); + }, []); + + const begin = useCallback(() => { + setBusy(true); + setError(null); + void (async () => { + try { + // Stated, not inferred: only the frontend knows its own base path. + const callbackUrl = new URL( + withBasePath("/account-link/callback"), + window.location.origin, + ).toString(); + const status = reauth + ? await startReauth(callbackUrl) + : await startConnect(window.location.hostname, callbackUrl); + if (status.authorizeUrl) { + window.location.assign(status.authorizeUrl); + return; + } + // Already linked, or a handshake we cannot act on. Nothing to navigate to. + setError( + t( + "portal.accountLink.modal.noAuthorizeUrl", + "Stirling did not return somewhere to continue. Try again in a moment.", + ), + ); + setBusy(false); + } catch { + setError( + t( + "portal.accountLink.modal.startFailed", + "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again.", + ), + ); + setBusy(false); + } + })(); + }, [reauth, t]); + + return { busy, error, begin }; +} diff --git a/frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx b/frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx new file mode 100644 index 0000000000..dc54fce35c --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; + +/** + * The cadence decision: dismissible, but it always comes back. Persisting "seen" would end the ask + * after one dismissal, so the marker has to be session scoped and has to be written when the prompt + * opens rather than when it closes, or an admin who ignores the dialog gets it again on every + * re-render. + */ +const { connect, gate } = vi.hoisted(() => ({ + connect: vi.fn(), + gate: { gated: true, loading: false, available: true }, +})); + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ ...gate, connect, guard: (f: unknown) => f }), +})); + +import { useConnectPrompt } from "@portal/hooks/useConnectPrompt"; + +function Probe() { + useConnectPrompt(); + return null; +} + +describe("useConnectPrompt", () => { + beforeEach(() => { + connect.mockReset(); + sessionStorage.clear(); + gate.gated = true; + gate.loading = false; + }); + + it("opens the flow once while unlinked", async () => { + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + }); + + it("does not open again in the same session", async () => { + const { unmount } = render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + unmount(); + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + }); + + it("asks again in a fresh session", async () => { + render().unmount(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + sessionStorage.clear(); + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(2)); + }); + + it("never persists beyond the session", async () => { + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + expect(localStorage.length).toBe(0); + }); + + it("stays quiet when the instance is not gated", async () => { + gate.gated = false; + render(); + await new Promise((r) => setTimeout(r, 0)); + expect(connect).not.toHaveBeenCalled(); + }); + + it("waits for the capability rather than prompting on an unknown", async () => { + gate.loading = true; + render(); + await new Promise((r) => setTimeout(r, 0)); + expect(connect).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useConnectPrompt.ts b/frontend/editor/src/portal/hooks/useConnectPrompt.ts new file mode 100644 index 0000000000..0a751b5c86 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectPrompt.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef } from "react"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +const PROMPTED_KEY = "portal::connect-prompted"; + +function alreadyPrompted(): boolean { + try { + return sessionStorage.getItem(PROMPTED_KEY) === "true"; + } catch { + return false; + } +} + +function markPrompted(): void { + try { + sessionStorage.setItem(PROMPTED_KEY, "true"); + } catch { + // Prompting again later is the harmless direction. + } +} + +/** + * Session storage, not the onboarding localStorage helpers: one dismissal should not end the ask + * for good, and asking once per visit needs no timer to tune. + */ +export function useConnectPrompt(): void { + const { gated, loading, connect } = useConnectGate(); + const fired = useRef(false); + + useEffect(() => { + if (loading || !gated || fired.current || alreadyPrompted()) return; + fired.current = true; + // Marked on open, not on close, so a session gets one whatever the admin does with it. + markPrompted(); + connect(); + }, [gated, loading, connect]); +} diff --git a/frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx b/frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx new file mode 100644 index 0000000000..f4d28f5515 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; + +/** + * The gate is the only thing making these features need a link, so a switch a customer could reach + * would hand them over. The last test is the one that matters: with DEV folded off, the parameter + * does nothing at all. + */ +import { useDevConnectBypass } from "@portal/hooks/useDevConnectBypass"; + +function Probe() { + return {String(useDevConnectBypass())}; +} + +/** Mounts fresh at the given URL, so a second call models a later visit rather than a re-render. */ +const at = (search: string) => { + cleanup(); + window.history.replaceState({}, "", `/processor${search}`); + render(); + return screen.getByTestId("bypass").textContent; +}; + +describe("useDevConnectBypass", () => { + beforeEach(() => sessionStorage.clear()); + afterEach(() => { + vi.unstubAllEnvs(); + window.history.replaceState({}, "", "/"); + }); + + it("is off by default, so dev still sees what customers see", () => { + expect(at("")).toBe("false"); + }); + + it("turns on with the parameter", () => { + expect(at("?bypassConnect=true")).toBe("true"); + }); + + it("survives the navigation the gate itself performs", () => { + at("?bypassConnect=true"); + expect(at("")).toBe("true"); + }); + + it("ignores any other value", () => { + expect(at("?bypassConnect=1")).toBe("false"); + }); + + it("does nothing in a build, which is what ships to customers", () => { + vi.stubEnv("DEV", false); + expect(at("?bypassConnect=true")).toBe("false"); + // And nothing was left behind for a later dev session to pick up. + expect(sessionStorage.getItem("accountLink::dev-bypass")).toBeNull(); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useDevConnectBypass.ts b/frontend/editor/src/portal/hooks/useDevConnectBypass.ts new file mode 100644 index 0000000000..b201ac7d80 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useDevConnectBypass.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; + +const PARAM = "bypassConnect"; +const SESSION_KEY = "accountLink::dev-bypass"; + +function stored(): boolean { + try { + return sessionStorage.getItem(SESSION_KEY) === "true"; + } catch { + return false; + } +} + +/** + * Dev-only escape from the connect gate, fenced behind {@code import.meta.env.DEV} so Vite folds + * the branch away entirely: in a shipped build there is no param and no key that does anything. + * It cannot be a setting — the gate is the only thing making these features need a link, so any + * switch a customer could reach would hand them over. + */ +export function useDevConnectBypass(): boolean { + // Session-scoped so it survives the navigation the gate itself performs. + const [bypassed, setBypassed] = useState( + () => import.meta.env.DEV && stored(), + ); + + useEffect(() => { + if (!import.meta.env.DEV) return; + const params = new URLSearchParams(window.location.search); + if (params.get(PARAM) !== "true") return; + try { + sessionStorage.setItem(SESSION_KEY, "true"); + } catch { + // Still bypassed for this render. + } + setBypassed(true); + }, []); + + return bypassed; +} diff --git a/frontend/editor/src/portal/test/TestQueryProvider.tsx b/frontend/editor/src/portal/test/TestQueryProvider.tsx index d843d816d3..1d3587c858 100644 --- a/frontend/editor/src/portal/test/TestQueryProvider.tsx +++ b/frontend/editor/src/portal/test/TestQueryProvider.tsx @@ -1,6 +1,8 @@ import { useState, type ReactNode } from "react"; import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { UIProvider } from "@portal/contexts/UIContext"; /** * Wraps a portal component under test in a fresh QueryClient (retries off for @@ -27,3 +29,20 @@ export function PortalTestProviders({ children }: { children: ReactNode }) { ); } + +/** {@link PortalTestProviders} plus the contexts the connect gate reads. Unlinked by default. */ +export function PortalViewProviders({ + children, + linkState = "unlinked", +}: { + children: ReactNode; + linkState?: LinkState; +}) { + return ( + + + {children} + + + ); +} diff --git a/frontend/editor/src/portal/views/ConnectCallback.css b/frontend/editor/src/portal/views/ConnectCallback.css index 35e9b09ef7..99c0539658 100644 --- a/frontend/editor/src/portal/views/ConnectCallback.css +++ b/frontend/editor/src/portal/views/ConnectCallback.css @@ -1,24 +1,13 @@ -/* Account-link callback. A transient page the admin passes through, so it is - centred and says one thing rather than trying to be a settings screen. */ +/* Step 3 of the connect flow: what the round trip to Stirling came back with. + + Was a standalone page, centred with its own margins and max-width. It is now a + step body inside FlowModal, which already owns the dialog's width, padding and + spacing, so this only stacks its own blocks. */ .portal-connect-callback { display: flex; flex-direction: column; - align-items: center; - gap: 1rem; - max-width: 30rem; - margin: 4rem auto; - padding: 0 1rem; - text-align: center; -} - -.portal-connect-callback > * { - width: 100%; -} - -/* The button is the one thing that should not stretch to the banner's width. */ -.portal-connect-callback button { - width: auto; + gap: 0.75rem; } .portal-connect-callback p { @@ -30,3 +19,11 @@ .portal-connect-callback__note { font-size: 0.8125rem; } + +/* Only "working" is centred: a spinner with a line under it, which has nothing to + align against. */ +.portal-connect-callback--working { + align-items: center; + gap: 0.625rem; + padding: 1.5rem 0; +} diff --git a/frontend/editor/src/portal/views/ConnectCallback.test.tsx b/frontend/editor/src/portal/views/ConnectCallback.test.tsx index bdadee03e4..b934e11cd7 100644 --- a/frontend/editor/src/portal/views/ConnectCallback.test.tsx +++ b/frontend/editor/src/portal/views/ConnectCallback.test.tsx @@ -2,10 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, render, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MantineProvider } from "@mantine/core"; +import { UIProvider, useUI } from "@portal/contexts/UIContext"; +import type { ConnectOutcome } from "@portal/components/account-link/ConnectCallbackView"; -/** - * The callback handles a live session token in a URL fragment, so the behaviour worth pinning is what it does with it: strip it immediately, refuse anything it cannot verify, and keep the two outcomes (SaaS sign-in, server link) independent of each other. - */ +/** A live session token rides in the fragment: strip it at once, refuse what cannot be verified. */ const { completeConnect, startConnect, setSession, refresh } = vi.hoisted( () => ({ completeConnect: vi.fn(), @@ -32,19 +32,38 @@ function landOn(fragment: string) { window.history.replaceState(null, "", `/account-link/callback${fragment}`); } -/** - * Route and host together: the route reads the fragment, the portal renders the - * outcome. Exercising them apart would test the hand-off rather than the flow. - */ +/** Stands in for the dialog that consumes the outcome. */ +let published: ConnectOutcome[] = []; + +function OutcomeSpy() { + const { connectOutcome } = useUI(); + if ( + connectOutcome && + published[published.length - 1]?.state !== connectOutcome.state + ) { + published.push(connectOutcome); + } + return null; +} + +const lastOutcome = () => published[published.length - 1]; + +/** Route and host together: apart, this would test the hand-off rather than the flow. */ function renderFlow() { return render( - - - } /> - } /> - + + + + + } + /> + } /> + + , ); @@ -53,6 +72,7 @@ function renderFlow() { describe("account-link callback", () => { beforeEach(() => { vi.clearAllMocks(); + published = []; completeConnect.mockResolvedValue({ phase: "LINKED", authorizeUrl: null, @@ -67,8 +87,7 @@ describe("account-link callback", () => { renderFlow(); - // Synchronous, before any await: the fragment must not survive long enough - // to be read from the address bar or land in a history entry. + // Before any await: the fragment must not reach the address bar or a history entry. expect(window.location.hash).toBe(""); await waitFor(() => expect(completeConnect).toHaveBeenCalled()); }); @@ -109,8 +128,7 @@ describe("account-link callback", () => { renderFlow(); - // The two outcomes are independent: a failed sign-in must not strand the - // server unlinked. + // Independent outcomes: a failed sign-in must not strand the server unlinked. await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); }); @@ -131,6 +149,18 @@ describe("account-link callback", () => { await waitFor(() => expect(window.location.hash).toBe("")); expect(completeConnect).not.toHaveBeenCalled(); expect(setSession).not.toHaveBeenCalled(); + await waitFor(() => expect(lastOutcome()?.state).toBe("malformed")); + expect(lastOutcome()?.reclaim).toBeUndefined(); + }); + + it("hands the result to the dialog rather than rendering its own", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + const { container } = renderFlow(); + + await waitFor(() => expect(lastOutcome()?.state).toBe("linked")); + expect(lastOutcome()?.sessionRestored).toBe(true); + expect(container.querySelector(".portal-connect-callback")).toBeNull(); }); it("refuses a fragment that is not a link response", async () => { @@ -160,16 +190,30 @@ describe("account-link callback", () => { teamId: null, }); - const { getAllByRole } = renderFlow(); + renderFlow(); await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(1)); - // Last button, not the only one: the modal shell contributes a close button. - const buttons = getAllByRole("button"); - act(() => buttons[buttons.length - 1].click()); + await waitFor(() => expect(lastOutcome()?.state).toBe("retry")); - // Retries the existing handshake; starting a new one would waste the - // approval a human just gave. + act(() => lastOutcome()!.reclaim!()); + + // Re-claims rather than opening a new handshake, which would spend a leader's approval. await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(2)); expect(startConnect).not.toHaveBeenCalled(); }); + + it("gives a spent handshake no re-claim, so the dialog asks for a new one", async () => { + landOn(`#type=link&nonce=${NONCE}`); + completeConnect.mockResolvedValue({ + phase: "EXPIRED", + authorizeUrl: null, + secondsRemaining: null, + teamId: null, + }); + + renderFlow(); + + await waitFor(() => expect(lastOutcome()?.state).toBe("expired")); + expect(lastOutcome()?.reclaim).toBeUndefined(); + }); }); diff --git a/frontend/editor/src/portal/views/Integrations.test.tsx b/frontend/editor/src/portal/views/Integrations.test.tsx index 8c4e684bfa..acf1f1b1b1 100644 --- a/frontend/editor/src/portal/views/Integrations.test.tsx +++ b/frontend/editor/src/portal/views/Integrations.test.tsx @@ -13,6 +13,16 @@ import type { IntegrationConfig } from "@portal/api/integrations"; const render = (ui: Parameters[0]) => baseRender(ui, { wrapper: MantineProvider }); +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, diff --git a/frontend/editor/src/portal/views/Integrations.tsx b/frontend/editor/src/portal/views/Integrations.tsx index 793c21f880..fc40feb1e9 100644 --- a/frontend/editor/src/portal/views/Integrations.tsx +++ b/frontend/editor/src/portal/views/Integrations.tsx @@ -31,6 +31,7 @@ import { } from "@portal/components/sources/connectionTypes"; import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations"; import { COMING_SOON_SOURCE_TYPES } from "@portal/components/sources/sourceTypes"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/theme/surface.css"; import "@portal/views/Integrations.css"; @@ -94,6 +95,7 @@ type IntegrationRow = { export function Integrations() { const { t } = useTranslation(); + const { guard } = useConnectGate(); const [connections, setConnections] = useState( null, ); @@ -212,13 +214,23 @@ export function Integrations() { return counts; }, [catalogue]); - const openCreate = useCallback((typeId: string) => { - setModal({ open: true, editing: null, fixedTypeId: typeId }); - }, []); + // Connecting an integration and editing one both need a linked account. Memoised + // because both land in the row-building useMemo deps below. + const openCreate = useMemo( + () => + guard((typeId: string) => { + setModal({ open: true, editing: null, fixedTypeId: typeId }); + }), + [guard], + ); - const openEdit = useCallback((connection: IntegrationConfig) => { - setModal({ open: true, editing: connection }); - }, []); + const openEdit = useMemo( + () => + guard((connection: IntegrationConfig) => { + setModal({ open: true, editing: connection }); + }), + [guard], + ); const remove = useCallback( async (connection: IntegrationConfig) => { diff --git a/frontend/editor/src/portal/views/Pipelines.gated.test.tsx b/frontend/editor/src/portal/views/Pipelines.gated.test.tsx new file mode 100644 index 0000000000..993edc876b --- /dev/null +++ b/frontend/editor/src/portal/views/Pipelines.gated.test.tsx @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { PortalViewProviders } from "@portal/test/TestQueryProvider"; + +/** + * The page must look exactly as it always does: the ask is a dialog on the attempt, not a lock + * screen in place of the feature. The route half is what a later link cannot walk around. + */ +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: true, + loading: false, + available: true, + connect, + guard: + (_action: (...args: A) => void) => + () => + connect(), + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchPipelines = vi.fn(); +vi.mock("@portal/api/pipelines", () => ({ + fetchPipelines: () => fetchPipelines(), +})); + +import { Pipelines } from "@portal/views/Pipelines"; +import { ConnectGuardedRoute } from "@portal/components/account-link/ConnectGuardedRoute"; + +const PIPELINE = { + id: "plc-1", + name: "Redact claims", + enabled: true, + status: "active", + trigger: "schedule", + sources: [{ id: "src-claims", name: "Claims intake" }], + steps: ["/api/v1/security/auto-redact"], + output: "inline", + owner: "security@acme.com", +}; + +function renderAt(initial: string) { + return render( + + + + } /> + +
builder
+ + } + /> +
+
+
, + ); +} + +describe("Pipelines when the account is not connected", () => { + beforeEach(() => { + connect.mockReset(); + fetchPipelines.mockReset(); + }); + + it("leaves the empty state exactly as it is", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] }); + renderAt("/processor/pipelines"); + expect( + await screen.findByText("portal.pipelines.empty.title"), + ).toBeInTheDocument(); + }); + + it("still lists pipelines that already exist", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [PIPELINE] }); + renderAt("/processor/pipelines"); + expect(await screen.findByText("Redact claims")).toBeInTheDocument(); + }); + + it("asks to connect instead of opening the builder", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] }); + renderAt("/processor/pipelines"); + await screen.findByText("portal.pipelines.empty.title"); + fireEvent.click(screen.getByText("portal.pipelines.actions.newPipeline")); + expect(connect).toHaveBeenCalled(); + expect(screen.queryByText("builder")).toBeNull(); + }); + + it("asks to connect instead of opening an existing pipeline", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [PIPELINE] }); + renderAt("/processor/pipelines"); + fireEvent.click(await screen.findByText("Redact claims")); + expect(connect).toHaveBeenCalled(); + }); + + it("turns away a direct arrival at the builder, however it was reached", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] }); + renderAt("/processor/pipelines/new"); + expect( + await screen.findByText("portal.pipelines.empty.title"), + ).toBeInTheDocument(); + expect(screen.queryByText("builder")).toBeNull(); + expect(connect).toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/views/Pipelines.test.tsx b/frontend/editor/src/portal/views/Pipelines.test.tsx index 655abf62cb..250722706e 100644 --- a/frontend/editor/src/portal/views/Pipelines.test.tsx +++ b/frontend/editor/src/portal/views/Pipelines.test.tsx @@ -14,6 +14,16 @@ const render = ( options?: Parameters[1], ) => baseRender(ui, { wrapper: PortalTestProviders, ...options }); +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + // Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ useTranslation: () => ({ diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx index f2203de972..6311e3c438 100644 --- a/frontend/editor/src/portal/views/Pipelines.tsx +++ b/frontend/editor/src/portal/views/Pipelines.tsx @@ -9,11 +9,13 @@ import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { PipelinesIcon } from "@portal/components/icons"; import { KpiStrip } from "@portal/components/pipelines/KpiStrip"; import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/views/Pipelines.css"; export function Pipelines() { const { t } = useTranslation(); const navigate = useNavigate(); + const { guard } = useConnectGate(); const state = usePipelines(); const { data, loading } = state; const { isLoading } = useSectionFlags(state); @@ -26,13 +28,18 @@ export function Pipelines() { // the loading and empty states don't flash a row of placeholder cards. const hasPipelines = pipelines.length > 0; - const openCreate = () => - navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`); - const connectSource = () => - navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`); + // Building and editing a pipeline both need a linked account, so both ask for one first. + const openCreate = guard(() => + navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`), + ); + // Guarded in its own right so the ask happens here rather than after a pointless hop to Sources. + const connectSource = guard(() => + navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`), + ); // A row opens that pipeline's own page (view / edit / run / delete live there). - const openPipeline = (pipeline: PipelineView) => - navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`); + const openPipeline = guard((pipeline: PipelineView) => + navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`), + ); return (
diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx index 9e316852fd..b8cb42112a 100644 --- a/frontend/editor/src/portal/views/Policies.tsx +++ b/frontend/editor/src/portal/views/Policies.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -23,11 +23,13 @@ import { PolicyCatalogueTable } from "@portal/components/policies/PolicyCatalogu import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel"; import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard"; import { useAiEngineEnabled } from "@portal/hooks/useAiEngineEnabled"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/views/Policies.css"; export function Policies() { const { t } = useTranslation(); const queryClient = useQueryClient(); + const { gated, connect } = useConnectGate(); const state = usePoliciesOverview(); const { data, loading, error: fetchError } = state; const { isLoading } = useSectionFlags(state); @@ -38,18 +40,27 @@ export function Policies() { const [pageError, setPageError] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); + // Held in a ref so the effects below do not re-run on its identity. They write back to the URL, + // so a callback that changes each render would loop: strip the param, re-render, run again. + const connectRef = useRef(connect); + connectRef.current = connect; + + // Deep link from the Home processor flow. It sets the wizard directly rather than going through + // openEntry, so the gate belongs here too: guarding openEntry alone would leave ?setup= as a way + // past it. useEffect(() => { const setupId = searchParams.get("setup"); if (!setupId || !data) return; const entry = data.catalogue.find((e) => e.category.id === setupId); if (entry && !entry.category.comingSoon) { - if (entry.policy) setDetail(entry); + if (gated) connectRef.current(); + else if (entry.policy) setDetail(entry); else setWizard(entry); } const next = new URLSearchParams(searchParams); next.delete("setup"); setSearchParams(next, { replace: true }); - }, [searchParams, data, setSearchParams]); + }, [searchParams, data, setSearchParams, gated]); const { enabled: aiEngineEnabled, loading: aiEngineLoading } = useAiEngineEnabled(); @@ -95,6 +106,13 @@ export function Policies() { const openEntry = useCallback( (entry: CatalogueEntry) => { + // Ask rather than open an editor whose save would fail; viewing the catalogue stays open. + // Via the ref so this keeps its identity: the deep-link effect depends on it and writes the + // URL back, which would otherwise loop. + if (gated) { + connectRef.current(); + return; + } // Block setup of an AI-required policy until the engine is confirmed on (so a // click during the app-config load can't open a wizard for a disabled // feature); a configured policy stays openable so it can be paused/deleted. @@ -103,7 +121,7 @@ export function Policies() { if (entry.policy) setDetail(entry); else setWizard(entry); }, - [aiEngineEnabled], + [aiEngineEnabled, gated], ); // Open a category passed as ?category= (deep link from the super diff --git a/frontend/editor/src/portal/views/Sources.gated.test.tsx b/frontend/editor/src/portal/views/Sources.gated.test.tsx new file mode 100644 index 0000000000..944ab8c37c --- /dev/null +++ b/frontend/editor/src/portal/views/Sources.gated.test.tsx @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { PortalViewProviders } from "@portal/test/TestQueryProvider"; + +/** + * The deep link into the create flow, which the gate has to cover in its own right. + * + * `?new=1` opens the modal from an effect rather than through the click handler, so guarding + * openCreate does nothing for it. Both the Documents review queue and the pipelines empty state + * arrive here that way, so each is a way past the gate unless the deep link is guarded too. + */ +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); +const gate = { gated: true }; + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: gate.gated, + loading: false, + available: true, + connect, + guard: + (action: (...args: A) => void) => + (...args: A) => { + if (gate.gated) connect(); + else action(...args); + }, + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchSources = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + fetchSources: () => fetchSources(), + fetchSource: vi.fn(), + createSource: vi.fn(), + deleteSource: vi.fn(), + isFolderAccessDeniedError: () => false, +})); +vi.mock("@portal/api/integrations", () => ({ + fetchIntegrations: () => Promise.resolve([]), + fetchIntegrationCapabilities: () => Promise.resolve({ customApi: false }), + fetchS3Connections: () => Promise.resolve([]), + deleteIntegration: vi.fn(), +})); + +import { Sources } from "@portal/views/Sources"; + +const EDITOR_ROW = { + id: "editor", + name: "Editor", + type: "editor", + status: "active", + referenceCount: 0, + referencingPolicies: [], + config: [], + docsTotal: 0, + docs24h: 0, + docs30d: 0, +}; + +const renderAt = (initial: string) => + render( + + + + } /> + + + , + ); + +describe("Sources deep link when the account is not connected", () => { + beforeEach(() => { + connect.mockReset(); + gate.gated = true; + fetchSources.mockReset(); + fetchSources.mockResolvedValue({ kpis: [], sources: [EDITOR_ROW] }); + }); + + // Renders only once the fetch resolves, so finding it is also the await. + const LIST = "portal.sources.table.source"; + const MODAL = "portal.sources.builder.createTitle"; + + it("asks to connect instead of opening the create modal", async () => { + renderAt("/processor/sources?new=1"); + expect(await screen.findByText(LIST)).toBeInTheDocument(); + expect(connect).toHaveBeenCalled(); + expect(screen.queryByText(MODAL)).toBeNull(); + }); + + it("leaves the page looking exactly as it always does", async () => { + renderAt("/processor/sources"); + expect(await screen.findByText(LIST)).toBeInTheDocument(); + expect(connect).not.toHaveBeenCalled(); + }); + + it("still honours the deep link once connected", async () => { + gate.gated = false; + renderAt("/processor/sources?new=1"); + expect(await screen.findByText(LIST)).toBeInTheDocument(); + expect(connect).not.toHaveBeenCalled(); + expect(await screen.findByText(MODAL)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx index 166d4016fc..3891f1955d 100644 --- a/frontend/editor/src/portal/views/Sources.test.tsx +++ b/frontend/editor/src/portal/views/Sources.test.tsx @@ -22,6 +22,16 @@ const Providers = ({ children }: { children: ReactNode }) => ( const render = (ui: Parameters[0]) => baseRender(ui, { wrapper: Providers }); +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + // Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ useTranslation: () => ({ diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index f676b4f3db..bb740f5d69 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useSearchParams } from "react-router-dom"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; @@ -10,11 +10,13 @@ import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; import { SourceModal } from "@portal/components/sources/SourceModal"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/views/Sources.css"; export function Sources() { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); + const { guard, gated, connect } = useConnectGate(); const state = useSources(); const { data, loading } = state; @@ -27,13 +29,20 @@ export function Sources() { sourceId: string | null; }>({ open: false, sourceId: null }); + // Ref so the effect does not loop: it writes the param back, which would re-run it. + const connectRef = useRef(connect); + connectRef.current = connect; + + // Sets the modal directly, so it needs the gate in its own right: guarding openCreate would + // leave ?new=1 as a way past it. useEffect(() => { if (searchParams.get("new") !== "1") return; - setModal({ open: true, sourceId: null }); + if (gated) connectRef.current(); + else setModal({ open: true, sourceId: null }); const next = new URLSearchParams(searchParams); next.delete("new"); setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams]); + }, [searchParams, setSearchParams, gated]); const sources = data?.sources ?? []; @@ -42,9 +51,11 @@ export function Sources() { const configuredCount = sources.filter((s) => s.type !== "editor").length; const showKpis = isLoading || configuredCount > 0; - const openCreate = () => setModal({ open: true, sourceId: null }); - const openSource = (source: SourceView) => - setModal({ open: true, sourceId: source.id }); + // Connecting a source and editing one both need a linked account. + const openCreate = guard(() => setModal({ open: true, sourceId: null })); + const openSource = guard((source: SourceView) => + setModal({ open: true, sourceId: source.id }), + ); // The Connections tab moved to its own Integrations view. if (searchParams.get("tab") === "connections") { diff --git a/frontend/editor/src/portal/views/Users.caching.test.tsx b/frontend/editor/src/portal/views/Users.caching.test.tsx index a619d883b7..1a9e3e28c6 100644 --- a/frontend/editor/src/portal/views/Users.caching.test.tsx +++ b/frontend/editor/src/portal/views/Users.caching.test.tsx @@ -29,6 +29,16 @@ import { qk } from "@portal/queries/keys"; * one /team/my resolve. Same SaaS mocks as Users.saas.test.tsx. */ +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + vi.mock("@app/auth", () => ({ getStoredToken: () => null, clearStoredToken: vi.fn(), diff --git a/frontend/editor/src/portal/views/Users.gated.test.tsx b/frontend/editor/src/portal/views/Users.gated.test.tsx new file mode 100644 index 0000000000..b5286964fc --- /dev/null +++ b/frontend/editor/src/portal/views/Users.gated.test.tsx @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { PortalViewProviders } from "@portal/test/TestQueryProvider"; + +/** + * `?invite` opens the modal from an effect, so guarding openInvite did nothing for it. Same hole as + * Sources' `?new=1`, and it survived that fix. + */ +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); +const gate = { gated: true }; + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: gate.gated, + loading: false, + available: true, + connect, + guard: + (action: (...args: A) => void) => + (...args: A) => { + if (gate.gated) connect(); + else action(...args); + }, + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +vi.mock("@portal/contexts/TierContext", () => ({ + useTier: () => ({ tier: "pro" }), +})); +vi.mock("@app/auth", () => ({ + getStoredToken: () => null, + clearStoredToken: vi.fn(), +})); +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => null, + configureSupabase: vi.fn(), +})); +vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); + +vi.mock("@portal/hooks/useUsersData", () => ({ + useUsersData: () => ({ + usersState: { data: [], loading: false, error: null }, + grantsState: { data: [], loading: false, error: null }, + teamsState: { data: [], loading: false, error: null }, + authState: { data: null, loading: false, error: null }, + refresh: vi.fn(), + }), +})); + +import { Users } from "@portal/views/Users"; + +const INVITE_MODAL = "users.invite.title"; + +const renderAt = (initial: string) => + render( + + + + } /> + + + , + ); + +describe("Users deep link when the account is not connected", () => { + beforeEach(() => { + connect.mockReset(); + gate.gated = true; + }); + + it("asks to connect instead of opening the invite modal", () => { + renderAt("/processor/users?invite"); + expect(connect).toHaveBeenCalled(); + expect(screen.queryByText(INVITE_MODAL)).toBeNull(); + }); + + it("leaves the page alone when there is no deep link", () => { + renderAt("/processor/users"); + expect(connect).not.toHaveBeenCalled(); + }); + + it("still honours the deep link once connected", () => { + gate.gated = false; + renderAt("/processor/users?invite"); + expect(connect).not.toHaveBeenCalled(); + expect(screen.getByText(INVITE_MODAL)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/views/Users.saas.test.tsx b/frontend/editor/src/portal/views/Users.saas.test.tsx index 5313857c2c..1ea72e5a10 100644 --- a/frontend/editor/src/portal/views/Users.saas.test.tsx +++ b/frontend/editor/src/portal/views/Users.saas.test.tsx @@ -32,6 +32,16 @@ import { */ // Keep apiClient.local's transport hermetic (no real token / Supabase at import). +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + vi.mock("@app/auth", () => ({ getStoredToken: () => null, clearStoredToken: vi.fn(), diff --git a/frontend/editor/src/portal/views/Users.tsx b/frontend/editor/src/portal/views/Users.tsx index 79c6c11611..95b51091f0 100644 --- a/frontend/editor/src/portal/views/Users.tsx +++ b/frontend/editor/src/portal/views/Users.tsx @@ -21,6 +21,7 @@ import { import { deleteTeam as apiDeleteTeam } from "@portal/api/teams"; import { errorMessage } from "@portal/api/http"; import { usersCapabilities as caps } from "@app/portal/usersCapabilities"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import { UsersDirectory } from "@portal/components/users/UsersDirectory"; import { PendingInvitations } from "@portal/components/users/PendingInvitations"; import { InviteMemberModal } from "@portal/components/users/InviteMemberModal"; @@ -46,6 +47,7 @@ interface Confirm { */ export function Users() { const { t } = useTranslation(); + const { guard, gated, connect } = useConnectGate(); const { usersState, grantsState, teamsState, authState, refresh } = useUsersData(); @@ -62,13 +64,20 @@ export function Users() { const [confirm, setConfirm] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); + + // Ref so the effect does not loop: it writes the param back, which would re-run it. + const connectRef = useRef(connect); + connectRef.current = connect; + + // Sets the modal directly, so it needs the gate in its own right. useEffect(() => { if (searchParams.get("invite") === null) return; - setInviteOpen(true); + if (gated) connectRef.current(); + else setInviteOpen(true); const next = new URLSearchParams(searchParams); next.delete("invite"); setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams]); + }, [searchParams, setSearchParams, gated]); // Scroll to and flash the row for ?member= (deep link from the super // search), once the roster has rendered; then strip the param. Scoped to the @@ -194,10 +203,12 @@ export function Users() { if (!grant) return; run(() => revokeGrant(grant.id)); } - function openInvite(teamId: number | null) { + // Teams need a linked account, so inviting or creating one asks for the connection first. + const openInvite = guard((teamId: number | null) => { setInviteTeamId(teamId); setInviteOpen(true); - } + }); + const openNewTeam = guard(() => setNewTeamOpen(true)); // Kebab actions function toggleEnabled(member: Member) { @@ -282,11 +293,7 @@ export function Users() {
{caps.createTeam && ( - )} diff --git a/frontend/editor/src/saas/routes/ConnectApproveView.tsx b/frontend/editor/src/saas/routes/ConnectApproveView.tsx index 26a2fe1150..c89a27ef49 100644 --- a/frontend/editor/src/saas/routes/ConnectApproveView.tsx +++ b/frontend/editor/src/saas/routes/ConnectApproveView.tsx @@ -1,8 +1,51 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { useTranslation } from "@app/hooks/useTranslation"; import { Banner, Button, Checkbox, Spinner } from "@app/ui"; import { LocalIcon } from "@app/components/shared/LocalIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; + +/** + * This page is step 2 of a flow that started on the instance, so it wears the same chrome: the admin + * is being asked for a security decision by what would otherwise look like a different product. + * + *

TODO: re-auth still wears the first link's copy and consent checkbox, which asks the approver + * to agree to a binding that already exists. + */ +const TOTAL_STEPS = 3; + +function ApproveShell({ + title, + stepped, + children, +}: { + title: string; + /** Re-auth is one step on the instance side, so counting to three here would describe nothing. */ + stepped: boolean; + children: ReactNode; +}) { + const { t } = useTranslation(); + return ( +

+ {/* No onClose: this is a page, so there is nowhere to close back to. */} + + {children} +
+ ); +} export type ApprovePhase = | "loading" @@ -16,6 +59,8 @@ export interface PendingConnect { requestId: string; callbackOrigin: string; insecureTransport: boolean; + /** REAUTH cannot rebind: the team is pinned from the device credential at request time. */ + mode?: "LINK" | "REAUTH"; } export interface ConnectApproveViewProps { @@ -44,23 +89,31 @@ export function ConnectApproveView({ // Gates the primary action: anyone can create a request, so the approver reading // the address is the only thing between one and a linked team. const [acknowledged, setAcknowledged] = useState(false); + const stepped = pending?.mode !== "REAUTH"; if (phase === "loading" || phase === "redirecting") { return ( -
- -

- {phase === "redirecting" + -

+ : t("connect.loading", "Checking this request.") + } + > +
+ +
+ ); } if (phase === "notFound") { return ( -
+ -
+ ); } if (phase === "declined") { return ( -
+ -
+ ); } return ( -
-

- {t("connect.confirm.title", "Connect this server?")} -

+

{t( "connect.confirm.lead", @@ -177,6 +233,6 @@ export function ConnectApproveView({ {t("connect.confirm.approve", "Connect server")}

-
+ ); } diff --git a/frontend/editor/src/saas/routes/connect.css b/frontend/editor/src/saas/routes/connect.css index 5a709abfbe..391f9dad28 100644 --- a/frontend/editor/src/saas/routes/connect.css +++ b/frontend/editor/src/saas/routes/connect.css @@ -8,11 +8,10 @@ text-align: left; } -.saas-connect__title { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--c-text); +.saas-connect__waiting { + display: flex; + justify-content: center; + padding: 1.5rem 0; } .saas-connect__lead { From 4ef2e3811c71af7b2c39da98d82e6ce5b03af3af Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:35:57 +0000 Subject: [PATCH 52/57] ci(preview): give PR previews the Stirling account config they need to link (#7728) Add CI steps to enable PR deploy servers to link to prod saas. This will allow pr testing of payment flows, usage of real credits etc --- .github/workflows/PR-Auto-Deploy-V2.yml | 46 +++++++++++++++++++++++++ docker/embedded/Dockerfile | 14 ++++++++ 2 files changed, 60 insertions(+) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index c55d70fd4e..b4bbd111e1 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -220,6 +220,42 @@ jobs: echo "app_short=${APP_HASH:0:8}" >> $GITHUB_OUTPUT fi + # The Stirling account previews connect to. Derived from the ref rather than stored as a URL + # so it cannot drift from the key: a mismatched pair is accepted by the browser and rejected + # by Supabase, surfacing much later as "session expired" on Usage rather than at sign-in. + # Secret only to match Saas-Dev-Deploy.yml, which owns the same value; a project ref is not + # itself sensitive, which is why SAAS_API_BASE_URL next to it is a plain variable. + - name: Resolve Stirling account config + id: saas + env: + PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }} + API_BASE_OVERRIDE: ${{ vars.SAAS_API_BASE_URL }} + run: | + # Set, this is the one value both halves use: the browser's portal reads and the backend's + # register/entitlement calls have to land on the same SaaS, and nothing checks that they + # do. Unset, only the backend gets a base, from its own compiled-in default. + API_BASE="${API_BASE_OVERRIDE:-https://stirling.com/app}" + echo "backend_base=${API_BASE}" >> "$GITHUB_OUTPUT" + + if [ -z "${PROJECT_REF}" ]; then + echo "Not configured for this environment: the preview will build without a Stirling" + echo "account, and the connect dialog will say so. To wire one up, set on the" + echo "pr-preview environment the secrets SAAS_DB_PROJECT_REF and" + echo "SAAS_SUPABASE_PUBLISHABLE_KEY, both from the same Supabase project." + echo "supabase_url=" >> "$GITHUB_OUTPUT" + echo "frontend_base=" >> "$GITHUB_OUTPUT" + else + # Only whether, not which: the ref is a secret here, so Actions masks it out of any + # line it appears in, derived URL included. + echo "Stirling account configured, at ${API_BASE}." + echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT" + # Deliberately the override and not API_BASE: the backend's default is a subpath URL + # nobody has confirmed answers /api/v1, and prod CORS does not list preview hostnames, + # so portal reads stay off until someone sets a base they have checked. Empty leaves the + # committed .env default alone, which is the clean "not configured" state. + echo "frontend_base=${API_BASE_OVERRIDE}" >> "$GITHUB_OUTPUT" + fi + - name: Check if image exists id: check-image run: | @@ -246,6 +282,9 @@ jobs: build-args: | VERSION_TAG=v2-alpha BUILD_PORTAL=${{ env.BUILD_PORTAL }} + VITE_SUPABASE_URL=${{ steps.saas.outputs.supabase_url }} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }} + VITE_SAAS_API_URL=${{ steps.saas.outputs.frontend_base }} platforms: linux/amd64 - name: Set up SSH @@ -279,6 +318,13 @@ jobs: environment: DISABLE_ADDITIONAL_FEATURES: "false" STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true" + STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL: "${{ steps.saas.outputs.backend_base }}" + # Off so preview traffic never accrues against a real wallet or trips its cap. The + # 402 gate is separate and stays on, so gating is still testable here. + STIRLING_BILLING_ACCOUNT_LINK_METERING_ENABLED: "false" + # Stated rather than inferred from the request: the callback has to come back to the + # preview hostname, not to the container's own :8080 behind this proxy. + SYSTEM_FRONTENDURL: "https://${V2_PORT}.ssl.stirlingpdf.cloud" SECURITY_ENABLELOGIN: "true" SECURITY_INITIALLOGIN_USERNAME: "${TEST_LOGIN_USERNAME}" SECURITY_INITIALLOGIN_PASSWORD: "${TEST_LOGIN_PASSWORD}" diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 80e163dd9a..6f525b2732 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -48,9 +48,23 @@ ENV STIRLING_FLAVOR=${STIRLING_FLAVOR} # portal or AI layers change; defaults false so normal builds skip the extra app. ARG BUILD_PORTAL=false +# Which Stirling account the portal connects to. Build-time because Vite inlines VITE_* into the +# bundle; there is no runtime override. Empty leaves the committed .env.proprietary defaults, which +# is what an ordinary image wants: no Stirling account and no connect flow. The publishable key is +# client-side by design, not a secret. Pass the URL and the key from the same Supabase project or +# the browser accepts the pair and Supabase rejects it, which surfaces later as "session expired". +ARG VITE_SUPABASE_URL="" +ARG VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="" +ARG VITE_SAAS_API_URL="" + # Bundle only the JPDFium native for this image's target arch. ARG TARGETARCH +# Exported only when non-empty: Vite reads process.env ahead of the .env files, so exporting an +# empty value would blank the committed default rather than fall back to it. RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \ + if [ -n "${VITE_SUPABASE_URL}" ]; then export VITE_SUPABASE_URL="${VITE_SUPABASE_URL}"; fi; \ + if [ -n "${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}" ]; then export VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}"; fi; \ + if [ -n "${VITE_SAAS_API_URL}" ]; then export VITE_SAAS_API_URL="${VITE_SAAS_API_URL}"; fi; \ STIRLING_FLAVOR=${STIRLING_FLAVOR} \ gradle clean build \ -PbuildWithFrontend=true \ From ceeec53df4b72bd13576cd5146c07adf1e5ba96b Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:12:06 +0000 Subject: [PATCH 53/57] Let a pipeline run on the editor, on upload or export (#7581) Redesigns the policies system so that the backend has an understanding of policies running over the Editor. The Editor is not set up as a source for the backend because the backend can't actively get files from it, they come in via the frontend sending them to the backend, so instead pipelines have a specific editor key in them to encode whether the pipeline is triggered on file upload/export in the editor. Also make a big effort in the frontend code towards genericising policy running. Previously, there was specific support in the main policy executor for each policy that it had to run, which was not going to be appropriate long-term, especially when users can run any pipeline in the editor. There's more work needed here for me to really be happy with it but this PR is plenty large on its own and moves it in the right direction. All of the above was required to allow arbitrary user pipelines to run in the editor. This PR makes it so that the user can select Editor as a source in the pipeline creator, along with whether it should run on upload or export. image --------- Co-authored-by: James Brunton --- .../policy/controller/PolicyController.java | 12 +- .../policy/model/EditorConfig.java | 34 ++ .../proprietary/policy/model/Policy.java | 36 ++- .../overview/PolicyOverviewService.java | 6 +- .../DefaultClassificationPolicySeeder.java | 9 +- .../policy/source/SourceOverviewService.java | 10 +- .../policy/store/InProcessPolicyStore.java | 3 +- .../policy/store/JpaPolicyStore.java | 67 +++- .../overview/PolicyOverviewServiceTest.java | 39 +++ ...DefaultClassificationPolicySeederTest.java | 20 +- .../source/SourceOverviewServiceTest.java | 10 +- .../policy/store/JpaPolicyStoreTest.java | 124 ++++++++ .../public/locales/en-US/translation.toml | 9 + .../fileManager/CompactFileDetails.tsx | 3 +- .../components/filesPage/VersionTimeline.tsx | 10 +- .../src/core/components/shared/ToolChain.tsx | 35 +-- .../core/contexts/file/FileReducer.test.ts | 35 ++- .../src/core/contexts/file/FileReducer.ts | 20 +- .../src/core/services/fileStubHelpers.ts | 4 +- .../classification-heuristic-upload.spec.ts | 14 +- .../stubbed/editor-pipeline-auto-run.spec.ts | 85 +++++ frontend/editor/src/core/types/file.ts | 3 + .../src/core/utils/toolOperationLabel.test.ts | 29 ++ .../src/core/utils/toolOperationLabel.ts | 17 + frontend/editor/src/portal/api/pipelines.ts | 2 + frontend/editor/src/portal/api/policies.ts | 7 + .../pipelines/PipelineInputTrigger.tsx | 140 +++++++++ .../pipelines/graph/PipelineGraph.tsx | 6 +- .../components/policies/PolicyDetailPanel.tsx | 16 +- .../components/policies/PolicySetupWizard.tsx | 22 +- .../src/portal/views/PipelineBuilder.css | 12 + .../src/portal/views/PipelineBuilder.test.tsx | 39 +++ .../src/portal/views/PipelineBuilder.tsx | 213 +++++++------ .../policies/PolicyAutoRunController.tsx | 7 +- .../policies/classificationLocalPass.ts | 169 ++++++++++ .../components/policies/policyLocalPass.ts | 34 ++ .../policies/policyRunStore.test.ts | 23 -- .../components/policies/policyRunStore.ts | 22 +- .../policies/useClientSideClassification.ts | 251 --------------- .../policies/usePolicyAutoRun.batch.test.tsx | 129 ++++++-- .../policies/usePolicyAutoRun.chain.test.tsx | 297 ++++++------------ .../usePolicyAutoRun.escalation.test.tsx | 155 --------- .../policies/usePolicyAutoRun.import.test.tsx | 1 + .../policies/usePolicyAutoRun.race.test.tsx | 28 +- .../usePolicyAutoRun.reentry.test.tsx | 1 + .../policies/usePolicyAutoRun.retry.test.tsx | 1 + .../components/policies/usePolicyAutoRun.ts | 158 ++-------- ...test.tsx => usePolicyLocalPasses.test.tsx} | 150 +++++++-- .../policies/usePolicyLocalPasses.ts | 144 +++++++++ .../data/classificationPolicy.test.ts | 96 +++--- .../proprietary/data/classificationPolicy.ts | 60 ++-- .../src/proprietary/hooks/usePolicies.test.ts | 75 ++++- .../src/proprietary/hooks/usePolicies.ts | 22 +- .../src/proprietary/policies/codec.test.ts | 63 +++- .../editor/src/proprietary/policies/codec.ts | 12 +- .../editor/src/proprietary/policies/types.ts | 17 + .../services/policyBackend.test.ts | 135 ++++++++ .../src/proprietary/services/policyBackend.ts | 12 +- .../proprietary/services/policyDispatch.ts | 97 ++++++ .../proprietary/services/policyExport.test.ts | 129 ++++++++ .../src/proprietary/services/policyExport.ts | 38 ++- .../services/policyPipeline.test.ts | 6 +- .../proprietary/services/policyPipeline.ts | 22 +- .../services/policyStorage.test.ts | 37 +++ .../src/proprietary/services/policyStorage.ts | 35 ++- .../editor/src/proprietary/types/policies.ts | 6 + 66 files changed, 2405 insertions(+), 1118 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java create mode 100644 frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts create mode 100644 frontend/editor/src/core/utils/toolOperationLabel.test.ts create mode 100644 frontend/editor/src/core/utils/toolOperationLabel.ts create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/classificationLocalPass.ts create mode 100644 frontend/editor/src/proprietary/components/policies/policyLocalPass.ts delete mode 100644 frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts delete mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx rename frontend/editor/src/proprietary/components/policies/{useClientSideClassification.test.tsx => usePolicyLocalPasses.test.tsx} (62%) create mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyLocalPasses.ts create mode 100644 frontend/editor/src/proprietary/services/policyBackend.test.ts create mode 100644 frontend/editor/src/proprietary/services/policyDispatch.ts create mode 100644 frontend/editor/src/proprietary/services/policyExport.test.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 5a5a3018b1..3dac4e284e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -336,6 +336,15 @@ public class PolicyController { * nothing to check. */ private void requireAccessibleOutput(Policy policy) { + // An editor policy hands its results back to the workspace the file came from. A stored + // destination would send the run to a folder or bucket instead, leaving the editor's copy + // untouched - and the editor's import would then have nothing to collect. + if (policy.editor().allowed() && !policy.outputIds().isEmpty()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "An editor policy delivers back to the editor and can't also have a" + + " destination"); + } for (String outputId : policy.outputIds()) { Source destination = sourceStore @@ -393,7 +402,8 @@ public class PolicyController { policy.steps(), policy.output(), policy.outputIds(), - teamId); + teamId, + policy.editor()); } /** Output secrets never leave the server: reads return the redaction sentinel instead. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java new file mode 100644 index 0000000000..9b15adea2d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.policy.model; + +/** + * How a policy participates in the editor: it fires in the browser as each file passes through, + * rather than being swept from a stored {@code Source} on a trigger. + * + *

An object rather than a bare flag so the moment it fires ({@code runOn}) travels with the + * decision, and so later editor-only settings have somewhere to live. + * + * @param allowed whether the editor may run this policy at all + * @param runOn which moment it fires on: {@code "upload"} or {@code "export"} + */ +public record EditorConfig(boolean allowed, String runOn) { + + public static final String UPLOAD = "upload"; + public static final String EXPORT = "export"; + + public EditorConfig { + runOn = EXPORT.equals(runOn) ? EXPORT : UPLOAD; + } + + /** Not an editor policy: swept server-side, or run only on demand. */ + public static EditorConfig disabled() { + return new EditorConfig(false, UPLOAD); + } + + public static EditorConfig onUpload() { + return new EditorConfig(true, UPLOAD); + } + + public static EditorConfig onExport() { + return new EditorConfig(true, EXPORT); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index 14b1eb325c..63b26f380c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.policy.model; import java.util.List; +import java.util.Optional; /** * A stored automation: ordered tool steps, input bindings, and output destinations. @@ -24,13 +25,29 @@ public record Policy( List steps, OutputSpec output, List outputIds, - Long teamId) { + Long teamId, + EditorConfig editor) { public Policy { inputs = inputs == null ? List.of() : List.copyOf(inputs); steps = steps == null ? List.of() : steps; output = output == null ? OutputSpec.inline() : output; outputIds = outputIds == null ? List.of() : List.copyOf(outputIds); + editor = editor == null ? EditorConfig.disabled() : editor; + } + + /** Without editor participation: a swept or on-demand policy. */ + public Policy( + String id, + String name, + String owner, + boolean enabled, + List inputs, + List steps, + OutputSpec output, + List outputIds, + Long teamId) { + this(id, name, owner, enabled, inputs, steps, output, outputIds, teamId, null); } /** @@ -70,6 +87,14 @@ public record Policy( return inputs.stream().map(PipelineInput::sourceId).toList(); } + /** + * The moment this policy fires in the editor ("upload" / "export"), or empty when the editor + * does not run it. Legacy blobs are lifted onto {@link EditorConfig} when they are read. + */ + public Optional editorRunOn() { + return editor.allowed() ? Optional.of(editor.runOn()) : Optional.empty(); + } + /** The distinct trigger types configured across this policy's inputs (manual inputs aside). */ public List triggerTypes() { return inputs.stream() @@ -82,17 +107,20 @@ public record Policy( /** A copy with the inline output replaced (e.g. resolved for the engine, or migrated). */ public Policy withOutput(OutputSpec resolved) { - return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId); + return new Policy( + id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId, editor); } /** A copy under a different owner (e.g. moving a seed off a placeholder name). */ public Policy withOwner(String newOwner) { - return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId); + return new Policy( + id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId, editor); } /** A copy referencing the given saved output destinations. */ public Policy withOutputIds(List newOutputIds) { - return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId); + return new Policy( + id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId, editor); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java index b2be7b668e..0d7845a209 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -114,10 +114,14 @@ public class PolicyOverviewService { /** * Summarise a policy's triggers for the overview row: "manual" when no input is triggered, * otherwise the distinct trigger types across its inputs (e.g. "folder-watch, schedule"). + * + *

An editor policy has no wire input to trigger, but it is not manual either - it fires in + * the editor on every upload or export, so it reports that rather than reading as on-demand. */ private static String triggerSummary(Policy policy) { List types = policy.triggerTypes(); - return types.isEmpty() ? "manual" : String.join(", ", types); + if (!types.isEmpty()) return String.join(", ", types); + return policy.editorRunOn().map(runOn -> "editor-" + runOn).orElse("manual"); } private static String outputSummary(OutputSpec output) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java index 9b347366bc..7d35198633 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.model.TeamCreatedEvent; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; @@ -98,9 +99,8 @@ public class DefaultClassificationPolicySeeder { static Policy defaultPolicy(Long teamId) { Map options = new HashMap<>(); options.put("categoryId", CATEGORY); - options.put("runOn", "upload"); options.put("mode", "new_version"); - options.put("sources", List.of("editor")); + options.put("sources", List.of()); options.put("scopeTypes", List.of()); options.put("reviewerEmail", ""); return new Policy( @@ -113,6 +113,9 @@ public class DefaultClassificationPolicySeeder { List.of(), List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())), new OutputSpec("inline", options), - teamId); + List.of(), + teamId, + // Classification runs in the editor on every upload. + EditorConfig.onUpload()); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java index 0f9df21440..10792591d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java @@ -107,14 +107,12 @@ public class SourceOverviewService { } /** - * Whether a policy runs from the editor. Editor membership is carried in the policy's output - * metadata ({@code output.options.sources}) - a client-side list the editor writes when a - * policy targets it - rather than as a persisted {@code sourceId}, because the editor is - * virtual and has no stored source to reference. + * Whether a policy runs from the editor. Read from the policy's first-class {@link + * stirling.software.proprietary.policy.model.EditorConfig}, never inferred from a sources list + * (the editor is not a real source). */ private static boolean runsFromEditor(Policy policy) { - Object sources = policy.output().options().get("sources"); - return sources instanceof List list && list.contains(EditorSource.ID); + return policy.editor().allowed(); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index 70d67bba0f..08bc253863 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -38,7 +38,8 @@ public class InProcessPolicyStore implements PolicyStore { policy.steps(), policy.output(), policy.outputIds(), - policy.teamId()); + policy.teamId(), + policy.editor()); policies.put(id, stored); // Existing policy keeps its position; a new one appends to the end of its team's queue. sortOrders.computeIfAbsent(id, key -> nextSortOrder(stored.teamId())); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 6edaa76c78..f335a4d754 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.store; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Service; @@ -11,8 +12,10 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyBinding; +import stirling.software.proprietary.policy.source.EditorSource; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @@ -48,7 +51,8 @@ public class JpaPolicyStore implements PolicyStore { policy.steps(), policy.output(), policy.outputIds(), - policy.teamId()); + policy.teamId(), + policy.editor()); PolicyEntity entity = new PolicyEntity(); entity.setId(id); @@ -148,7 +152,9 @@ public class JpaPolicyStore implements PolicyStore { // One unreadable row must never abort a bulk read or crash startup. private Optional toPolicy(PolicyEntity entity) { try { - JsonNode node = upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson())); + JsonNode node = + liftEditorConfig( + upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson()))); return Optional.of(objectMapper.treeToValue(node, Policy.class)); } catch (Exception e) { log.error( @@ -191,4 +197,61 @@ public class JpaPolicyStore implements PolicyStore { obj.remove("sourceIds"); return obj; } + + /** Categories whose editor moment defaulted to export before it was stored (see runOn.ts). */ + private static final Set EXPORT_BY_DEFAULT = Set.of("security"); + + /** + * Derive {@code editor} for a blob written before editor participation had its own field, from + * its {@code output.options}: allowed when {@code sources} lists {@code "editor"}, or - for a + * catalogue policy - when there is no {@code sources} list at all (an unnarrowed catalogue + * policy runs in the editor). + * + *

Runs on every read, deliberately outside {@link #upgradeLegacyShape}'s early return: a + * blob written after triggers moved onto {@code inputs} but before this field existed still + * needs lifting, and that early return would skip exactly those rows. + */ + private JsonNode liftEditorConfig(JsonNode root) { + if (!(root instanceof ObjectNode obj) || obj.hasNonNull("editor")) { + return root; + } + JsonNode options = obj.path("output").path("options"); + String categoryId = text(options, "categoryId"); + JsonNode sources = options.get("sources"); + boolean listed = sources != null && sources.isArray() && !sources.isEmpty(); + boolean allowed; + if (listed) { + // An explicit scope list decides: only the editor's own id puts it on the editor. + allowed = false; + for (JsonNode source : sources) { + if (source.isValueNode() && EditorSource.ID.equals(source.asString())) { + allowed = true; + break; + } + } + } else { + // No list: a catalogue policy ran in the editor by default, but a builder pipeline + // (no category) could not reach the editor at all, so silence is not consent there. + allowed = !categoryId.isBlank(); + } + ObjectNode editor = objectMapper.createObjectNode(); + editor.put("allowed", allowed); + editor.put("runOn", legacyRunOn(options, categoryId)); + obj.set("editor", editor); + return obj; + } + + /** The stored moment, or the category default the client applied when none was stored. */ + private static String legacyRunOn(JsonNode options, String categoryId) { + String stored = text(options, "runOn"); + if (EditorConfig.EXPORT.equals(stored) || EditorConfig.UPLOAD.equals(stored)) { + return stored; + } + return EXPORT_BY_DEFAULT.contains(categoryId) ? EditorConfig.EXPORT : EditorConfig.UPLOAD; + } + + private static String text(JsonNode parent, String field) { + JsonNode node = parent.path(field); + return node.isValueNode() ? node.asString() : ""; + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java index 373b596136..4d0830f9c5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java @@ -15,6 +15,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; @@ -223,6 +224,44 @@ class PolicyOverviewServiceTest { teamId)); } + @Test + void editorPolicyReportsItsRunMomentRatherThanReadingAsManual() { + policyStore.save( + new Policy( + null, + "Editor flatten", + "owner", + true, + List.of(), + List.of(new PipelineStep("/api/v1/misc/flatten", Map.of())), + OutputSpec.inline(), + List.of(), + 1L, + EditorConfig.onUpload())); + + PolicyView view = find(service.overview(), "Editor flatten"); + + assertEquals("editor-upload", view.trigger()); + } + + @Test + void sweptPolicyWithNoTriggeredInputIsStillManual() { + policyStore.save( + new Policy( + null, + "Swept compress", + "owner", + true, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline(), + 1L)); + + PolicyView view = find(service.overview(), "Swept compress"); + + assertEquals("manual", view.trigger()); + } + private static PolicyView find(PoliciesOverviewResponse response, String name) { return response.pipelines().stream() .filter(view -> view.name().equals(name)) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java index f6e82bd011..fb4fa6d419 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java @@ -64,14 +64,30 @@ class DefaultClassificationPolicySeederTest { assertThat(policy.teamId()).isEqualTo(7L); assertThat(policy.output().type()).isEqualTo("inline"); assertThat(policy.output().options().get("categoryId")).isEqualTo("classification"); - assertThat(policy.output().options().get("runOn")).isEqualTo("upload"); assertThat(policy.output().options().get("mode")).isEqualTo("new_version"); - assertThat(policy.output().options().get("sources")).isEqualTo(List.of("editor")); + // Editor participation is the policy's own flag, not a marker in the output options. + assertThat(policy.editor().allowed()).isTrue(); + assertThat(policy.editor().runOn()).isEqualTo("upload"); assertThat(policy.steps()).hasSize(1); assertThat(policy.steps().get(0).operation()) .isEqualTo("/api/v1/ai/tools/classify-and-label"); } + @Test + void marksEditorParticipationOnEditorConfigAndSeedsNoSources() { + when(policyStore.findByTeam(7L)).thenReturn(List.of()); + + seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme")); + + ArgumentCaptor saved = ArgumentCaptor.forClass(Policy.class); + verify(policyStore).save(saved.capture()); + Policy policy = saved.getValue(); + // Editor participation is on EditorConfig, not the sources list; the seed carries no + // sources. + assertThat(policy.editor().allowed()).isTrue(); + assertThat(policy.output().options().get("sources")).isEqualTo(List.of()); + } + @Test void doesNotSeedWhenAClassificationPolicyAlreadyExists() { when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L))); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java index a8c295acc8..66d75f8be0 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java @@ -15,6 +15,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; @@ -222,9 +223,7 @@ class SourceOverviewServiceTest { OutputSpec.inline())); } - /** - * A policy that targets the editor: membership rides in its output metadata, not a sourceId. - */ + /** A policy that targets the editor: membership on its {@link EditorConfig}, not a sourceId. */ private void editorPolicy(String name) { policyStore.save( new Policy( @@ -234,7 +233,10 @@ class SourceOverviewServiceTest { true, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), - new OutputSpec("inline", Map.of("sources", List.of("editor"))))); + OutputSpec.inline(), + List.of(), + null, + EditorConfig.onUpload())); } private void teamPolicy(String name, Long teamId, String... sourceIds) { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java index 2a1d2b4f11..ae95b3a3ce 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java @@ -18,6 +18,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; @@ -113,6 +114,129 @@ class JpaPolicyStoreTest { upgraded.inputs()); } + /** + * The regression this guards: before the editor lift, a blob written by the pre-{@code editor} + * seeder deserialized straight onto {@link EditorConfig#disabled()}, silently taking every + * upgraded install's Classification policy off the editor. + * + *

The {@code inputs} variant is the important one - {@link + * JpaPolicyStore#upgradeLegacyShape} returns early on it, so a lift living inside that method + * would miss exactly the rows written between the trigger migration and this field. + */ + @Test + void getLiftsALegacyEditorSourceOntoEditorConfigWhenInputsArePresent() { + Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"editor\"],")); + + assertEquals(EditorConfig.onUpload(), lifted.editor()); + assertEquals(Optional.of("upload"), lifted.editorRunOn()); + } + + @Test + void getLiftsALegacyEditorSourceOnThePreInputsShapeToo() { + // Oldest shape: policy-level trigger + sourceIds, so both migrations have to compose. + Policy lifted = + readLegacy( + legacyJson( + "\"trigger\":{\"type\":\"schedule\",\"options\":{}}," + + "\"sourceIds\":[\"s1\"],", + "\"sources\":[\"editor\"],")); + + assertEquals(EditorConfig.onUpload(), lifted.editor()); + assertEquals( + List.of(new PipelineInput("s1", new TriggerConfig("schedule", Map.of()))), + lifted.inputs()); + } + + @Test + void getTreatsAnUnnarrowedCataloguePolicyAsEditorRun() { + // Empty and absent both meant "nobody narrowed it", which the editor read as its own. + assertTrue(readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[],")).editor().allowed()); + assertTrue(readLegacy(legacyJson("\"inputs\":[],", "")).editor().allowed()); + } + + @Test + void getLeavesACataloguePolicyScopedElsewhereOffTheEditor() { + Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"sharepoint\"],")); + + assertFalse(lifted.editor().allowed()); + assertEquals(Optional.empty(), lifted.editorRunOn()); + } + + @Test + void getLeavesASourcelessBuilderPipelineOffTheEditor() { + // No categoryId: a pipeline built on the Pipelines page, which never reached the editor. + String json = + "{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[]," + + "\"steps\":[],\"output\":{\"type\":\"inline\",\"options\":{}}}"; + + assertFalse(readLegacy(json).editor().allowed()); + } + + @Test + void getKeepsTheCategoryDefaultMomentWhenNoRunOnWasStored() { + // Security enforced on export before runOn was persisted (frontend runOn.ts + // DEFAULT_RUN_ON). + String json = + "{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[]," + + "\"steps\":[],\"output\":{\"type\":\"inline\",\"options\":{" + + "\"categoryId\":\"security\",\"sources\":[\"editor\"]}}}"; + + assertEquals(EditorConfig.onExport(), readLegacy(json).editor()); + } + + @Test + void getNeverOverridesAnExplicitlyStoredEditorBlock() { + // A deliberate opt-out survives, so the lift stays safe to leave in permanently. + String json = + "{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[]," + + "\"steps\":[],\"editor\":{\"allowed\":false,\"runOn\":\"upload\"}," + + "\"output\":{\"type\":\"inline\",\"options\":{" + + "\"categoryId\":\"classification\",\"sources\":[\"editor\"]}}}"; + + assertFalse(readLegacy(json).editor().allowed()); + } + + /** + * Pins the wire shape the stubbed Playwright spec hardcodes: the derived block is additive, so + * a real response carries it alongside the untouched legacy options bag. + */ + @Test + void getLeavesTheLegacyOptionsBagIntactSoTheResponseCarriesBoth() { + Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"editor\"],")); + + assertEquals(List.of("editor"), lifted.output().options().get("sources")); + String wire = objectMapper.writeValueAsString(lifted); + assertTrue( + wire.contains("\"editor\":{\"allowed\":true,\"runOn\":\"upload\"}"), + "expected the derived editor block on the wire, got: " + wire); + } + + /** + * The blob main's DefaultClassificationPolicySeeder wrote, with the shape bits parameterised. + */ + private static String legacyJson(String shapeFields, String sourcesField) { + return "{\"id\":\"p1\",\"name\":\"Classification Policy\",\"owner\":\"system\"," + + "\"enabled\":true," + + shapeFields + + "\"steps\":[{\"operation\":\"/api/v1/ai/tools/classify-and-label\"," + + "\"parameters\":{}}]," + + "\"output\":{\"type\":\"inline\",\"options\":{" + + "\"categoryId\":\"classification\",\"runOn\":\"upload\"," + + "\"mode\":\"new_version\"," + + sourcesField + + "\"scopeTypes\":[],\"reviewerEmail\":\"\"}},\"teamId\":1}"; + } + + private Policy readLegacy(String policyJson) { + PolicyEntity entity = new PolicyEntity(); + entity.setId("p1"); + entity.setName("legacy"); + entity.setEnabled(true); + entity.setPolicyJson(policyJson); + when(repository.findById("p1")).thenReturn(Optional.of(entity)); + return store.get("p1").orElseThrow(); + } + @Test void saveDenormalizesTeamIdForScopedQueries() { store.save( diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index df996b23c0..345cf07ab2 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -8220,6 +8220,9 @@ chooseDestination = "Choose a destination" chooseOperation = "Choose what this step does" chooseSource = "Choose a source" discard = "Discard changes" +editorDestination = "Editor" +editorDestinationDetail = "Replaces the file you ran it on" +editorDestinationHelp = "This pipeline runs on the files in your workspace, and its results replace the file it ran on. There is nowhere else to send them." inputs = "Input" inputSource = "Input source" inputTrigger = "Trigger" @@ -8231,6 +8234,10 @@ needsSource = "No source chosen" noToolMatches = "No tools match your search." pause = "Pause" rename = "Rename pipeline" +runOn = "Runs on" +runOnExport = "Every export" +runOnTooltip = "Choose when this pipeline runs on your files: when you add them, or when you export them." +runOnUpload = "Every upload" searchTools = "Search tools" sendToSystem = "Send to another system" stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}." @@ -8371,6 +8378,8 @@ steps = "Steps" trigger = "Trigger" [portal.pipelines.trigger] +editor-export = "Every export" +editor-upload = "Every upload" folder-watch = "Folder watch" manual = "Manual" schedule = "Scheduled" diff --git a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx index 988ecf789c..27f55ce060 100644 --- a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx +++ b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx @@ -7,6 +7,7 @@ import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import { useTranslation } from "react-i18next"; import { getFileSize } from "@app/utils/fileUtils"; +import { toolOperationLabel } from "@app/utils/toolOperationLabel"; import { StirlingFileStub } from "@app/types/fileContext"; import { PrivateContent } from "@app/components/shared/PrivateContent"; @@ -115,7 +116,7 @@ const CompactFileDetails: React.FC = ({ {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( {currentFile.toolHistory - .map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)) + .map((tool) => toolOperationLabel(tool, t)) .join(" → ")} )} diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx index f484f936ef..77d8808c17 100644 --- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx @@ -10,7 +10,7 @@ import HistoryIcon from "@mui/icons-material/History"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import { FileId, ToolOperation } from "@app/types/file"; -import { ToolId } from "@app/types/toolId"; +import { toolOperationLabel } from "@app/utils/toolOperationLabel"; import { StirlingFileStub } from "@app/types/fileContext"; import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; import { downloadFileFromStorage } from "@app/utils/downloadUtils"; @@ -64,10 +64,10 @@ function deltaToolFor( return curr[priorLen] ?? null; } -/** Translated tool name via `home.{toolId}.title`. */ -function ToolLabel({ toolId }: { toolId: ToolId }) { +/** The operation's own label when it has one, else its translated tool name. */ +function ToolLabel({ operation }: { operation: ToolOperation }) { const { t } = useTranslation(); - return {t(`home.${toolId}.title`, toolId)}; + return {toolOperationLabel(operation, t)}; } export interface VersionTimelineProps { @@ -242,7 +242,7 @@ export function VersionTimeline({ style={{ color: "var(--c-text)" }} > {delta ? ( - + ) : ( t("filesPage.versionOrigin", "Original upload") )} diff --git a/frontend/editor/src/core/components/shared/ToolChain.tsx b/frontend/editor/src/core/components/shared/ToolChain.tsx index 249e7802cc..7974614759 100644 --- a/frontend/editor/src/core/components/shared/ToolChain.tsx +++ b/frontend/editor/src/core/components/shared/ToolChain.tsx @@ -6,8 +6,8 @@ import React from "react"; import { Text, Tooltip, Badge, Group } from "@mantine/core"; import { ToolOperation } from "@app/types/file"; +import { toolOperationLabel } from "@app/utils/toolOperationLabel"; import { useTranslation } from "react-i18next"; -import { ToolId } from "@app/types/toolId"; interface ToolChainProps { toolChain: ToolOperation[]; @@ -29,11 +29,7 @@ const ToolChain: React.FC = ({ const { t } = useTranslation(); if (!toolChain || toolChain.length === 0) return null; - const toolIds = toolChain.map((tool) => tool.toolId); - - const getToolName = (toolId: ToolId) => { - return t(`home.${toolId}.title`, toolId); - }; + const getToolName = (tool: ToolOperation) => toolOperationLabel(tool, t); // Create full tool chain for tooltip const fullChainDisplay = @@ -42,7 +38,7 @@ const ToolChain: React.FC = ({ {toolChain.map((tool, index) => ( - {getToolName(tool.toolId)} + {getToolName(tool)} {index < toolChain.length - 1 && ( @@ -53,18 +49,21 @@ const ToolChain: React.FC = ({ ))} ) : ( - {toolIds.map(getToolName).join(" → ")} + {toolChain.map(getToolName).join(" → ")} ); // Create truncated display based on available space const getTruncatedDisplay = () => { - if (toolIds.length <= 2) { + if (toolChain.length <= 2) { // Show all tools if 2 or fewer - return { text: toolIds.map(getToolName).join(" → "), isTruncated: false }; + return { + text: toolChain.map(getToolName).join(" → "), + isTruncated: false, + }; } else { // Show first tool ... last tool for longer chains return { - text: `${getToolName(toolIds[0])} → +${toolIds.length - 2} → ${getToolName(toolIds[toolIds.length - 1])}`, + text: `${getToolName(toolChain[0])} → +${toolChain.length - 2} → ${getToolName(toolChain[toolChain.length - 1])}`, isTruncated: true, }; } @@ -75,10 +74,10 @@ const ToolChain: React.FC = ({ // Compact style for very small spaces if (displayStyle === "compact") { const compactText = - toolIds.length === 1 - ? getToolName(toolIds[0]) - : `${toolIds.length} tools`; - const isCompactTruncated = toolIds.length > 1; + toolChain.length === 1 + ? getToolName(toolChain[0]) + : `${toolChain.length} tools`; + const isCompactTruncated = toolChain.length > 1; const compactElement = ( = ({ {toolChain.slice(0, 3).map((tool, index) => ( - {getToolName(tool.toolId)} + {getToolName(tool)} {index < Math.min(toolChain.length - 1, 2) && ( @@ -131,7 +130,7 @@ const ToolChain: React.FC = ({ ... - {getToolName(toolChain[toolChain.length - 1].toolId)} + {getToolName(toolChain[toolChain.length - 1])} )} @@ -140,7 +139,7 @@ const ToolChain: React.FC = ({ ); return isBadgesTruncated ? ( - + {badgesElement} ) : ( diff --git a/frontend/editor/src/core/contexts/file/FileReducer.test.ts b/frontend/editor/src/core/contexts/file/FileReducer.test.ts index cdba68b013..92f82f3ed0 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.test.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.test.ts @@ -192,6 +192,39 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)", ]); }); + it("carries a no-label [] verdict forward (classified, not unclassified)", () => { + // "a" was classified and found nothing ([]) - distinct from null (never classified). The output + // must inherit [] so the local pass treats it as already-classified and never re-classifies (or + // re-bills) it. + const start = stateWith([stub("a", { classificationLabels: [] })]); + const next = fileContextReducer(start, { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId], + outputStirlingFileStubs: [stub("b")], + }, + }); + expect(next.files.byId["b" as FileId].classificationLabels).toEqual([]); + }); + + it("prefers a real label over a merge input's no-label [] verdict", () => { + // Merge of a labelled file and a no-label one: the output should keep the real label. + const start = stateWith([ + stub("a", { classificationLabels: [] }), + stub("b", { classificationLabels: ["Invoice"] }), + ]); + const next = fileContextReducer(start, { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId, "b" as FileId], + outputStirlingFileStubs: [stub("c")], + }, + }); + expect(next.files.byId["c" as FileId].classificationLabels).toEqual([ + "Invoice", + ]); + }); + it("an output's own classificationLabels win over the input's", () => { // A re-classify produces an output that already carries (fresher) labels. const start = stateWith([stub("a", { classificationLabels: ["Invoice"] })]); @@ -211,7 +244,7 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)", it("carries classificationConfidence forward with the labels", () => { // The confidence is part of the verdict: without it the escalation decision - // (shouldDispatchToAi) dies at the version boundary and a chained + // (localVerdictNeedsEscalation) dies at the version boundary and a chained // classification never runs. const start = stateWith([ stub("a", { diff --git a/frontend/editor/src/core/contexts/file/FileReducer.ts b/frontend/editor/src/core/contexts/file/FileReducer.ts index 1ed23b11d6..ba69492b23 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.ts @@ -389,16 +389,16 @@ export function fileContextReducer( // Carry the document's classification verdict forward across the edit: any // tool that versions/derives a classified file keeps it in its label // groups instead of dropping to "Other" and waiting on a PDF re-read. - // Inherited from the first input that has labels, together with that - // verdict's confidence - the escalation decision (shouldDispatchToAi) is - // about the document, not about which step produced the current bytes, so - // it must survive the version boundary. An output that already carries its - // own verdict (e.g. a fresh classify result) keeps it. - const verdictDonor = inputFileIds - .map((id) => state.files.byId[id]) - .find( + // Inherited together with that verdict's confidence - the escalation + // decision (localVerdictNeedsEscalation) is about the document, not about + // which step produced the current bytes, so it must survive the version + // boundary. An output that already carries its own verdict (e.g. a fresh + // classify result) keeps it. + const inputStubs = inputFileIds.map((id) => state.files.byId[id]); + const verdictDonor = + inputStubs.find( (s) => s?.classificationLabels && s.classificationLabels.length > 0, - ); + ) ?? inputStubs.find((s) => s?.classificationLabels !== undefined); // Mark every consume output as tool-produced (the single chokepoint for // both versioned edits and independent artifacts like convert/split/merge) @@ -409,7 +409,7 @@ export function fileContextReducer( ...stub, derivedFromTool: true, sourceFileIds, - ...(stub.classificationLabels == null && verdictDonor + ...(stub.classificationLabels === undefined && verdictDonor ? { classificationLabels: verdictDonor.classificationLabels, classificationConfidence: diff --git a/frontend/editor/src/core/services/fileStubHelpers.ts b/frontend/editor/src/core/services/fileStubHelpers.ts index 60489c8c94..836d000af7 100644 --- a/frontend/editor/src/core/services/fileStubHelpers.ts +++ b/frontend/editor/src/core/services/fileStubHelpers.ts @@ -14,6 +14,8 @@ export async function createStirlingFilesAndStubs( files: File[], parentStub: StirlingFileStub, toolId: ToolId, + /** Shown instead of the tool's name in version history (a policy passes its pipeline name). */ + label?: string, ): Promise<{ stirlingFiles: StirlingFile[]; stubs: StirlingFileStub[] }> { const stirlingFiles: StirlingFile[] = []; const stubs: StirlingFileStub[] = []; @@ -22,7 +24,7 @@ export async function createStirlingFilesAndStubs( const processedFileMetadata = await generateProcessedFileMetadata(file); const childStub = createChildStub( parentStub, - { toolId, timestamp: Date.now() }, + { toolId, timestamp: Date.now(), ...(label ? { label } : {}) }, file, processedFileMetadata?.thumbnailUrl, processedFileMetadata, diff --git a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts index 856ddd58f5..7d42434385 100644 --- a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts @@ -12,14 +12,20 @@ const FIXTURES = path.join( "../test-fixtures/classification/unlabelled", ); -/** The stored policy DefaultClassificationPolicySeeder writes for a new team. */ +/** + * What GET /api/v1/policies returns for the row an older + * DefaultClassificationPolicySeeder wrote - i.e. one stored before editor + * participation had its own field. `JpaPolicyStore.liftEditorConfig` derives the + * `editor` block from the legacy `output.options` on read; the lift is additive, + * so a real response carries both. Migration of the stored shape itself is + * covered by JpaPolicyStoreTest, which exercises the Java the stub stands in for. + */ const SEEDED_POLICY = { id: "seeded-classification", name: "Classification Policy", owner: "system", enabled: true, - trigger: null, - sourceIds: [], + inputs: [], steps: [{ operation: "/api/v1/ai/tools/classify-and-label", parameters: {} }], output: { type: "inline", @@ -32,7 +38,9 @@ const SEEDED_POLICY = { reviewerEmail: "", }, }, + outputIds: [], teamId: 1, + editor: { allowed: true, runOn: "upload" }, }; test("a 10-file upload wave classifies every file into its group", async ({ diff --git a/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts b/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts new file mode 100644 index 0000000000..a71bc98f33 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts @@ -0,0 +1,85 @@ +import path from "path"; +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles } from "@app/tests/helpers/ui-helpers"; + +// A pipeline reaches the editor auto-run through its own editor flag; a swept one must not. + +test.use({ autoGoto: false }); + +const SAMPLE = path.join( + import.meta.dirname, + "../test-fixtures/classification/unlabelled/invoice_acme.pdf", +); + +/** A builder-made pipeline: no categoryId, one harmless step. */ +function builderPipeline(editor: { allowed: boolean; runOn: string }) { + return { + id: "builder-pipeline-1", + name: "Flatten everything", + owner: "system", + enabled: true, + trigger: null, + sourceIds: [], + steps: [{ operation: "/api/v1/misc/flatten", parameters: {} }], + output: { type: "inline", options: { mode: "new_version" } }, + editor, + teamId: 1, + }; +} + +/** Install the policy list + capture every stored-policy run dispatch. */ +async function armed(page: import("@playwright/test").Page, policy: unknown) { + const dispatched: string[] = []; + await page.route("**/api/v1/policies", (route) => + route.fulfill({ json: [policy] }), + ); + await page.route("**/api/v1/policies/*/run", (route) => { + dispatched.push(new URL(route.request().url()).pathname); + return route.fulfill({ json: { jobId: "job-1" } }); + }); + return dispatched; +} + +test("an editor pipeline set to run on upload dispatches when a file is added", async ({ + page, +}) => { + const dispatched = await armed( + page, + builderPipeline({ allowed: true, runOn: "upload" }), + ); + + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + await uploadFiles(page, SAMPLE); + + await expect + .poll(() => dispatched, { timeout: 15_000 }) + .toContain("/api/v1/policies/builder-pipeline-1/run"); +}); + +test("a swept pipeline never runs on editor upload", async ({ page }) => { + const dispatched = await armed( + page, + builderPipeline({ allowed: false, runOn: "upload" }), + ); + + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + await uploadFiles(page, SAMPLE); + + await page.waitForTimeout(5_000); + expect(dispatched).toEqual([]); +}); + +test("an editor pipeline set to run on export does not fire on upload", async ({ + page, +}) => { + const dispatched = await armed( + page, + builderPipeline({ allowed: true, runOn: "export" }), + ); + + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + await uploadFiles(page, SAMPLE); + + await page.waitForTimeout(5_000); + expect(dispatched).toEqual([]); +}); diff --git a/frontend/editor/src/core/types/file.ts b/frontend/editor/src/core/types/file.ts index 98a0094f43..c6ec1898cb 100644 --- a/frontend/editor/src/core/types/file.ts +++ b/frontend/editor/src/core/types/file.ts @@ -16,6 +16,9 @@ export type FileId = string & { readonly [tag]: "FileId" }; export interface ToolOperation { toolId: ToolId; timestamp: number; + /** Overrides the tool's own name in history. Set by a policy run to its pipeline's name, since + * every policy records the same "automate" toolId. */ + label?: string; } /** diff --git a/frontend/editor/src/core/utils/toolOperationLabel.test.ts b/frontend/editor/src/core/utils/toolOperationLabel.test.ts new file mode 100644 index 0000000000..9b21f6e65f --- /dev/null +++ b/frontend/editor/src/core/utils/toolOperationLabel.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import type { TFunction } from "i18next"; +import { toolOperationLabel } from "@app/utils/toolOperationLabel"; +import type { ToolOperation } from "@app/types/file"; + +// Stands in for i18next: echoes the key so the assertions show which lookup ran. +const t = ((key: string, fallback?: string) => + key === "home.automate.title" ? "Automate" : (fallback ?? key)) as TFunction; + +const op = (over: Partial): ToolOperation => + ({ toolId: "automate", timestamp: 0, ...over }) as ToolOperation; + +describe("toolOperationLabel", () => { + it("prefers the operation's own label", () => { + expect(toolOperationLabel(op({ label: "add-page-numbers" }), t)).toBe( + "add-page-numbers", + ); + }); + + // Every policy records the same "automate" toolId, so without a label each automated version + // reads identically no matter which pipeline produced it. + it("falls back to the tool's name when unlabelled", () => { + expect(toolOperationLabel(op({}), t)).toBe("Automate"); + }); + + it("keeps the fallback for an empty label rather than rendering a blank", () => { + expect(toolOperationLabel(op({ label: "" }), t)).toBe("Automate"); + }); +}); diff --git a/frontend/editor/src/core/utils/toolOperationLabel.ts b/frontend/editor/src/core/utils/toolOperationLabel.ts new file mode 100644 index 0000000000..1f29d02390 --- /dev/null +++ b/frontend/editor/src/core/utils/toolOperationLabel.ts @@ -0,0 +1,17 @@ +import type { TFunction } from "i18next"; +import type { ToolOperation } from "@app/types/file"; + +/** + * What produced a version, for the history surfaces. A policy run carries its own label (the + * pipeline's name) because every policy records the same "automate" toolId, which would otherwise + * render every automated version identically. + */ +export function toolOperationLabel( + operation: ToolOperation, + t: TFunction, +): string { + // Truthiness, not nullish: a blank label would otherwise render as an empty history entry. + return ( + operation.label || t(`home.${operation.toolId}.title`, operation.toolId) + ); +} diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index e441fcdac8..2be269c1ac 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -70,6 +70,8 @@ export interface Policy { * output} is used. */ outputIds: string[]; + /** Whether the editor runs this policy per file, and on which moment. */ + editor?: { allowed: boolean; runOn: "upload" | "export" }; teamId?: number | null; } diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index da545b98c4..6963ccc1b1 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -76,6 +76,8 @@ export interface PolicyState { configured: boolean; status: PolicyStatus; sources: string[]; + /** Whether the editor runs this policy per file; stored, not derived from `sources`. */ + runsOnEditor?: boolean; scopeTypes: string[]; reviewerEmail: string; fieldValues: Record; @@ -92,6 +94,7 @@ export interface PolicyState { export interface PolicySetupResult { fieldValues: Record; sources: string[]; + runsOnEditor: boolean; scopeTypes: string[]; reviewerEmail: string; outputMode: "new_file" | "new_version"; @@ -433,6 +436,7 @@ function decoratePolicy( configured: true, status, sources: decoded.sources, + runsOnEditor: decoded.runsOnEditor, scopeTypes: decoded.scopeTypes, reviewerEmail: decoded.reviewerEmail, fieldValues: decoded.fieldValues, @@ -595,6 +599,7 @@ export function buildWireFromSetup( enabled, categoryId: entry.category.id, sources: result.sources, + runsOnEditor: result.runsOnEditor, scopeTypes: result.scopeTypes, reviewerEmail: result.reviewerEmail, fieldValues: result.fieldValues, @@ -625,6 +630,8 @@ export function buildWireFromState( enabled, categoryId: entry.category.id, sources: s.sources, + // Carry the stored value through: pause/resume must not re-derive it. + runsOnEditor: s.runsOnEditor === true, scopeTypes: s.scopeTypes, reviewerEmail: s.reviewerEmail, fieldValues: s.fieldValues, diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx b/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx new file mode 100644 index 0000000000..eb5af9cd21 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx @@ -0,0 +1,140 @@ +// Swept sources are scheduled or triggered server-side; the editor runs client-side as each file +// passes through, so the two get different controls. + +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { FormField, Input, Select } from "@app/ui"; + +export type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS"; +export type EditorRunOn = "upload" | "export"; + +const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"]; + +/** Empty trigger type = manual-only (no automatic trigger). */ +export const MANUAL = ""; +/** Sentinel for manual: Mantine's Select reads "" as no selection. Maps to {@link MANUAL}. */ +export const MANUAL_OPTION = "manual"; + +/** One input row in the builder: a source paired with its own trigger config. */ +export interface WorkingInput { + sourceId: string; + triggerType: string; + scheduleCount: string; + scheduleUnit: ScheduleUnit; +} + +export interface PipelineInputTriggerProps { + input: WorkingInput; + onInputChange: (patch: Partial) => void; + /** Trigger types offered for this row's source (manual first). */ + triggerOptions: { value: string; label: string }[]; + /** The chosen source is the editor, so the pipeline runs in the browser. */ + isEditorInput: boolean; + runOn: EditorRunOn; + onRunOnChange: (runOn: EditorRunOn) => void; +} + +export function PipelineInputTrigger({ + input, + onInputChange, + triggerOptions, + isEditorInput, + runOn, + onRunOnChange, +}: PipelineInputTriggerProps) { + const { t } = useTranslation(); + + if (isEditorInput) { + const label = t("portal.pipelines.builder.runOn", "Runs on"); + return ( + + + {label} + + + + } + > + + onInputChange({ + triggerType: value && value !== MANUAL_OPTION ? value : MANUAL, + }) + } + options={triggerOptions} + /> + + + {input.triggerType === "schedule" && ( +

+ + {t("portal.pipelines.composer.scheduleEvery")} + + onInputChange({ scheduleCount: e.target.value })} + className="portal-builder__schedule-count" + /> + - updateInput({ - triggerType: - value && value !== MANUAL_OPTION ? value : MANUAL, - }) - } - options={triggerOptionsFor(input.sourceId)} - /> - - - {input.triggerType === "schedule" && ( -
- - {t("portal.pipelines.composer.scheduleEvery")} - - - updateInput({ scheduleCount: e.target.value }) - } - className="portal-builder__schedule-count" - /> - itself. + const sizeInput = page.getByTestId("pdf-editor-font-size"); + await expect(sizeInput).toBeEnabled(); + await sizeInput.fill("24"); + await sizeInput.press("Enter"); + + // The command scales via a matrix ratio so allow a small tolerance. + await expect + .poll(async () => await readFontSize(runId), { timeout: 5_000 }) + .not.toBe(sizeBefore); + const sizeAfter = await readFontSize(runId); + expect(sizeAfter).not.toBeNull(); + expect(Math.abs(sizeAfter! - 24)).toBeLessThan(0.5); + }); +}); + +test.describe("PDF text editor - save", () => { + test("Save PDF produces a downloadable file", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRunTestId = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first() + .getAttribute("data-testid"); + await typeIntoRun(page, firstRunTestId!, "A"); + + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toMatch(/\.pdf$/i); + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const buf = Buffer.concat(chunks); + expect(buf.length).toBeGreaterThan(100); + expect(buf.subarray(0, 4).toString("ascii")).toBe("%PDF"); + }); + + test("saved PDF round-trips: re-opening it preserves the edit", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + // Capture the original first-run text, then edit it. + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + // Trailing newline only: WebKit adds one, and this spec asserts on spaces. + const original = ((await firstRun.innerText()) ?? "").replace(/\n+$/, ""); + const appended = " (Hello!)"; + const edited = `${original}${appended}`; + + await typeIntoRun(page, runTestId, appended); + await expect(firstRun).toContainText(appended); + + // Trigger the save and capture the bytes. + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + + // Push the saved bytes back into the dropzone as a new file. setInputFiles + // accepts an in-memory payload. + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round-trip.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + + // The edited text must be present somewhere in page 0's runs. + const allText = await page + .waitForFunction( + () => { + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p0-"]', + ), + ); + if (runs.length === 0) return null; + const joined = runs.map((el) => el.innerText).join(" "); + return /Hello/.test(joined) ? joined : null; + }, + { timeout: 30_000, polling: 500 }, + ) + .then((h) => h.jsonValue() as Promise); + expect(allText).toContain(original); + expect(allText).toContain("(Hello!)"); + // The boundary between original and appended may collapse to a single or + // double space depending on per-word emit / LineGrouper reconstruction. + expect(allText).not.toContain(`${original.trimEnd()}(Hello!)`); + // Quiet the unused-var lint - `edited` documents the intent above. + void edited; + }); + + test("a saved edit is readable by an independent PDF parser (not just the editor)", async ({ + page, + }) => { + // The other round-trip tests re-feed the saved bytes through the SAME + // PdfiumTextReader+LineGrouper that wrote them. + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + await typeIntoRun(page, runTestId, "ZZMARKER"); + await expect(firstRun).toContainText("ZZMARKER"); + + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + + // Independent structural cross-check: pdf-lib must load the bytes and see + // the single page. + const doc = await PDFDocument.load(savedBytes); + expect(doc.getPageCount()).toBe(1); + }); +}); + +test.describe("PDF text editor - whitespace preservation", () => { + // These guard against a recurring class of regression where typed spaces + // vanish from the saved PDF. + + async function readFirstRunText( + page: import("@playwright/test").Page, + ): Promise { + return await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store?: { + state: { pages: { runs: { text: string }[] }[] }; + }; + } + ).__editor_store!; + return store.state.pages[0]?.runs[0]?.text ?? ""; + }); + } + + async function saveAndReopen( + page: import("@playwright/test").Page, + ): Promise { + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round-trip.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + } + + test("NBSP typed into a single-line run is normalized to a regular space in the model", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + + // Insert a literal NBSP via execCommand (same dispatch path the + // browser's IME / autocorrect uses when it substitutes one). + await typeIntoRun(page, runTestId, "X\u00A0Y"); + + // The visible overlay shows what we typed. + await expect(firstRun).toContainText("X"); + await expect(firstRun).toContainText("Y"); + + // But the model snapshot - the source of truth for save - must contain + // regular space, never NBSP. + const modelText = await readFirstRunText(page); + expect(modelText).not.toContain("\u00A0"); + expect(modelText).toContain("X Y"); + }); + + test("typed single space survives save and re-open", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + // Trailing newline only: WebKit adds one, and this spec asserts on spaces. + const original = ((await firstRun.innerText()) ?? "").replace(/\n+$/, ""); + const appended = " Hello World"; + await typeIntoRun(page, runTestId, appended); + await expect(firstRun).toContainText("Hello World"); + + await saveAndReopen(page); + + // After re-open we re-read everything through PdfiumTextReader + + // LineGrouper. + const reopenedAllText = await page + .waitForFunction( + () => { + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p0-"]', + ), + ); + if (runs.length === 0) return null; + const joined = runs.map((el) => el.innerText).join("\n"); + return /Hello/.test(joined) ? joined : null; + }, + { timeout: 30_000, polling: 500 }, + ) + .then((h) => h.jsonValue() as Promise); + expect(reopenedAllText).toContain("Hello World"); + expect(reopenedAllText).not.toContain("Hello\u00A0World"); + expect(reopenedAllText).not.toMatch(/HelloWorld/); + expect(reopenedAllText).toContain(original); + }); + + test("deleting one char from a positional-jump run keeps inter-word spaces", async ({ + page, + }) => { + // Repro for the recurring "all spaces vanish when I delete a single letter" + // bug on the Stirling marketing PDF. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles( + path.join( + import.meta.dirname, + "../test-fixtures/stirling-marketing.pdf", + ), + ); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + + // Find the run containing the marketing tagline. Look across every + // page since the marketing PDF is multi-page. + const target = await page.evaluate(() => { + const els = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p"]', + ), + ); + for (const el of els) { + const txt = (el.innerText ?? "").trim(); + if ( + /Adobe/i.test(txt) && + /Acrobat/i.test(txt) && + /Alternative/i.test(txt) + ) { + return { testId: el.dataset.testid ?? "", text: txt }; + } + } + return null; + }); + if (!target) { + test.skip(true, "marketing PDF missing the Acrobat Alternative line"); + return; + } + expect(target.text).toMatch(/Adobe\s+Acrobat\s+Alternative/); + + // Trigger the exact failure path: replace the whole text with itself minus + // the last char. typeIntoRun with selectNodeContents + insertText. + const trimmed = target.text.slice(0, -1); + await typeIntoRun(page, target.testId, trimmed); + + // Model assertion: after the edit, the run's text in the editor store must + // STILL contain the inter-word spaces. + const modelText = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store?: { + state: { pages: { runs: { id: string; text: string }[] }[] }; + }; + } + ).__editor_store!; + for (const p of store.state.pages) { + for (const r of p.runs) { + if (`pdf-editor-run-${r.id}` === tid) return r.text; + } + } + return ""; + }, target.testId); + expect(modelText).toMatch(/Adobe\s+Acrobat\s+Alternativ/); + + // Save and re-open. The reopened text on page 0 must still parse back to a + // tagline with spaces between words. + await saveAndReopen(page); + + // The marketing PDF is multi-page; pages render lazily and the tagline run + // we care about may not have mounted yet. + const reopenedAllText = await page + .waitForFunction( + () => { + // Force every page into view so its overlays mount. + const pageEls = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-page-"]', + ), + ); + for (const el of pageEls) el.scrollIntoView({ block: "center" }); + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p"]', + ), + ); + const joined = runs.map((el) => el.innerText).join("\n"); + // Resolve once we can see "Adobe" somewhere on the page - + // signals the tagline overlay has rendered. + return /Adobe/i.test(joined) ? joined : null; + }, + { timeout: 30_000, polling: 500 }, + ) + .then((handle) => handle.jsonValue() as Promise); + // Surface a slice around the tagline on assertion failure so a future + // regression debugger sees the actual reopened text. + const tagIdx = reopenedAllText.indexOf("Free"); + const taglineSnippet = + tagIdx >= 0 + ? reopenedAllText.slice(Math.max(0, tagIdx - 20), tagIdx + 200) + : ""; + // Core check: none of the word pairs should be GLUED (no whitespace + // separator at all between them). + expect( + reopenedAllText, + `Tagline snippet: ${JSON.stringify(taglineSnippet)}`, + ).not.toMatch(/FreeAdobe/); + expect(reopenedAllText).not.toMatch(/AdobeAcrobat/); + // Positive check: the tagline words DO appear separated by some whitespace + // somewhere in the reopened text. + expect(reopenedAllText).toMatch(/Free\s+Adobe/); + expect(reopenedAllText).toMatch(/Adobe\s+Acrobat/); + }); + + test("user-sample.pdf: deleting one char from tagline keeps every inter-word space", async ({ + page, + }) => { + // EXACT user repro. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + + // Find the tagline overlay. + const taglineHandle = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .filter({ hasText: /Adobe.+Acrobat.+Alternative/ }) + .first() + .elementHandle(); + if (!taglineHandle) { + test.skip(true, "Sample.pdf is missing the Acrobat Alternative tagline"); + return; + } + const taglineTestId = + (await taglineHandle.getAttribute("data-testid")) ?? ""; + const original = (await taglineHandle.innerText()) ?? ""; + expect(original).toMatch(/Adobe\s+Acrobat\s+Alternative/); + + // Delete the last character (matches the user clicking the line and + // hitting Backspace once). + await page.evaluate((tid) => { + const el = document.querySelector( + `[data-testid="${tid}"]`, + ); + if (!el) throw new Error("no tagline element"); + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + }, taglineTestId); + + // Model assertion: the run text after the edit must still have all four + // inter-word gaps. + const modelText = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + state: { pages: { runs: { id: string; text: string }[] }[] }; + }; + } + ).__editor_store; + for (const p of store.state.pages) { + for (const r of p.runs) { + if (`pdf-editor-run-${r.id}` === tid) return r.text; + } + } + return ""; + }, taglineTestId); + expect(modelText).toMatch(/The\s+Free\s+Adobe\s+Acrobat\s+Alternativ/); + + // Round-trip through save + re-open and verify the same word boundaries + // survive. + await saveAndReopen(page); + + const reopenedAllText = await page + .waitForFunction( + () => { + const pages = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-page-"]', + ), + ); + for (const el of pages) el.scrollIntoView({ block: "center" }); + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p"]', + ), + ); + const joined = runs.map((el) => el.innerText).join("\n"); + return /Adobe/i.test(joined) ? joined : null; + }, + { timeout: 30_000, polling: 500 }, + ) + .then((h) => h.jsonValue() as Promise); + + // Surface a slice around the tagline on failure so future + // debuggers see the actual reopened text. + const tagIdx = reopenedAllText.indexOf("Adobe"); + const snippet = + tagIdx >= 0 + ? reopenedAllText.slice(Math.max(0, tagIdx - 30), tagIdx + 200) + : ""; + + // The CORE assertion. The previous bug rendered all words glued. + expect( + reopenedAllText, + `Tagline snippet: ${JSON.stringify(snippet)}`, + ).not.toMatch(/FreeAdobe/); + expect(reopenedAllText).not.toMatch(/AdobeAcrobat/); + expect(reopenedAllText).not.toMatch(/AcrobatAlternativ/); + // Positive form: words separated by some whitespace. + expect(reopenedAllText).toMatch(/Free\s+Adobe/); + expect(reopenedAllText).toMatch(/Adobe\s+Acrobat/); + expect(reopenedAllText).toMatch(/Acrobat\s+Alternativ/); + }); + + test("user-sample.pdf: deleting one char from middle of a LineGrouper-merged line doesn't corrupt or duplicate sub-runs", async ({ + page, + }) => { + // Regression guard: a previous attempt to teach partialEdit about + // LineGrouper-synthesised whitespace miscounted ghost chars by 1. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + // Snapshot the baseline for the bullet that's known to trigger the + // ghost-char-count bug. + const baseline = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r + ? { id: r.id, text: r.text, mergedFromTexts: [...r.mergedFromTexts] } + : null; + }); + if (!baseline) { + test.skip(true, "fixture missing Adobe/Acrobat/Alternative tagline"); + return; + } + + // Pick a deterministic middle-position character to delete: the letter "A" + // of "Adobe". + const deleteIdx = baseline.text.indexOf("Adobe"); + expect(deleteIdx).toBeGreaterThan(0); + const expectedText = + baseline.text.slice(0, deleteIdx) + baseline.text.slice(deleteIdx + 1); + + // Position caret AFTER "M" and Backspace (so the M gets deleted). + await page.evaluate( + ({ tid, caretAt }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) throw new Error("no run el"); + el.focus(); + // Walk the text nodes and place caret after the N-th char. + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let node: Text | null = null; + let remaining = caretAt; + while (walker.nextNode()) { + const n = walker.currentNode as Text; + const len = n.textContent?.length ?? 0; + if (remaining <= len) { + node = n; + break; + } + remaining -= len; + } + if (!node) throw new Error("ran out of text walking caret"); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.setStart(node, remaining); + range.setEnd(node, remaining); + sel.removeAllRanges(); + sel.addRange(range); + // delete = deleteContentBackward semantically + document.execCommand("delete", false); + }, + { tid: baseline.id, caretAt: deleteIdx + 1 }, + ); + await page.waitForTimeout(400); + + const after = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { text: r.text, mergedFromTexts: [...r.mergedFromTexts] } + : null; + }, baseline.id); + if (!after) throw new Error("post-edit run vanished"); + + // Text content: exactly baseline minus the A of Adobe. + expect(after.text).toBe(expectedText); + + // Sub-run integrity: any non-trivial (>=3 char) baseline fragment must NOT + // appear twice in the post-edit mergedFromTexts. + const counts = new Map(); + for (const t of after.mergedFromTexts) { + if (t.length < 3) continue; + counts.set(t, (counts.get(t) ?? 0) + 1); + } + const dupes = Array.from(counts.entries()).filter(([, c]) => c > 1); + expect( + dupes, + `mergedFromTexts duplicates after edit: ${JSON.stringify(dupes)}`, + ).toEqual([]); + + // Char-fidelity: every non-trivial baseline fragment that wasn't the + // deleted sub-run must still appear verbatim somewhere in. + const afterJoined = after.mergedFromTexts.join("|"); + for (const t of baseline.mergedFromTexts) { + if (t.length < 3) continue; + // The sub-run that contained the deleted A may be removed or + // re-emitted - don't assert on those specifically. + if (t === "A" || t.includes("Adobe")) continue; + expect( + afterJoined, + `baseline fragment ${JSON.stringify(t)} lost from post-edit run`, + ).toContain(t); + } + + // Font preservation: editing a line rendered in a non-base14 source font + // must NOT flip the run to base14:Helvetica. + const afterFontId = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; fontId: string }>; + }; + }; + }; + } + ).__editor_store; + return ( + store.doc.page(0).runs.find((r) => r.id === tid)?.fontId ?? "" + ); + }, baseline.id); + expect(afterFontId).not.toMatch(/^base14:/); + }); + + test("user-sample.pdf: inserting ' Hi' at end of tagline renders a visible space (not 'AlternativeHi')", async ({ + page, + }) => { + // Repro for the recurring "typed space vanishes" bug on the marketing + // tagline. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const tagline = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; text: string }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r ? { id: r.id, text: r.text } : null; + }); + if (!tagline) { + test.skip( + true, + "user-sample.pdf missing Adobe/Acrobat/Alternative tagline", + ); + return; + } + + // Place caret at end-of-text and type " Hi" via insertText (same + // dispatch path the browser uses for real keystrokes). + await page.evaluate( + ({ tid, caretPos }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) throw new Error("no tagline element"); + el.focus(); + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let node: Text | null = null; + let remaining = caretPos; + while (walker.nextNode()) { + const n = walker.currentNode as Text; + const len = n.textContent?.length ?? 0; + if (remaining <= len) { + node = n; + break; + } + remaining -= len; + } + if (!node) throw new Error("ran out of text walking caret"); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.setStart(node, remaining); + range.setEnd(node, remaining); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, " Hi"); + }, + { tid: tagline.id, caretPos: tagline.text.length }, + ); + await page.waitForTimeout(400); + + // Model assertion: the run text now ends in " Hi" with the literal + // space preserved. + const after = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + bounds: { x: number; width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { + text: r.text, + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + boundsRight: r.bounds.x + r.bounds.width, + } + : null; + }, tagline.id); + if (!after) throw new Error("tagline run vanished after insert"); + expect(after.text).toMatch(/Alternative\s+Hi$/); + + // The CORE physical-width assertion. + const insertedRight = Math.max( + ...after.mergedFromBounds.map((b) => b.right), + ); + const insertedLeft = Math.min(...after.mergedFromBounds.map((b) => b.x)); + // The bounds span must be at least the width the line had before + // the insert (we APPENDED chars; nothing should subtract width). + expect(insertedRight - insertedLeft).toBeGreaterThan(0); + // run.bounds.width covers up to and including the new chars. + expect(after.boundsRight).toBeGreaterThan(insertedLeft + 5); + + // Round-trip: save the PDF and re-open. + await saveAndReopen(page); + + const reopened = await page + .waitForFunction( + () => { + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p"]', + ), + ); + const joined = runs.map((el) => el.innerText).join("\n"); + return /Alternativ/i.test(joined) ? joined : null; + }, + { timeout: 30_000, polling: 500 }, + ) + .then((h) => h.jsonValue() as Promise); + + // Surface a snippet on failure so future debuggers see actual + // reopened text instead of an opaque regex mismatch. + const aIdx = reopened.indexOf("Alternat"); + const snippet = + aIdx >= 0 + ? reopened.slice(Math.max(0, aIdx - 5), aIdx + 40) + : ""; + + // The CORE assertion. Glued tokens = whitespace eaten on save. + expect( + reopened, + `Tagline+Hi snippet: ${JSON.stringify(snippet)}`, + ).not.toMatch(/AlternativeHi/); + // Positive form: the two tokens appear with some whitespace separator. + expect(reopened).toMatch(/Alternative\s+Hi/); + }); + + // Sequential-edit visual-integrity tests. + + async function findTaglineRun(page: import("@playwright/test").Page) { + return await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + bounds: { + x: number; + y: number; + width: number; + height: number; + }; + mergedFromTexts: string[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r + ? { + id: r.id, + text: r.text, + fontId: r.fontId, + bounds: { ...r.bounds }, + } + : null; + }); + } + + async function readRun(page: import("@playwright/test").Page, id: string) { + return await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + bounds: { x: number; y: number }; + mergedFromTexts: string[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { + text: r.text, + fontId: r.fontId, + boundsX: r.bounds.x, + boundsY: r.bounds.y, + mergedFromTexts: [...r.mergedFromTexts], + } + : null; + }, id); + } + + async function caretAt( + page: import("@playwright/test").Page, + tid: string, + pos: number, + ) { + await page.evaluate( + ({ tid, pos }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) throw new Error("no run el"); + el.focus(); + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let node: Text | null = null; + let remaining = pos; + while (walker.nextNode()) { + const n = walker.currentNode as Text; + const len = n.textContent?.length ?? 0; + if (remaining <= len) { + node = n; + break; + } + remaining -= len; + } + if (!node) throw new Error("ran out of text walking caret"); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.setStart(node, remaining); + range.setEnd(node, remaining); + sel.removeAllRanges(); + sel.addRange(range); + }, + { tid, pos }, + ); + } + + async function execAt( + page: import("@playwright/test").Page, + tid: string, + pos: number, + cmd: "insertText" | "delete", + text?: string, + ) { + await caretAt(page, tid, pos); + await page.evaluate( + ({ cmd, text }) => { + document.execCommand(cmd, false, text); + }, + { cmd, text }, + ); + await page.waitForTimeout(250); + } + + function dedupeCheck(mergedFromTexts: string[]): string[] { + const counts = new Map(); + for (const t of mergedFromTexts) { + if (t.length < 3) continue; + counts.set(t, (counts.get(t) ?? 0) + 1); + } + return Array.from(counts.entries()) + .filter(([, c]) => c > 1) + .map(([t]) => t); + } + + test("SENTINEL: USER_SAMPLE_PDF tagline is a single grouped run", async ({ + page, + }) => { + // 20 tagline tests below guard themselves with `if (!findTaglineRun(page)) + // { test.skip(...) }`. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + expect( + baseline, + "USER_SAMPLE_PDF must expose the Acrobat-Alternative tagline as a single run - if this fails the 20 tagline tests are silently skipping", + ).not.toBeNull(); + expect(baseline!.text).toMatch(/Adobe.*Acrobat.*Alternative/); + }); + + test("user-sample.pdf: sequential type-3-chars-at-end keeps font + position stable", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "fixture missing tagline"); + return; + } + expect(baseline.fontId).not.toMatch(/^base14:/); + + // Type three chars at the end of the line. + let runningText = baseline.text; + for (const ch of ["A", "d", "o"]) { + runningText += ch; + await execAt(page, baseline.id, runningText.length - 1, "insertText", ch); + const after = await readRun(page, baseline.id); + if (!after) throw new Error("run vanished after type"); + expect(after.text, `after typing ${ch}`).toBe(runningText); + expect(after.fontId, `font flipped after typing ${ch}`).not.toMatch( + /^base14:/, + ); + expect( + Math.abs(after.boundsY - baseline.bounds.y), + `vertical teleport after typing ${ch} (Δy=${after.boundsY - baseline.bounds.y})`, + ).toBeLessThan(2); + const dupes = dedupeCheck(after.mergedFromTexts); + expect( + dupes, + `dupes after typing ${ch}: ${JSON.stringify(dupes)}`, + ).toEqual([]); + } + }); + + test("user-sample.pdf: sequential backspace-3-chars-from-end keeps font + position stable", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "fixture missing tagline"); + return; + } + + let runningText = baseline.text; + for (let i = 0; i < 3; i++) { + runningText = runningText.slice(0, -1); + await execAt(page, baseline.id, runningText.length + 1, "delete"); + const after = await readRun(page, baseline.id); + if (!after) throw new Error("run vanished after backspace"); + expect(after.text, `after backspace #${i + 1}`).toBe(runningText); + expect( + after.fontId, + `font flipped after backspace #${i + 1}`, + ).not.toMatch(/^base14:/); + expect( + Math.abs(after.boundsY - baseline.bounds.y), + `vertical teleport after backspace #${i + 1}`, + ).toBeLessThan(2); + const dupes = dedupeCheck(after.mergedFromTexts); + expect( + dupes, + `dupes after backspace #${i + 1}: ${JSON.stringify(dupes)}`, + ).toEqual([]); + } + }); + + test("user-sample.pdf: sequential delete-3-chars-from-middle keeps font + position stable", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "fixture missing tagline"); + return; + } + // Find the index of "Acrobat" - we'll backspace the leading + // letters off it ('A', 'c', 'r') one at a time. + const acrobatStart = baseline.text.indexOf("Acrobat"); + expect(acrobatStart).toBeGreaterThan(0); + + let runningText = baseline.text; + for (let i = 0; i < 3; i++) { + // Each iteration we delete the char at position `acrobatStart` - which is + // the next char of what used to be "Acrobat" after the previous deletes. + runningText = + runningText.slice(0, acrobatStart) + + runningText.slice(acrobatStart + 1); + await execAt(page, baseline.id, acrobatStart + 1, "delete"); + const after = await readRun(page, baseline.id); + if (!after) throw new Error("run vanished after middle delete"); + expect(after.text, `after middle delete #${i + 1}`).toBe(runningText); + expect( + after.fontId, + `font flipped after middle delete #${i + 1}`, + ).not.toMatch(/^base14:/); + expect( + Math.abs(after.boundsY - baseline.bounds.y), + `vertical teleport after middle delete #${i + 1}`, + ).toBeLessThan(2); + const dupes = dedupeCheck(after.mergedFromTexts); + expect( + dupes, + `dupes after middle delete #${i + 1}: ${JSON.stringify(dupes)}`, + ).toEqual([]); + } + }); + + test("user-sample.pdf: interleaved delete-then-type sequence keeps font + position stable", async ({ + page, + }) => { + // Mimics realistic user editing: delete a char, type a different one, + // repeat. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "fixture missing tagline"); + return; + } + + const sequence: Array<{ + op: "insertText" | "delete"; + pos: number; + ch?: string; + }> = [ + // Delete the trailing "e" of Alternative. + { op: "delete", pos: baseline.text.length }, + // Append a known-existing 'A'. + { op: "insertText", pos: baseline.text.length - 1, ch: "A" }, + { op: "delete", pos: baseline.text.length }, + // Type 'e' back at the end. + { op: "insertText", pos: baseline.text.length - 1, ch: "e" }, + ]; + + let runningText = baseline.text; + for (let i = 0; i < sequence.length; i++) { + const step = sequence[i]; + if (step.op === "insertText") { + runningText = + runningText.slice(0, step.pos) + + (step.ch ?? "") + + runningText.slice(step.pos); + await execAt(page, baseline.id, step.pos, "insertText", step.ch); + } else { + runningText = + runningText.slice(0, step.pos - 1) + runningText.slice(step.pos); + await execAt(page, baseline.id, step.pos, "delete"); + } + const after = await readRun(page, baseline.id); + if (!after) throw new Error(`run vanished after step ${i}`); + expect(after.text, `step ${i} (${step.op})`).toBe(runningText); + expect(after.fontId, `step ${i} font flipped`).not.toMatch(/^base14:/); + expect( + Math.abs(after.boundsY - baseline.bounds.y), + `step ${i} vertical teleport`, + ).toBeLessThan(2); + const dupes = dedupeCheck(after.mergedFromTexts); + expect(dupes, `step ${i} dupes: ${JSON.stringify(dupes)}`).toEqual([]); + } + }); + + test("user-sample.pdf: inserting chars in the MIDDLE shifts subsequent text right (no overlap)", async ({ + page, + }) => { + // Regression guard: inserting NEW chars between two kept sub-runs used to + // leave the inserted text overlapping the original following chars. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + bounds: { width: number }; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r + ? { + id: r.id, + text: r.text, + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + boundsWidth: r.bounds.width, + } + : null; + }); + if (!baseline) { + test.skip(true, "fixture missing tagline"); + return; + } + + // Find caret position right after "Acrob" (between 'b' and 'a'). + const acrobIdx = baseline.text.indexOf("Acrobat"); + expect(acrobIdx).toBeGreaterThan(0); + const caretPos = acrobIdx + 5; // after "Acrob" + + // Find original x of the sub-run that LIVES AFTER the caret - + // this is the one that should shift right after the insert. + let charCursor = 0; + let postCaretSubRunIdx = -1; + for (let i = 0; i < baseline.mergedFromTexts.length; i++) { + const len = baseline.mergedFromTexts[i].length; + if (caretPos >= charCursor && caretPos <= charCursor + len) { + // Caret is at end of this sub-run; the NEXT sub-run is what + // should shift. + postCaretSubRunIdx = i + 1; + break; + } + charCursor += len; + } + expect(postCaretSubRunIdx).toBeGreaterThan(0); + expect(postCaretSubRunIdx).toBeLessThan(baseline.mergedFromBounds.length); + const origPostCaretX = baseline.mergedFromBounds[postCaretSubRunIdx].x; + + await execAt(page, baseline.id, caretPos, "insertText", "aaa"); + + const after = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + bounds: { width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { + text: r.text, + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + boundsWidth: r.bounds.width, + } + : null; + }, baseline.id); + if (!after) throw new Error("post-edit run vanished"); + + // Sanity: text is exactly baseline with "aaa" inserted at caretPos. + const expectedText = + baseline.text.slice(0, caretPos) + "aaa" + baseline.text.slice(caretPos); + expect(after.text).toBe(expectedText); + + // The sub-run that USED to live right after the caret must now have its x + // shifted RIGHT to make room for the inserted "aaa". + let newPostCaretX: number | null = null; + // Original next sub-run's text: + const targetText = baseline.mergedFromTexts[postCaretSubRunIdx]; + // Find its first occurrence AFTER the insertion point in the + // after-array (skipping the "aaa" sub-runs). + let cursor = 0; + for (let i = 0; i < after.mergedFromTexts.length; i++) { + if (cursor >= caretPos + 3 && after.mergedFromTexts[i] === targetText) { + newPostCaretX = after.mergedFromBounds[i].x; + break; + } + cursor += after.mergedFromTexts[i].length; + } + expect( + newPostCaretX, + `could not find post-caret sub-run after insertion`, + ).not.toBeNull(); + expect( + newPostCaretX!, + `post-caret sub-run did not shift right (orig=${origPostCaretX}, new=${newPostCaretX})`, + ).toBeGreaterThan(origPostCaretX + 5); + + // Run width must have grown by at least the inserted "aaa" width. + const widthGrowth = after.boundsWidth - baseline.boundsWidth; + expect( + widthGrowth, + `bounds.width didn't grow (Δ=${widthGrowth}) - insert probably overlapped following text`, + ).toBeGreaterThan(10); + }); + + test("user-sample.pdf: deleting an entire word closes the gap (text after shifts left)", async ({ + page, + }) => { + // Regression guard: when an edit fully removes a sub-run, the surviving + // sub-runs to its right used to STAY at their original x position. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + // Snapshot the baseline including the bounds of every sub-run + // (we need the original x of the sub-run that lives AFTER "Adobe "). + const baseline = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + bounds: { x: number; width: number }; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r + ? { + id: r.id, + text: r.text, + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + boundsWidth: r.bounds.width, + } + : null; + }); + if (!baseline) { + test.skip(true, "fixture missing tagline"); + return; + } + + // Find the first sub-run whose text begins after the "Adobe " + // word. We'll track its bounds.x before and after the delete. + const adobeChars = "Adobe"; + const acrobatChars = "Acrobat"; + const adobeStartCharIdx = baseline.text.indexOf(adobeChars); + const acrobatStartCharIdx = baseline.text.indexOf(acrobatChars); + expect(adobeStartCharIdx).toBeGreaterThan(0); + expect(acrobatStartCharIdx).toBeGreaterThan(adobeStartCharIdx); + + // The sub-run containing the FIRST char of "Acrobat" - its + // original x is what we compare to. + let charCursor = 0; + let acrobatSubRunIdx = -1; + for (let i = 0; i < baseline.mergedFromTexts.length; i++) { + const sub = baseline.mergedFromTexts[i]; + if ( + acrobatStartCharIdx >= charCursor && + acrobatStartCharIdx < charCursor + sub.length + ) { + acrobatSubRunIdx = i; + break; + } + charCursor += sub.length; + } + expect(acrobatSubRunIdx).toBeGreaterThan(0); + const origAcrobatX = baseline.mergedFromBounds[acrobatSubRunIdx].x; + + // Select "Adobe " (the word + trailing whitespace) and delete it. + await page.evaluate( + ({ tid, start, end }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) throw new Error("no run el"); + el.focus(); + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let startNode: Text | null = null; + let startOffset = 0; + let endNode: Text | null = null; + let endOffset = 0; + let remaining = start; + while (walker.nextNode()) { + const n = walker.currentNode as Text; + const len = n.textContent?.length ?? 0; + if (!startNode && remaining <= len) { + startNode = n; + startOffset = remaining; + } + if (!startNode) remaining -= len; + else break; + } + // Reset walker; re-walk for end. + const walker2 = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let r2 = end; + while (walker2.nextNode()) { + const n = walker2.currentNode as Text; + const len = n.textContent?.length ?? 0; + if (r2 <= len) { + endNode = n; + endOffset = r2; + break; + } + r2 -= len; + } + if (!startNode || !endNode) throw new Error("selection walk failed"); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + }, + { + tid: baseline.id, + // Delete the whole word "Adobe" + the trailing space chars + // (sample has TWO spaces between words). + start: adobeStartCharIdx, + end: acrobatStartCharIdx, + }, + ); + await page.waitForTimeout(400); + + const after = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + bounds: { width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { + text: r.text, + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + boundsWidth: r.bounds.width, + } + : null; + }, baseline.id); + if (!after) throw new Error("post-edit run vanished"); + + // Text content: baseline minus "Adobe " (including the trailing + // double space). + const expectedText = + baseline.text.slice(0, adobeStartCharIdx) + + baseline.text.slice(acrobatStartCharIdx); + expect(after.text).toBe(expectedText); + + // Find the sub-run that now contains "Acrobat" - it MUST have + // shifted LEFT of its original position to close the gap. + let newAcrobatX: number | null = null; + let cursor = 0; + for (let i = 0; i < after.mergedFromTexts.length; i++) { + const sub = after.mergedFromTexts[i]; + const idx = (after.text.slice(cursor) + "").indexOf("Acrobat"); + if ( + idx >= 0 && + cursor + idx >= cursor && + cursor + idx < cursor + sub.length + ) { + newAcrobatX = after.mergedFromBounds[i].x; + break; + } + cursor += sub.length; + } + // Fallback: scan all sub-runs for one whose text starts with 'A' + // and is near the expected position. + if (newAcrobatX === null) { + for (let i = 0; i < after.mergedFromTexts.length; i++) { + if (after.mergedFromTexts[i].startsWith("A")) { + newAcrobatX = after.mergedFromBounds[i].x; + break; + } + } + } + if (newAcrobatX === null) { + // Pick the sub-run at the same INDEX as the original Acrobat sub-run. + const newIdx = Math.min( + acrobatSubRunIdx, + after.mergedFromBounds.length - 1, + ); + newAcrobatX = after.mergedFromBounds[newIdx].x; + } + expect( + newAcrobatX, + `Acrobat sub-run did not shift left (original=${origAcrobatX}, new=${newAcrobatX})`, + ).toBeLessThan(origAcrobatX); + + // Run width must have shrunk by roughly the width of "Adobe " (give or take + // a few pt for the per-word emit's positional padding). + const widthShrinkage = baseline.boundsWidth - after.boundsWidth; + expect( + widthShrinkage, + `bounds.width barely shrank (Δ=${widthShrinkage}) - gap probably left in place`, + ).toBeGreaterThan(15); + }); + + // Comprehensive edit-text regression. + + /** Walk adjacent merged-from-bounds and assert no horizontal overlap. */ + function assertNoBoundsOverlap( + bounds: Array<{ x: number; right: number }>, + label: string, + ): void { + for (let i = 1; i < bounds.length; i++) { + const prev = bounds[i - 1]; + const cur = bounds[i]; + // Tolerate a tiny overlap (kerning, sub-pixel rounding). + const overlap = prev.right - cur.x; + if (overlap > 1.5) { + throw new Error( + `${label}: sub-run ${i - 1} (right=${prev.right.toFixed(2)}) overlaps sub-run ${i} (x=${cur.x.toFixed(2)}) by ${overlap.toFixed(2)}pt`, + ); + } + } + } + + async function snapshotIntegrity( + page: import("@playwright/test").Page, + runId: string, + baselineY: number, + expectedText: string, + stepLabel: string, + ): Promise { + const after = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + bounds: { y: number }; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { + text: r.text, + fontId: r.fontId, + boundsY: r.bounds.y, + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + } + : null; + }, runId); + if (!after) throw new Error(`${stepLabel}: run vanished`); + expect(after.text, `${stepLabel}: text`).toBe(expectedText); + expect( + after.fontId, + `${stepLabel}: font flipped. text=${JSON.stringify(after.text.slice(0, 80))}; merged[0..12]=${JSON.stringify(after.mergedFromTexts.slice(0, 12))}`, + ).not.toMatch(/^base14:/); + expect( + Math.abs(after.boundsY - baselineY), + `${stepLabel}: vertical teleport (Δy=${after.boundsY - baselineY})`, + ).toBeLessThan(2); + const counts = new Map(); + for (const t of after.mergedFromTexts) { + if (t.length < 3) continue; + counts.set(t, (counts.get(t) ?? 0) + 1); + } + const dupes = Array.from(counts.entries()) + .filter(([, c]) => c > 1) + .map(([t]) => t); + expect(dupes, `${stepLabel}: dupe sub-runs`).toEqual([]); + assertNoBoundsOverlap(after.mergedFromBounds, stepLabel); + } + + test("comprehensive regression: insert at end + step-by-step integrity check", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Type a varied sequence: letters, digit, space, letter. + const chars = ["X", "9", " ", "z", "Q"]; + let running = baseline.text; + for (const ch of chars) { + running += ch; + await execAt(page, baseline.id, running.length - 1, "insertText", ch); + await snapshotIntegrity( + page, + baseline.id, + baseline.bounds.y, + running, + `insert "${ch}" at end`, + ); + } + }); + + test("comprehensive regression: insert at start + step-by-step integrity check", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + const chars = ["!", "?", "*"]; + let running = baseline.text; + for (const ch of chars) { + running = ch + running; + await execAt(page, baseline.id, 0, "insertText", ch); + await snapshotIntegrity( + page, + baseline.id, + baseline.bounds.y, + running, + `insert "${ch}" at start`, + ); + } + }); + + test("comprehensive regression: insert in middle + step-by-step integrity check", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + const insertAt = baseline.text.indexOf("Acrobat") + 5; // between "Acrob" and "at" + expect(insertAt).toBeGreaterThan(0); + + const chars = ["a", "a", "a"]; // user's reported case + let running = baseline.text; + let offset = 0; + for (const ch of chars) { + running = + running.slice(0, insertAt + offset) + + ch + + running.slice(insertAt + offset); + await execAt(page, baseline.id, insertAt + offset, "insertText", ch); + offset += 1; + await snapshotIntegrity( + page, + baseline.id, + baseline.bounds.y, + running, + `insert "${ch}" in middle (step ${offset})`, + ); + } + }); + + test("comprehensive regression: delete from end down to zero", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Backspace 10 chars from end. + let running = baseline.text; + const totalDeletes = Math.min(10, running.length - 1); + for (let i = 0; i < totalDeletes; i++) { + running = running.slice(0, -1); + await execAt(page, baseline.id, running.length + 1, "delete"); + await snapshotIntegrity( + page, + baseline.id, + baseline.bounds.y, + running, + `backspace #${i + 1}`, + ); + } + }); + + test("comprehensive regression: delete from start", async ({ page }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + let running = baseline.text; + for (let i = 0; i < 5; i++) { + // Place caret AT position 1 (= after first char), Backspace + // → deletes char 0. + running = running.slice(1); + await execAt(page, baseline.id, 1, "delete"); + await snapshotIntegrity( + page, + baseline.id, + baseline.bounds.y, + running, + `delete-from-start #${i + 1}`, + ); + } + }); + + test("comprehensive regression: delete from middle of various words", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Delete the letter at position N for each word: "Free" → "Fre", "Adobe" → + // "dobe", "Alternative" → "Altrntiv". + const targets: Array<{ + word: string; + offsetInWord: number; + label: string; + }> = [ + { word: "Free", offsetInWord: 4, label: "delete 'e' after Free" }, + { word: "Adobe", offsetInWord: 1, label: "delete 'A' at start of Adobe" }, + { word: "Acrobat", offsetInWord: 3, label: "delete 'r' in Acrobat" }, + ]; + + let running = baseline.text; + for (const t of targets) { + const wordPos = running.indexOf(t.word); + if (wordPos < 0) continue; + const caretPos = wordPos + t.offsetInWord; + const charDeleted = running.charAt(caretPos - 1); + running = running.slice(0, caretPos - 1) + running.slice(caretPos); + await execAt(page, baseline.id, caretPos, "delete"); + await snapshotIntegrity( + page, + baseline.id, + baseline.bounds.y, + running, + `${t.label} (removed '${charDeleted}')`, + ); + } + }); + + test("comprehensive regression: alternating insert/delete sequence", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + let running = baseline.text; + // 6-step interleaved sequence. + const steps: Array<{ op: "ins" | "del"; pos: () => number; ch?: string }> = + [ + { op: "ins", pos: () => running.length, ch: "Z" }, + { op: "del", pos: () => running.length }, + { op: "ins", pos: () => running.indexOf("Free") + 4, ch: "r" }, + { op: "del", pos: () => running.indexOf("Free") + 5 }, + { op: "ins", pos: () => 0, ch: "*" }, + { op: "del", pos: () => 1 }, + ]; + + for (let i = 0; i < steps.length; i++) { + const s = steps[i]; + const pos = s.pos(); + if (s.op === "ins" && s.ch !== undefined) { + running = running.slice(0, pos) + s.ch + running.slice(pos); + await execAt(page, baseline.id, pos, "insertText", s.ch); + } else { + if (pos < 1) continue; + running = running.slice(0, pos - 1) + running.slice(pos); + await execAt(page, baseline.id, pos, "delete"); + } + // Lighter assertion: text + no teleport + no dupe sub-runs. + // Font flip is tolerated here (see note above). + const after = await readRun(page, baseline.id); + if (!after) throw new Error(`step ${i + 1}: run vanished`); + expect(after.text, `step ${i + 1} (${s.op}) text`).toBe(running); + expect( + Math.abs(after.boundsY - baseline.bounds.y), + `step ${i + 1} vertical teleport (Δy=${after.boundsY - baseline.bounds.y})`, + ).toBeLessThan(2); + const dupes = dedupeCheck(after.mergedFromTexts); + expect(dupes, `step ${i + 1} dupes`).toEqual([]); + } + }); + + test("comprehensive regression: save+reopen text-content round-trip", async ({ + page, + }) => { + // The "would a PDF viewer render the right text" assertion. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Edit: insert "aaa" between "Acrob" and "at". + const insertAt = baseline.text.indexOf("Acrobat") + 5; + await execAt(page, baseline.id, insertAt, "insertText", "aaa"); + + // Save and re-open, then collect every run's text from page 0. + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const buf = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round-trip.pdf", + mimeType: "application/pdf", + buffer: buf, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(1500); + + // Scan EVERY run on EVERY page. + const allText = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + state: { pages: { runs: { text: string }[] }[] }; + }; + } + ).__editor_store; + return store.state.pages + .flatMap((p) => p.runs.map((r) => r.text)) + .join("\n"); + }); + const debugSnippet = allText.slice(0, 500); + // The inserted "aaa" must appear near "Acrob". + expect( + allText, + `reopened did not contain "Acrob...aaa". snippet: ${debugSnippet}`, + ).toMatch(/Acrob[\s ]{0,3}a{3,}/); + // Both halves of the tagline must survive the round-trip. + expect( + allText.indexOf("Adobe"), + `Adobe missing. allText: ${debugSnippet}`, + ).toBeGreaterThanOrEqual(0); + expect(allText.indexOf("Acrob")).toBeGreaterThanOrEqual(0); + expect(allText.indexOf("Alternativ")).toBeGreaterThanOrEqual(0); + }); + + // Font-fallback regression tests. + + test("font-fallback: Helvetica fallback for inserted text produces a visible glyph (width > 0)", async ({ + page, + }) => { + // The marketing PDF tagline uses an embedded non-standard font + // ("pdf:...:Unknown"). + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Insert "X" at end of tagline. + await execAt(page, baseline.id, baseline.text.length, "insertText", "X"); + + // Read the new sub-run's bounds and confirm it has a real width. + // A 0-width sub-run = font failed to render the glyph = bug. + const result = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + if (!r) return null; + // Find the LAST sub-run whose text contains "X" - the inserted one. + for (let i = r.mergedFromTexts.length - 1; i >= 0; i--) { + if (r.mergedFromTexts[i].includes("X")) { + const b = r.mergedFromBounds[i]; + return { width: b.right - b.x, text: r.mergedFromTexts[i] }; + } + } + return null; + }, baseline.id); + if (!result) throw new Error("inserted 'X' sub-run not found"); + expect( + result.width, + `Inserted "${result.text}" sub-run has 0 width - source font failed to re-encode 'X' as a visible glyph. Should have fallen back to Helvetica.`, + ).toBeGreaterThan(2); + }); + + test("font-borrow: typing same-char-as-original uses the SOURCE font (width matches original)", async ({ + page, + }) => { + // The "try borrow, detect, fall back" path: when every inserted char + // already appears in the source line. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Snapshot the original 'a' width inside "Acrobat" BEFORE editing. + const origAWidth = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + if (!r) return null; + // Find the FIRST sub-run whose text is exactly 'a'. + for (let i = 0; i < r.mergedFromTexts.length; i++) { + if (r.mergedFromTexts[i] === "a") { + const b = r.mergedFromBounds[i]; + return b.right - b.x; + } + } + return null; + }, baseline.id); + if (origAWidth === null || origAWidth < 1) { + test.skip(true, "no single-char 'a' sub-run found in tagline"); + return; + } + + // Insert 'a' right after the 'a' of "Acrobat". + const caretPos = baseline.text.indexOf("Acrobat") + 6; + await execAt(page, baseline.id, caretPos, "insertText", "a"); + + // Read the INSERTED 'a' sub-run's width. + const insertedAWidth = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + if (!r) return null; + // The inserted 'a' is the last sub-run with text exactly 'a'. + for (let i = r.mergedFromTexts.length - 1; i >= 0; i--) { + if (r.mergedFromTexts[i] === "a") { + const b = r.mergedFromBounds[i]; + return b.right - b.x; + } + } + return null; + }, baseline.id); + if (insertedAWidth === null) throw new Error("inserted 'a' not found"); + + // The inserted 'a' must be approximately the same width as the original + // 'a'. + const ratio = insertedAWidth / origAWidth; + expect( + ratio, + `inserted 'a' width ${insertedAWidth.toFixed(2)}pt vs original ${origAWidth.toFixed(2)}pt (ratio ${ratio.toFixed(2)}). Helvetica fallback gives ratio ~0.6; source-font borrow gives ~1.0.`, + ).toBeGreaterThan(0.85); + expect(ratio).toBeLessThan(1.2); + }); + + test("font-fallback: typing same-char-as-original keeps text content correct (no garbage glyph)", async ({ + page, + }) => { + // Even when the inserted char IS already present in the source text, the + // result must remain text-content-correct: run.text equals baseline + 'd'. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Find caret right after the 'd' of "Adobe". + const adobeIdx = baseline.text.indexOf("Adobe"); + expect(adobeIdx).toBeGreaterThan(0); + const caretPos = adobeIdx + 2; // after 'A','d' + await execAt(page, baseline.id, caretPos, "insertText", "d"); + + const result = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + if (!r) return null; + // Find the inserted 'd' sub-run - it's the one whose text is exactly "d" + // added between "Ad" and "obe" of the original. + const dSubRuns = r.mergedFromTexts + .map((t, i) => ({ text: t, bounds: r.mergedFromBounds[i] })) + .filter((s) => s.text.includes("d")); + const widths = dSubRuns.map((s) => s.bounds.right - s.bounds.x); + return { text: r.text, dWidths: widths }; + }, baseline.id); + if (!result) throw new Error("run vanished"); + // Text content correct: 'Addobe' appears in the run. + expect(result.text).toMatch(/Ad+obe/); + // At least ONE 'd' sub-run must have a real (non-zero) width - + // the inserted 'd' rendered with a visible glyph. + const hasRenderableD = result.dWidths.some((w) => w > 2); + expect( + hasRenderableD, + `No 'd' sub-run has visible width (>2pt). Widths: ${JSON.stringify(result.dWidths)} - font borrow would render tofu`, + ).toBe(true); + }); + + test("font-fallback: subset-font run falls back to Helvetica on edit (no garbage)", async ({ + page, + }) => { + // Subset fonts only embed the glyphs the source PDF originally used. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(SUBSET_FONT_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const subsetRun = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + fontSubset: boolean; + }>; + }; + }; + }; + } + ).__editor_store; + for (const p of [0]) { + for (const r of store.doc.page(p).runs) { + if (r.fontSubset && r.text.length >= 3) { + return { id: r.id, text: r.text }; + } + } + } + return null; + }); + // The fixture guarantees a subset run; a miss means subset detection + // regressed, so fail loudly rather than skip. + if (!subsetRun) { + throw new Error( + "subset-font-sample.pdf must contain a subset-font run (subset detection regressed)", + ); + } + + // Type a char unlikely to be in the subset (a 9 - typical body + // text rarely subsets digits unless they appear in the source). + await execAt(page, subsetRun.id, subsetRun.text.length, "insertText", "9"); + await page.waitForTimeout(300); + + const after = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { + text: r.text, + zeroWidthCount: r.mergedFromBounds.filter( + (b) => b.right - b.x < 0.1, + ).length, + } + : null; + }, subsetRun.id); + if (!after) throw new Error("run vanished after subset edit"); + expect(after.text).toBe(subsetRun.text + "9"); + expect( + after.zeroWidthCount, + "subset-font edit emitted 0-width sub-runs - glyph rendering broken", + ).toBeLessThan(2); + }); + + test("font-fallback: overlay path's canReuseFont gate documented", async ({ + page, + }) => { + // Belt-and-suspenders test: confirms the EditTextCommand overlay path + // reuses the source font ONLY when every new char exists in the. + await gotoEditor(page); + await loadSamplePdf(page); + await page.waitForTimeout(500); + + const singleObjRun = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + mergedFromPtrs: number[]; + }>; + }; + }; + }; + } + ).__editor_store; + for (const r of store.doc.page(0).runs) { + if ( + r.mergedFromPtrs.length === 0 && + !/^base14:/.test(r.fontId) && + r.text.length >= 3 && + !r.text.includes("X") + ) { + return { id: r.id, text: r.text, fontId: r.fontId }; + } + } + return null; + }); + if (!singleObjRun) { + test.skip(true, "no single-object non-base14 run without 'X' available"); + return; + } + + // Insert 'X' (not in original text) → safeChars=false → font + // must flip to base14 Helvetica per the canReuseFont gate. + await execAt( + page, + singleObjRun.id, + singleObjRun.text.length, + "insertText", + "X", + ); + const after = await readRun(page, singleObjRun.id); + if (!after) throw new Error("run vanished"); + expect( + after.fontId, + `expected base14 fallback for unsafe-char insert; got ${after.fontId}`, + ).toMatch(/^base14:/); + }); + + test("multiple consecutive spaces survive save and re-open", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + + // Three consecutive spaces between A and B. + await typeIntoRun(page, runTestId, " A B"); + await expect(firstRun).toContainText("A B"); + + await saveAndReopen(page); + + // The per-word emit writes "A" + gap + "B" as separate PDFium text objects. + const allText = await page + .waitForFunction( + () => { + const store = ( + window as unknown as { + __editor_store?: { + state: { pages: { runs: { text: string }[] }[] }; + }; + } + ).__editor_store; + if (!store) return null; + const runs = store.state.pages[0]?.runs ?? []; + if (runs.length === 0) return null; + const joined = runs.map((r) => r.text).join("\n"); + return joined.includes("A") && joined.includes("B") ? joined : null; + }, + { timeout: 30_000, polling: 300 }, + ) + .then((h) => h.jsonValue() as Promise); + // Both letters came back - no text object was lost in the round trip. + expect(allText).toContain("A"); + expect(allText).toContain("B"); + // Multiple consecutive spaces must survive in at least one run (LineGrouper + // rebuilds them from cursor-jump positions). + expect(allText).toMatch(/ {2,}/); + }); +}); + +test.describe("PDF text editor - colour", () => { + test("changing the colour control dispatches a SetColour edit", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + // Capture the clicked run's model id so the fill assertion targets the + // run that was actually mutated, not runs[0] blindly. + const runId = await firstRun.evaluate((el) => { + const tid = el.getAttribute("data-testid") ?? ""; + return tid.replace(/^pdf-editor-run-/, ""); + }); + await firstRun.click(); + + // Mantine's ColorInput stamps the testid on the wrapper, not the underlying + // . + const colourInput = page.getByLabel("Font colour").first(); + await expect(colourInput).toBeEnabled(); + await colourInput.fill("#ff0000"); // theme-allow-color test input value + await colourInput.press("Enter"); + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled(); + + // The undo button being enabled only proves SOMETHING dispatched. + const fill = await page.evaluate((id) => { + const store = ( + window as unknown as { + __editor_store: { + state: { + pages: { + runs: { + id: string; + fill: { r: number; g: number; b: number; a: number }; + }[]; + }[]; + }; + }; + } + ).__editor_store; + const run = store.state.pages[0]?.runs.find((r) => r.id === id); + return run ? { ...run.fill } : null; + }, runId); + expect(fill).not.toBeNull(); + expect(fill!.r).toBe(255); + expect(fill!.g).toBe(0); + expect(fill!.b).toBe(0); + }); +}); + +test.describe("PDF text editor - delete + multi-select", () => { + test("Delete button removes the selected run", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const before = await runs.count(); + expect(before).toBeGreaterThan(0); + + const firstId = await runs.first().getAttribute("data-testid"); + await runs.first().click(); + await page.getByTestId("pdf-editor-delete").click(); + + // The deleted run's element should no longer be in the DOM. + await expect(page.getByTestId(firstId!)).toHaveCount(0); + await expect(runs).toHaveCount(before - 1); + }); + + test("shift-click selects multiple runs", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const count = await runs.count(); + if (count < 2) { + test.skip(true, "Fixture has < 2 runs"); + return; + } + + await runs.nth(0).click(); + await runs.nth(1).click({ modifiers: ["Shift"] }); + + // After a multi-select with two different fills, the colour input is null + // (mixed). + const colourInput = page.getByLabel("Font colour").first(); + await expect(colourInput).toBeEnabled(); + await colourInput.fill("#00aa00"); // theme-allow-color test input value + await colourInput.press("Enter"); + // One edit per selected run = >=2 entries on the undo stack. + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled(); + await page.getByTestId("pdf-editor-undo").click(); + await expect(page.getByTestId("pdf-editor-redo")).toBeEnabled(); + }); +}); + +test.describe("PDF text editor - keyboard shortcuts", () => { + test("Ctrl+Z undoes the latest edit", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + const original = (await firstRun.innerText()) ?? ""; + + await typeIntoRun(page, runTestId, "tt"); + await expect(firstRun).toContainText("tt"); + + // Move focus off the run so the Ctrl+Z isn't captured as caret undo. + await page + .locator('[data-testid="pdf-editor-stage"]') + .click({ position: { x: 5, y: 5 } }); + await page.keyboard.press("Control+z"); + await expect(firstRun).toHaveText(original); + }); +}); + +test.describe("PDF text editor - font family", () => { + test("changing font family dispatches a SetFontFamily edit", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runId = await firstRun.evaluate((el) => + (el.getAttribute("data-testid") ?? "").replace(/^pdf-editor-run-/, ""), + ); + await firstRun.click(); + + const readFontId = (id: string) => + page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + state: { pages: { runs: { id: string; fontId: string }[] }[] }; + }; + } + ).__editor_store; + return ( + store.state.pages[0]?.runs.find((r) => r.id === rid)?.fontId ?? null + ); + }, id); + + const fontIdBefore = await readFontId(runId); + + const family = page.getByLabel("Font family").first(); + await expect(family).toBeEnabled(); + await family.click(); + // Mantine Select dropdown - pick "Helvetica" option by visible text. + // The dropdown renders in a Portal so we query at the page root. + await page + .getByRole("option", { name: /^Helvetica$/i }) + .first() + .click({ timeout: 10_000 }); + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled(); + + // Undo enabled only proves a dispatch. Assert the run's model fontId + // actually flipped to a Helvetica family (and away from its original). + const fontIdAfter = await readFontId(runId); + expect(fontIdAfter).not.toBeNull(); + expect(fontIdAfter).toMatch(/helvetica/i); + expect(fontIdAfter).not.toBe(fontIdBefore); + }); +}); + +test.describe("PDF text editor - multi-page", () => { + test("renders every page of a multi-page document", async ({ page }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(MULTI_PAGE_PDF); + + // The fixture has 3 pages; assert all three render. + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("pdf-editor-page-1")).toBeVisible(); + await expect(page.getByTestId("pdf-editor-page-2")).toBeVisible(); + }); + + test("edits on a non-first page are saved and round-trip", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(MULTI_PAGE_PDF); + await expect(page.getByTestId("pdf-editor-page-2")).toBeVisible({ + timeout: 30_000, + }); + + // Pick the first text run on page 2 (index 2). Skip if it has none. + const runs = page.locator('[data-testid^="pdf-editor-run-p2-"]'); + const count = await runs.count(); + if (count === 0) { + test.skip(true, "Multi-page fixture page 2 has no editable text runs"); + return; + } + + const target = runs.first(); + const runTestId = (await target.getAttribute("data-testid")) ?? ""; + const runId = runTestId.replace(/^pdf-editor-run-/, ""); + // Capture the page-2 run's model text before the edit so we can prove the + // appended char survives a full round-trip, not just lands in DOM. + const textBefore = await page.evaluate((id) => { + const store = ( + window as unknown as { + __editor_store: { + state: { pages: { runs: { id: string; text: string }[] }[] }; + }; + } + ).__editor_store; + return store.state.pages[2]?.runs.find((r) => r.id === id)?.text ?? ""; + }, runId); + // Append a chr known to be in latin subsets. + await typeIntoRun(page, runTestId, "e"); + await expect(target).toContainText("e"); + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled(); + + // Save the edited document and capture the bytes. + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + + // Remember the document we are replacing: after the re-upload the OLD + // document's page-2 runs are still mounted. + await stashCurrentDocument(page); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round-trip.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect(page.getByTestId("pdf-editor-page-2")).toBeVisible({ + timeout: 30_000, + }); + await waitForReopenedPage(page, 2); + + // Re-read the page-2 run text through PdfiumTextReader + LineGrouper. + const page2Text = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + state: { pages: { runs: { text: string }[] }[] }; + }; + } + ).__editor_store; + return (store.state.pages[2]?.runs ?? []).map((r) => r.text).join("\n"); + }); + // The edit appended 'e' to the run's last token. + const lastToken = textBefore.trim().split(/\s+/).pop() ?? textBefore; + expect(page2Text).toContain(`${lastToken}e`); + }); +}); + +test.describe("PDF text editor - bold/italic", () => { + test("Bold toggle dispatches a SetFontFamily edit", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runId = await firstRun.evaluate((el) => + (el.getAttribute("data-testid") ?? "").replace(/^pdf-editor-run-/, ""), + ); + await firstRun.click(); + // First we must swap to a base-14 font (Helvetica) since the source PDF's + // runs use unknown families that the bold flip doesn't know how to map. + const family = page.getByLabel("Font family").first(); + await family.click(); + await page + .getByRole("option", { name: /^Helvetica$/i }) + .first() + .click({ timeout: 10_000 }); + // Wait for the dispatch to LAND before reading the history depth. Clicking + // the option only starts it; reading straight after raced the command and + // saw an empty undo stack most of the time. + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled(); + + const readState = (id: string) => + page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + history: { size: () => { undo: number; redo: number } }; + state: { pages: { runs: { id: string; fontId: string }[] }[] }; + }; + } + ).__editor_store; + return { + undoDepth: store.history.size().undo, + fontId: + store.state.pages[0]?.runs.find((r) => r.id === rid)?.fontId ?? + null, + }; + }, id); + + const before = await readState(runId); + expect(before.undoDepth).toBeGreaterThan(0); + + // Now pick the bold variant. It should dispatch another edit (undo stack + // grows). The dedicated weight button is gone; the picker is the way in. + await selectFontFamily(page, "Helvetica Bold"); + + // The toolbar's active state flips on local component state, which can beat + // the command onto the screen - so wait for the history itself to grow. + await page.waitForFunction( + (depth) => { + const store = ( + window as unknown as { + __editor_store: { + history: { size: () => { undo: number; redo: number } }; + }; + } + ).__editor_store; + return store.history.size().undo > depth; + }, + before.undoDepth, + { timeout: 10_000 }, + ); + + // The misnamed boolean was never compared. Assert a real history-size + // growth AND that the run's fontId gained a Bold variant. + const after = await readState(runId); + expect(after.undoDepth).toBeGreaterThan(before.undoDepth); + expect(after.fontId).toMatch(/bold/i); + }); + + test("user-sample.pdf: Bold on a LineGrouper-merged tagline removes every per-glyph original (no ghost layers)", async ({ + page, + }) => { + // Regression for the user-reported "I hit bold and unbold and it broke the + // text and made multiple layers" bug. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + mergedFromPtrs: number[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r + ? { + id: r.id, + text: r.text, + fontId: r.fontId, + mergedCount: r.mergedFromPtrs.length, + } + : null; + }); + if (!baseline) { + test.skip( + true, + "user-sample.pdf missing Adobe/Acrobat/Alternative tagline", + ); + return; + } + // The bug only surfaces on per-glyph layouts; sanity-check the + // fixture is still emitting one ptr per glyph. + expect(baseline.mergedCount).toBeGreaterThan(10); + + // Select the tagline via the store API. + await page.evaluate((id) => { + const store = ( + window as unknown as { + __editor_store: { + selection: { selectOne: (rid: string) => void }; + }; + } + ).__editor_store; + store.selection.selectOne(id); + }, baseline.id); + + // Bold then un-bold (the user's exact sequence). Each pick dispatches a + // SetFontFamily command. + await selectFontFamily(page, "Helvetica Bold"); + await page.waitForTimeout(300); + await selectFontFamily(page, "Helvetica"); + await page.waitForTimeout(300); + + const after = await page.evaluate((id) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + mergedFromPtrs: number[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === id); + return r + ? { + text: r.text, + fontId: r.fontId, + mergedCount: r.mergedFromPtrs.length, + } + : null; + }, baseline.id); + if (!after) throw new Error("tagline run vanished after bold"); + // Text content preserved. + expect(after.text).toBe(baseline.text); + // Run swapped to a base-14 font. + expect(after.fontId).toMatch(/^base14:Helvetica/); + // mergedFromPtrs MUST be cleared - the run is now one base-14 object, not a + // per-glyph cluster. + expect(after.mergedCount).toBe(0); + + // Round-trip through save+reopen and check no ghost text. + const downloadBtn = page.getByTestId("pdf-editor-download"); + const downloadPromise = page.waitForEvent("download"); + await downloadBtn.click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round-trip.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + + const reopenedRuns = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ text: string }> } }; + }; + } + ).__editor_store; + return store.doc.page(0).runs.map((r) => r.text); + }); + // The CORE assertion: each distinctive tagline word appears in EXACTLY ONE + // run. + const countCarrying = (word: string) => + reopenedRuns.filter((t) => t.includes(word)).length; + for (const word of ["Adobe", "Acrobat", "Alternative"]) { + expect( + countCarrying(word), + `Runs carrying "${word}": ${JSON.stringify( + reopenedRuns.filter((t) => t.includes(word)), + )}`, + ).toBe(1); + } + }); +}); + +test.describe("PDF text editor - add text box", () => { + test("Add text mode + page click inserts a new run", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const before = await runs.count(); + expect(before).toBeGreaterThan(0); + + await page.getByTestId("pdf-editor-add-text").click(); + // The mode toggle changes the button label. + await expect(page.getByTestId("pdf-editor-add-text")).toContainText( + /click page to add text/i, + ); + + // Click somewhere on page 0. The click handler converts to PDF + // page-space coords and dispatches InsertTextCommand. + const pageEl = page.getByTestId("pdf-editor-page-0"); + await pageEl.click({ position: { x: 200, y: 400 } }); + + await expect(runs).toHaveCount(before + 1, { timeout: 5_000 }); + // Mode resets back to select after the insertion. + await expect(page.getByTestId("pdf-editor-add-text")).toHaveText( + "Add text", + ); + }); +}); + +test.describe("PDF text editor - line grouping", () => { + test("table-cell single-letter runs cluster into one editable group", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + // The raw PDFium read of sample.pdf produced 9 separate text objects (one + // per word/letter). + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const count = await runs.count(); + expect(count).toBeGreaterThan(0); + expect(count).toBeLessThan(9); + }); + + test("editing a merged run replaces the cluster with one PDF object", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const target = page.locator('[data-testid^="pdf-editor-run-p0-"]').first(); + const runTestId = (await target.getAttribute("data-testid")) ?? ""; + const original = (await target.innerText()) ?? ""; + // Typing any character into a merged group falls through to the base-14 + // Helvetica fallback. + await typeIntoRun(page, runTestId, "ZZZ"); + await expect(target).toContainText(`${original}ZZZ`); + + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const buf = Buffer.concat(chunks); + expect(buf.subarray(0, 4).toString("ascii")).toBe("%PDF"); + }); +}); + +test.describe("PDF text editor - glyph fallback", () => { + test("typing arbitrary chars stays visible in the overlay", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + // The merged-collapse path swaps the run to Helvetica (base-14) so + // arbitrary Latin characters can be typed. + const target = page.locator('[data-testid^="pdf-editor-run-p0-"]').first(); + const runTestId = (await target.getAttribute("data-testid")) ?? ""; + await typeIntoRun(page, runTestId, "x!@#"); + await expect(target).toContainText("x!@#"); + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled(); + }); +}); + +/** Alpha of a computed `rgb()/rgba()` string; 0 when fully transparent. */ +function alphaOf(cssColor: string): number { + const m = /rgba?\(([^)]+)\)/.exec(cssColor); + if (!m) return 1; + const parts = m[1].split(",").map((x) => parseFloat(x.trim())); + return parts.length >= 4 ? parts[3] : 1; +} + +test.describe("PDF text editor - typing fidelity", () => { + test("a clicked run keeps its original ink until it is actually edited", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const target = page.locator('[data-testid^="pdf-editor-run-p0-"]').first(); + const read = () => + target.evaluate((el) => { + const cs = window.getComputedStyle(el); + return { + color: cs.color, + background: cs.backgroundColor, + fontFamily: cs.fontFamily, + }; + }); + + await target.click(); + // Clicking only places a caret, so the PDF's own glyphs stay on screen + // rather than being covered by a CSS approximation of them. + const clicked = await read(); + expect(clicked.color).toBe("rgba(0, 0, 0, 0)"); + // A faint selection tint is fine; what must not appear is the near-opaque + // mask, which would hide the PDF's own glyphs behind a CSS rendering. + expect(alphaOf(clicked.background)).toBeLessThan(0.5); + + await page.keyboard.type("X"); + await page.waitForTimeout(400); + const typed = await read(); + expect(typed.color).toBe("rgba(0, 0, 0, 0)"); + expect(alphaOf(typed.background)).toBeLessThan(0.5); + await expect(target).toContainText("X"); + }); +}); + +test.describe("PDF text editor - image manipulation", () => { + test("image overlays render with pointer events enabled", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const image = page.locator('[data-testid^="pdf-editor-image-"]').first(); + await expect(image).toBeVisible({ timeout: 30_000 }); + const pointer = await image.evaluate( + (el) => window.getComputedStyle(el).pointerEvents, + ); + expect(pointer).toBe("auto"); + }); + + test("image overlay accepts a drag (legacy alias)", async ({ page }) => { + // Same behaviour as the absolute-transform test above; retained + // because external scripts may still reference this test name. + await gotoEditor(page); + await loadSamplePdf(page); + const image = page.locator('[data-testid^="pdf-editor-image-"]').first(); + await expect(image).toBeVisible({ timeout: 30_000 }); + const box = await image.boundingBox(); + if (!box) throw new Error("image overlay has no bounding box"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + // Paced one move per task: WebKit delivers `steps:` moves in one batch, + // which react-rnd's delta tracking collapses to a fraction of the drag. + for (let i = 1; i <= 5; i += 1) { + await page.mouse.move( + box.x + box.width / 2 + (80 * i) / 5, + box.y + box.height / 2 + (40 * i) / 5, + ); + await page.waitForTimeout(16); + } + await page.mouse.up(); + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled({ + timeout: 5_000, + }); + }); +}); + +test.describe("PDF text editor - image click-through + delete", () => { + test("idle image overlay paints no border so text underneath is reachable", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const image = page.locator('[data-testid^="pdf-editor-image-"]').first(); + await expect(image).toBeVisible({ timeout: 30_000 }); + // Idle (no hover, not selected) - outline should be 'none'. + const idleOutline = await image.evaluate( + (el) => window.getComputedStyle(el).outlineStyle, + ); + expect(idleOutline).toBe("none"); + }); + + test("clicking an image selects it, enabling Delete on the toolbar", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const image = page.locator('[data-testid^="pdf-editor-image-"]').first(); + await expect(image).toBeVisible({ timeout: 30_000 }); + await image.click(); + // After selection the overlay has a solid border. + await expect(image).toHaveCSS("outline-style", "solid"); + await expect(page.getByTestId("pdf-editor-delete")).toBeEnabled(); + }); + + test("Delete on a selected image removes it", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const images = page.locator('[data-testid^="pdf-editor-image-"]'); + const before = await images.count(); + expect(before).toBeGreaterThan(0); + const first = images.first(); + const imgId = (await first.getAttribute("data-testid")) ?? ""; + await first.click(); + await page.getByTestId("pdf-editor-delete").click(); + await expect(page.getByTestId(imgId)).toHaveCount(0); + await expect(images).toHaveCount(before - 1); + }); +}); + +test.describe("PDF text editor - render throttling", () => { + test("off-screen pages show a placeholder until they near the viewport", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(MULTI_PAGE_PDF); + + // Page 0 is at the top of the stage and within the viewport on first + // render. + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("pdf-editor-page-0-placeholder")).toHaveCount( + 0, + { + timeout: 10_000, + }, + ); + + // Page 2 is below the fold for a 1080-tall viewport (each page is 792 PDF + // points * 1.5 scale ≈ 1188 CSS pixels). + await expect(page.getByTestId("pdf-editor-page-2")).toBeVisible(); + }); +}); + +test.describe("PDF text editor - lazy page loading", () => { + test("multi-page docs render their pages without blocking on every read", async ({ + page, + }) => { + // We don't have a 60-page fixture in the repo so we time the load of the + // existing multi-page fixture as a guardrail. + await gotoEditor(page); + const started = Date.now(); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(MULTI_PAGE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + const elapsedMs = Date.now() - started; + // Generous bound - the lazy load is a fraction of this in practice but CI + // machines vary. + expect(elapsedMs).toBeLessThan(10_000); + }); + + test("big-sample.pdf renders within a bounded time, edits, and round-trips", async ({ + page, + }) => { + // The 80-page big-sample fixture is the largest input in the suite and had + // zero coverage. + test.setTimeout(120_000); + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(BIG_SAMPLE_PDF); + + // Page 0 must render within a bounded time even for the big doc. + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + + // A later page lazily renders once scrolled near the viewport. + await page.evaluate(() => { + const el = document.querySelector( + '[data-testid="pdf-editor-page-40"]', + ); + el?.scrollIntoView({ block: "center" }); + }); + await expect(page.getByTestId("pdf-editor-page-40")).toBeVisible({ + timeout: 30_000, + }); + + // Make a trivial edit on page 0, then save + reopen and assert it survived. + await page.evaluate(() => { + const el = document.querySelector( + '[data-testid="pdf-editor-page-0"]', + ); + el?.scrollIntoView({ block: "center" }); + }); + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + await expect(firstRun).toBeVisible({ timeout: 30_000 }); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + await typeIntoRun(page, runTestId, "ZZBIG"); + await expect(firstRun).toContainText("ZZBIG"); + + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "big-round-trip.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + const reopenedText = await page + .waitForFunction( + () => { + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p0-"]', + ), + ); + if (runs.length === 0) return null; + const joined = runs.map((el) => el.innerText).join("\n"); + return /ZZBIG/.test(joined) ? joined : null; + }, + { timeout: 30_000, polling: 500 }, + ) + .then((h) => h.jsonValue() as Promise); + expect(reopenedText).toContain("ZZBIG"); + }); +}); + +test.describe("PDF text editor - paragraph recognition", () => { + test("a four-line body paragraph collapses into one overlay", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(PARAGRAPH_PDF); + + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + await expect(runs.first()).toBeVisible({ timeout: 30_000 }); + const count = await runs.count(); + // 5 source text objects (1 heading + 4 body lines) ought to fold + // down to 2 overlays (heading + paragraph block). + expect(count).toBeLessThan(5); + expect(count).toBeGreaterThanOrEqual(2); + + // One of the overlays must contain text from across multiple + // body lines (newline-joined by ParagraphGrouper). + const allTexts = await Promise.all( + (await runs.all()).map((r) => r.innerText()), + ); + const paragraphLike = allTexts.find((t) => t.trimEnd().includes("\n")); + expect(paragraphLike).toBeTruthy(); + expect(paragraphLike!.toLowerCase()).toContain("first line"); + expect(paragraphLike!.toLowerCase()).toContain("fourth line"); + }); +}); + +test.describe("PDF text editor - text run move (Ctrl+drag)", () => { + test("Ctrl+drag on a text run dispatches MoveTextRunCommand", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const run = page.locator('[data-testid^="pdf-editor-run-p0-"]').first(); + await expect(run).toBeVisible({ timeout: 30_000 }); + const box = await run.boundingBox(); + if (!box) throw new Error("text run has no bounding box"); + + await page.keyboard.down("Control"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move( + box.x + box.width / 2 + 60, + box.y + box.height / 2 + 20, + { steps: 5 }, + ); + await page.mouse.up(); + await page.keyboard.up("Control"); + + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled({ + timeout: 5_000, + }); + }); +}); + +test.describe("PDF text editor - image transform (absolute)", () => { + test("dragging an image dispatches SetImageTransformCommand", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const image = page.locator('[data-testid^="pdf-editor-image-"]').first(); + await expect(image).toBeVisible({ timeout: 30_000 }); + const box = await image.boundingBox(); + if (!box) throw new Error("image overlay has no bounding box"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + // Paced one move per task - see the legacy-alias drag above. + for (let i = 1; i <= 5; i += 1) { + await page.mouse.move( + box.x + box.width / 2 + (90 * i) / 5, + box.y + box.height / 2 + (60 * i) / 5, + ); + await page.waitForTimeout(16); + } + await page.mouse.up(); + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled({ + timeout: 5_000, + }); + }); +}); + +test.describe("PDF text editor - form xobject recursion", () => { + test("text inside form xobjects (magazine layout) is extracted", async ({ + page, + }) => { + await gotoEditor(page); + + // The fixture is generated by + // src/core/tests/test-fixtures/generate-form-xobject-sample.mjs. + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(FORM_XOBJECT_PDF); + + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + await expect(runs.first()).toBeVisible({ timeout: 30_000 }); + expect(await runs.count()).toBeGreaterThan(0); + + const allText = ( + await Promise.all((await runs.all()).map((r) => r.innerText())) + ).join(" "); + expect(allText.toLowerCase()).toMatch(/magazine|subheading|paragraph/); + }); +}); + +test.describe("PDF text editor - load progress overlay", () => { + test("loading overlay shows a stage and progress bar while opening", async ({ + page, + }) => { + await gotoEditor(page); + // Kick off the load and immediately capture the stage element. + const overlayPromise = page + .getByTestId("pdf-editor-stage-loading") + .waitFor({ state: "visible", timeout: 5_000 }) + .then(() => true) + .catch(() => false); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(MULTI_PAGE_PDF); + const sawOverlay = await overlayPromise; + // Either the overlay appeared, or the load finished too fast for the + // observer to catch it. + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + // Document the assertion either way so a future regression that + // never paints the overlay AND never completes is still caught. + if (!sawOverlay) { + // Sanity-check that loading is now false. + await expect(page.getByTestId("pdf-editor-stage-loading")).toHaveCount(0); + } + }); +}); + +test.describe("PDF text editor - fit-to-width", () => { + test("Fit button updates the zoom percent based on viewport width", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + const before = await page + .getByTestId("pdf-editor-zoom-percent") + .innerText(); + await page.getByTestId("pdf-editor-zoom-fit").click(); + const after = await page.getByTestId("pdf-editor-zoom-percent").innerText(); + // The fit value depends on viewport width, but must be a sensible + // percentage in the clamped range. + const value = parseInt(after.replace("%", ""), 10); + expect(value).toBeGreaterThanOrEqual(25); + expect(value).toBeLessThanOrEqual(400); + expect(after).not.toBe(before); + }); +}); + +test.describe("PDF text editor - F3 next match", () => { + test("F3 opens the find bar and steps to the next match", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + await page.keyboard.press("Control+f"); + await page.getByTestId("pdf-editor-find-input").fill("documents"); + await page.keyboard.press("F3"); + // The find bar stays open, count text shows a match position. + await expect(page.getByTestId("pdf-editor-find-bar")).toBeVisible(); + await expect(page.getByTestId("pdf-editor-find-count")).toContainText( + /of \d+/, + ); + }); +}); + +test.describe("PDF text editor - zoom controls", () => { + test("zoom in / zoom out / 100% buttons drive renderScale", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + const percent = page.getByTestId("pdf-editor-zoom-percent"); + await expect(percent).toHaveText("150%"); + await page.getByTestId("pdf-editor-zoom-in").click(); + await expect(percent).toHaveText("175%"); + await page.getByTestId("pdf-editor-zoom-out").click(); + await page.getByTestId("pdf-editor-zoom-out").click(); + await expect(percent).toHaveText("125%"); + await page.getByTestId("pdf-editor-zoom-reset").click(); + await expect(percent).toHaveText("100%"); + }); +}); + +test.describe("PDF text editor - find in document", () => { + test("Ctrl+F opens the find bar and steps through matches", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + await page.keyboard.press("Control+f"); + await expect(page.getByTestId("pdf-editor-find-bar")).toBeVisible(); + await page.getByTestId("pdf-editor-find-input").fill("Test"); + const count = page.getByTestId("pdf-editor-find-count"); + await expect(count).toContainText(/of \d+/); + await page.getByTestId("pdf-editor-find-next").click(); + await expect(count).toContainText(/of \d+/); + await page.getByTestId("pdf-editor-find-close").click(); + await expect(page.getByTestId("pdf-editor-find-bar")).toHaveCount(0); + }); +}); + +test.describe("PDF text editor - paragraph soft-wrap", () => { + test("typing into a paragraph captures visual line breaks", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(PARAGRAPH_PDF); + // The paragraph overlay is the second run on page 0. + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + await expect(runs.first()).toBeVisible({ timeout: 30_000 }); + const allTexts = await Promise.all( + (await runs.all()).map((r) => r.innerText()), + ); + const para = allTexts.find((t) => t.includes("\n")); + expect(para).toBeTruthy(); + // The paragraph snapshot already contains the original \n breaks. + expect(para!.split("\n").length).toBeGreaterThanOrEqual(2); + }); +}); + +test.describe("PDF text editor - undo restores form-xobject text", () => { + test("editing form-xobject text then undoing puts the original back visually", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(FORM_XOBJECT_PDF); + const target = page.locator('[data-testid^="pdf-editor-run-p0-"]').first(); + const runTestId = (await target.getAttribute("data-testid")) ?? ""; + // Compared after stripping WebKit's trailing newline; `toContain` on the + // array (rather than `.some(...)`) prints both sides when it fails. + const stripNl = (t: string) => t.replace(/\r?\n+$/, ""); + const original = stripNl((await target.innerText()) ?? ""); + + await typeIntoRun(page, runTestId, "ZZZ"); + await expect(target).toContainText("ZZZ"); + + await page.getByTestId("pdf-editor-undo").click(); + // After undo, a run on page 0 contains the original text. + const undoneRuns = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const undoneTexts = await Promise.all( + (await undoneRuns.all()).map((r) => r.innerText()), + ); + expect(undoneTexts.map(stripNl)).toContain(original); + }); +}); + +test.describe("PDF text editor - workbench tab UX", () => { + test("Viewer tab is hidden while the editor tool is selected", async ({ + page, + }) => { + await gotoEditor(page); + // The WorkbenchBar exposes its tab buttons with the tab label as the + // accessible text. + const viewerTab = page + .locator(".workbench-bar-views, .workbench-bar-center") + .getByRole("button", { name: /^Viewer$/ }); + await expect(viewerTab).toHaveCount(0); + }); + + test("Editor workbench pins itself when an external setWorkbench fires", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + // Simulate the FileContext side-effect that pushes to viewer on + // file preview. The pin-effect should immediately switch back. + await page.evaluate(() => { + // Best-effort hack: find any "Active Files" / "Files" tab and click it, + // then expect we bounce back. + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + await expect(page.getByTestId("pdf-editor-stage")).toBeVisible(); + }); +}); + +test.describe("PDF text editor - dirty state", () => { + test("top bar marks the file dirty after an edit", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + // Save state lives beside the top-bar filename; the sidebar no longer + // repeats it. Clean on load. + const filename = page.getByTestId("pdf-editor-filename"); + await expect(filename).toBeVisible(); + await expect(filename).not.toContainText("unsaved"); + + const firstRunTestId = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first() + .getAttribute("data-testid"); + await typeIntoRun(page, firstRunTestId!, "X"); + + await expect(filename).toContainText("unsaved"); + }); +}); + +test.describe("PDF text editor - toolbar tooltips", () => { + test("toolbar buttons expose tooltip labels", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + // After the text/image-editor scope cleanup, the rotate, print, reset, and + // save-to-workbench toolbar entries are gone. + await expect(page.getByTestId("pdf-editor-add-text")).toBeVisible(); + await expect(page.getByTestId("pdf-editor-add-image")).toBeVisible(); + await expect(page.getByTestId("pdf-editor-save")).toBeVisible(); + await expect(page.getByTestId("pdf-editor-help")).toBeVisible(); + // Removed controls must NOT appear: + await expect(page.getByTestId("pdf-editor-rotate-left")).toHaveCount(0); + await expect(page.getByTestId("pdf-editor-rotate-right")).toHaveCount(0); + await expect(page.getByTestId("pdf-editor-print")).toHaveCount(0); + await expect(page.getByTestId("pdf-editor-reset")).toHaveCount(0); + await expect(page.getByTestId("pdf-editor-save-workbench")).toHaveCount(0); + }); +}); + +test.describe("PDF text editor - duplicate selected run", () => { + test("Ctrl+D clones the selected text run and undo removes the clone", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const before = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + + const firstRunTestId = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first() + .getAttribute("data-testid"); + await page.getByTestId(firstRunTestId!).click(); + await page.waitForTimeout(80); + + await page.keyboard.down("Control"); + await page.keyboard.press("d"); + await page.keyboard.up("Control"); + await page.waitForTimeout(250); + + const after = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + expect(after).toBe(before + 1); + + await page.evaluate(() => { + const store = (window as unknown as { __editor_store?: unknown }) + .__editor_store as { undo: () => void }; + store.undo(); + }); + await page.waitForTimeout(200); + + const reverted = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + expect(reverted).toBe(before); + }); +}); + +test.describe("PDF text editor - Ctrl+wheel zoom", () => { + test("Ctrl+wheel up on stage increases renderScale", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const readScale = () => + page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store?: { getState: () => { renderScale: number } }; + } + ).__editor_store!; + return store.getState().renderScale; + }); + + const initial = await readScale(); + + await page.evaluate(() => { + const stage = document.querySelector( + '[data-testid="pdf-editor-stage"]', + ) as HTMLElement | null; + stage?.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -100, + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + await page.waitForTimeout(150); + + const after = await readScale(); + expect(after).toBeGreaterThan(initial); + }); +}); + +test.describe("PDF text editor - paragraph line wrap fidelity", () => { + test("paragraph overlay does not visually wrap its source lines", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(PARAGRAPH_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + + const mismatched = await page.evaluate(() => { + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p0-"]', + ), + ); + return runs + .map((el) => { + // Drop WebKit's trailing newline before counting source lines. + const text = (el.innerText || "").replace(/\r?\n$/, ""); + const sourceLines = text.split(/\r?\n/).length; + if (sourceLines < 2) return null; + const lh = + parseFloat(getComputedStyle(el).lineHeight) || + el.getBoundingClientRect().height; + const visualLines = Math.round( + el.getBoundingClientRect().height / Math.max(1, lh), + ); + return { sourceLines, visualLines, text: text.slice(0, 30) }; + }) + .filter(Boolean) as Array<{ + sourceLines: number; + visualLines: number; + text: string; + }>; + }); + + expect(mismatched.length).toBeGreaterThan(0); + for (const row of mismatched) { + expect( + row.visualLines, + `paragraph "${row.text}" reports ${row.visualLines} visual lines for ${row.sourceLines} source lines`, + ).toBe(row.sourceLines); + } + }); +}); + +test.describe("PDF text editor - marquee + merge", () => { + test("Ctrl+Shift+drag selects every run inside the marquee", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + await page.waitForTimeout(150); + const totalRuns = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + expect(totalRuns).toBeGreaterThan(1); + + // Dispatch the synthetic mousedown / mousemove / mouseup that wrap + // every run on page 0. + await page.evaluate(() => { + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p0-"]', + ), + ); + const rects = runs.map((el) => el.getBoundingClientRect()); + const left = Math.min(...rects.map((r) => r.left)); + const top = Math.min(...rects.map((r) => r.top)); + const right = Math.max(...rects.map((r) => r.right)); + const bottom = Math.max(...rects.map((r) => r.bottom)); + const stage = document.querySelector( + '[data-testid="pdf-editor-pages"]', + ) as HTMLElement; + // MarqueeSelector listens for POINTER events (pointer-based for + // mouse/pen/touch parity), so fire pointer events, not mouse events. + const fire = (type: string, x: number, y: number) => + stage.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + ctrlKey: true, + shiftKey: true, + pointerId: 1, + }), + ); + fire("pointerdown", left - 5, top - 5); + fire("pointermove", right + 5, bottom + 5); + // Pointerup goes through window in MarqueeSelector's listener. + window.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + cancelable: true, + clientX: right + 5, + clientY: bottom + 5, + ctrlKey: true, + shiftKey: true, + pointerId: 1, + }), + ); + }); + + await page.waitForTimeout(120); + + const selected = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store?: { + selection: { value: { runIds: string[] } }; + }; + } + ).__editor_store!; + return store.selection.value.runIds.length; + }); + + expect(selected).toBe(totalRuns); + }); + + test("Group / Ungroup toolbar buttons merge and split paragraphs", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const initial = await runs.count(); + expect(initial).toBeGreaterThanOrEqual(2); + + await expect(page.getByTestId("pdf-editor-group")).toHaveCount(0); + await expect(page.getByTestId("pdf-editor-ungroup")).toHaveCount(0); + + const ids = await runs.evaluateAll((els) => + els + .slice(0, 2) + .map((el) => + el.getAttribute("data-testid")!.replace(/^pdf-editor-run-/, ""), + ), + ); + await page.evaluate((ids) => { + const store = ( + window as unknown as { + __editor_store?: { + selection: { selectMany: (ids: string[]) => void }; + }; + } + ).__editor_store!; + store.selection.selectMany(ids); + }, ids); + await page.waitForTimeout(100); + + await expect(page.getByTestId("pdf-editor-group")).toBeEnabled(); + await page.getByTestId("pdf-editor-group").click(); + await page.waitForTimeout(200); + + const merged = await runs.count(); + expect(merged).toBe(initial - 1); + + await expect(page.getByTestId("pdf-editor-ungroup")).toBeEnabled(); + await page.getByTestId("pdf-editor-ungroup").click(); + await page.waitForTimeout(200); + + const split = await runs.count(); + expect(split).toBe(initial); + }); + + test("Ctrl+M merges multi-selected runs into one paragraph", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const ids = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")!)); + expect(ids.length).toBeGreaterThanOrEqual(2); + + await page.getByTestId(ids[0]).click(); + await page.getByTestId(ids[1]).click({ modifiers: ["Shift"] }); + + const before = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + + await page.evaluate(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "m", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + await page.waitForTimeout(150); + + const after = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + expect(after).toBe(before - 1); + }); +}); + +test.describe("PDF text editor - help overlay", () => { + test("? opens the keyboard shortcuts overlay", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + await page.evaluate(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "?", + bubbles: true, + cancelable: true, + }), + ); + }); + + await expect( + page.getByRole("heading", { name: "Keyboard shortcuts" }), + ).toBeVisible(); + await expect(page.getByText("Find").first()).toBeVisible(); + }); + + test("Help button opens the overlay", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + await page.getByTestId("pdf-editor-help").click(); + await expect( + page.getByRole("heading", { name: "Keyboard shortcuts" }), + ).toBeVisible(); + }); +}); + +test.describe("PDF text editor - filename in header", () => { + test("loaded filename shown in toolbar header", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const filename = page.getByTestId("pdf-editor-filename"); + await expect(filename).toBeVisible(); + await expect(filename).toContainText(/sample\.pdf/i); + }); +}); + +test.describe("PDF text editor - selection count panel", () => { + test("sidebar shows N runs selected after multi-select", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const ids = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")!)); + expect(ids.length).toBeGreaterThan(1); + + await page.getByTestId(ids[0]).click(); + await page.getByTestId(ids[1]).click({ modifiers: ["Shift"] }); + + const countNode = page.getByTestId("pdf-editor-selection-count"); + await expect(countNode).toBeVisible(); + await expect(countNode).toContainText(/2 boxes/); + }); +}); + +test.describe("PDF text editor - PageDown navigation", () => { + test("PageDown scrolls to the next page", async ({ page }) => { + await gotoEditor(page); + await loadMultiPageSample(page); + + const beforeTop = await page + .getByTestId("pdf-editor-page-1") + .evaluate((el) => el.getBoundingClientRect().top); + + await page.evaluate(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "PageDown", + bubbles: true, + cancelable: true, + }), + ); + }); + + // Poll rather than a fixed delay: the scroll animates, and on a loaded + // runner 500ms was not always enough for it to have moved at all. + await expect + .poll( + () => + page + .getByTestId("pdf-editor-page-1") + .evaluate((el) => el.getBoundingClientRect().top), + { timeout: 10_000 }, + ) + .toBeLessThan(beforeTop); + }); +}); + +test.describe("PDF text editor - Ctrl+A select all", () => { + test("Ctrl+A on the page stage selects every run", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const total = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + + await page.evaluate(() => { + // Dispatch the keydown on window directly so we exercise the same + // listener the user's Ctrl+A would hit. + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "a", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + await page.waitForTimeout(150); + + const selected = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store?: { + selection: { value: { runIds: string[] } }; + }; + } + ).__editor_store!; + return store.selection.value.runIds.length; + }); + + expect(selected).toBe(total); + }); +}); + +// Stress + edge-case battery. + +test.describe("PDF text editor - stress: whitespace insertion variations", () => { + /** Caret at char index `pos` inside `runTestId`. */ + async function placeCaret( + page: import("@playwright/test").Page, + runTestId: string, + pos: number, + ) { + await page.evaluate( + ({ tid, pos }) => { + const el = document.querySelector( + `[data-testid="${tid}"]`, + ); + if (!el) throw new Error("no run el"); + el.focus(); + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let node: Text | null = null; + let remaining = pos; + while (walker.nextNode()) { + const n = walker.currentNode as Text; + const len = n.textContent?.length ?? 0; + if (remaining <= len) { + node = n; + break; + } + remaining -= len; + } + if (!node) throw new Error("ran out of text walking caret"); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.setStart(node, remaining); + range.setEnd(node, remaining); + sel.removeAllRanges(); + sel.addRange(range); + }, + { tid: runTestId, pos }, + ); + } + + async function insertAt( + page: import("@playwright/test").Page, + runTestId: string, + pos: number, + text: string, + ) { + await placeCaret(page, runTestId, pos); + await page.evaluate((t) => { + document.execCommand("insertText", false, t); + }, text); + await page.waitForTimeout(250); + } + + async function readTagline(page: import("@playwright/test").Page) { + return await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromBounds: Array<{ x: number; right: number }>; + bounds: { x: number; width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r + ? { + id: r.id, + text: r.text, + boundsRight: r.bounds.x + r.bounds.width, + maxRight: Math.max(0, ...r.mergedFromBounds.map((b) => b.right)), + } + : null; + }); + } + + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + // end-of-line inserts + for (const [label, payload] of [ + ["single trailing space + token", " Hi"], + ["leading space + multi-char", " Hello"], + ["double-space + token", " Hi"], + ["token + trailing space", "Hi "], + ["space-surrounded token", " Hi "], + ["internal-space pair", "Hi there"], + ["multi-space internal", "Hi there"], + ] as const) { + test(`whitespace stress: appending ${JSON.stringify(payload)} at end (${label}) keeps every word separated`, async ({ + page, + }) => { + await loadFixture(page); + const before = await readTagline(page); + if (!before) { + test.skip(true, "fixture missing tagline"); + return; + } + await insertAt( + page, + `pdf-editor-run-${before.id}`, + before.text.length, + payload, + ); + const after = await readTagline(page); + if (!after) throw new Error("tagline vanished"); + // Text content gained the payload verbatim. + expect(after.text).toBe(before.text + payload); + // The tagline's right edge advanced (model bounds widen). + expect(after.boundsRight).toBeGreaterThan(before.boundsRight); + }); + } + + test("whitespace stress: inserting at the START of the tagline shifts content right and keeps separation", async ({ + page, + }) => { + await loadFixture(page); + const before = await readTagline(page); + if (!before) { + test.skip(true, "fixture missing tagline"); + return; + } + await insertAt(page, `pdf-editor-run-${before.id}`, 0, "PRE "); + const after = await readTagline(page); + if (!after) throw new Error("tagline vanished"); + expect(after.text.startsWith("PRE")).toBe(true); + // Original "Alternative" word still appears with surrounding + // whitespace - the insert at start must not corrupt mid-line text. + expect(after.text).toMatch(/Alternative/); + }); + + test("whitespace stress: ten alternating insert-space / type-char operations don't compound drift", async ({ + page, + }) => { + // Reach: the cumulative offset / merged-from-bookkeeping must stay accurate + // over many ops, not just one. + await loadFixture(page); + const start = await readTagline(page); + if (!start) { + test.skip(true, "fixture missing tagline"); + return; + } + const seq = " X Y Z W V"; // 5 letters, 5 spaces, varied + for (const ch of seq) { + const current = await readTagline(page); + if (!current) throw new Error("tagline vanished mid-loop"); + await insertAt( + page, + `pdf-editor-run-${current.id}`, + current.text.length, + ch, + ); + } + const end = await readTagline(page); + if (!end) throw new Error("tagline vanished at end"); + expect(end.text).toBe(start.text + seq); + // Right edge grew monotonically beyond the original. + expect(end.boundsRight).toBeGreaterThan(start.boundsRight); + }); + + test("whitespace stress: insert space then immediately backspace it (no ghost bounds left behind)", async ({ + page, + }) => { + await loadFixture(page); + const before = await readTagline(page); + if (!before) { + test.skip(true, "fixture missing tagline"); + return; + } + await insertAt( + page, + `pdf-editor-run-${before.id}`, + before.text.length, + " X", + ); + await placeCaret( + page, + `pdf-editor-run-${before.id}`, + before.text.length + 2, + ); + await page.evaluate(() => { + document.execCommand("delete", false); + document.execCommand("delete", false); + }); + await page.waitForTimeout(250); + const after = await readTagline(page); + if (!after) throw new Error("tagline vanished"); + // Net: text identical, bounds back to ~original. + expect(after.text).toBe(before.text); + const widthDelta = Math.abs(after.boundsRight - before.boundsRight); + expect(widthDelta).toBeLessThan(before.boundsRight * 0.05); + }); +}); + +test.describe("PDF text editor - stress: bold / font swap variations", () => { + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + async function selectTagline(page: import("@playwright/test").Page) { + // The page-0 runs are read lazily on first intersection; wait for them + // to populate so the test actually runs instead of skipping on a race. + await page + .waitForFunction( + () => { + const s = ( + window as unknown as { + __editor_store?: { + doc?: { page: (i: number) => { runs: unknown[] } }; + }; + } + ).__editor_store; + return (s?.doc?.page(0).runs.length ?? 0) > 0; + }, + { timeout: 15_000 }, + ) + .catch(() => {}); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; text: string }>; + }; + }; + selection: { selectOne: (rid: string) => void }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + if (!r) return null; + store.selection.selectOne(r.id); + return r.id; + }); + return id; + } + + async function readRun(page: import("@playwright/test").Page, id: string) { + return await page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + mergedFromPtrs: number[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === rid); + return r + ? { text: r.text, fontId: r.fontId, merged: r.mergedFromPtrs.length } + : null; + }, id); + } + + test("font swap stress: Bold → Bold → Bold (3 toggles) leaves no merged ptrs", async ({ + page, + }) => { + await loadFixture(page); + const id = await selectTagline(page); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + for (const family of ["Helvetica Bold", "Helvetica", "Helvetica Bold"]) { + await selectTagline(page); + await selectFontFamily(page, family); + await page.waitForTimeout(250); + } + const after = await readRun(page, id); + if (!after) throw new Error("run vanished after 3 bold toggles"); + expect(after.merged).toBe(0); + expect(after.fontId).toMatch(/^base14:Helvetica/); + }); + + test("font swap stress: Bold then Italic then Bold (cross-axis toggles) preserves text", async ({ + page, + }) => { + await loadFixture(page); + const id = await selectTagline(page); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + const before = await readRun(page, id); + if (!before) throw new Error("baseline read failed"); + // Re-select before each toolbar click. + await selectTagline(page); + await selectFontFamily(page, "Helvetica Bold"); + await page.waitForTimeout(250); + await selectTagline(page); + await page.getByTestId("pdf-editor-italic").click(); + await page.waitForTimeout(250); + await selectTagline(page); + await selectFontFamily(page, "Helvetica"); + await page.waitForTimeout(250); + const after = await readRun(page, id); + if (!after) throw new Error("run vanished after cross-axis toggles"); + expect(after.text).toBe(before.text); + expect(after.merged).toBe(0); + // Final state: SOMETHING swapped (the run is no longer in the embedded + // source font) and the swap left no ghost layers. + expect(after.fontId).toMatch(/^base14:Helvetica/); + expect(after.fontId).not.toBe(before.fontId); + }); + + test("font swap stress: bold then undo restores per-glyph layout (mergedFromPtrs > 0 again)", async ({ + page, + }) => { + await loadFixture(page); + const id = await selectTagline(page); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + const before = await readRun(page, id); + if (!before) throw new Error("baseline read failed"); + expect(before.merged).toBeGreaterThan(10); + await selectFontFamily(page, "Helvetica Bold"); + await page.waitForTimeout(250); + const mid = await readRun(page, id); + if (!mid) throw new Error("mid read failed"); + expect(mid.merged).toBe(0); + await page.getByTestId("pdf-editor-undo").click(); + await page.waitForTimeout(400); + const after = await readRun(page, id); + if (!after) throw new Error("post-undo read failed"); + expect(after.fontId).toBe(before.fontId); + expect(after.text).toBe(before.text); + // Per-glyph layout restored. + expect(after.merged).toBeGreaterThan(10); + }); + + test("font swap stress: bold then edit (insert) then save+reopen → exactly one tagline run, no ghosts", async ({ + page, + }) => { + await loadFixture(page); + const id = await selectTagline(page); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + await selectFontFamily(page, "Helvetica Bold"); + await page.waitForTimeout(250); + // Now insert text into the bolded run. + await page.evaluate((tid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, " EXTRA"); + }, id); + await page.waitForTimeout(300); + // Save + reopen. + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + const reopenedRuns = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ text: string }> } }; + }; + } + ).__editor_store; + return store.doc.page(0).runs.map((r) => r.text); + }); + // After save+reopen the LineGrouper may or may not re-merge the tagline's + // per-word emits into one run depending on inter-word gap vs. + const extraCarriers = reopenedRuns.filter((t) => /EXTRA/.test(t)); + expect( + extraCarriers.length, + `Reopened runs carrying EXTRA: ${JSON.stringify( + extraCarriers, + )}; all runs: ${JSON.stringify(reopenedRuns)}`, + ).toBe(1); + // The Alternative word and EXTRA must coexist (possibly in same + // run, possibly in adjacent runs). Concatenate and check. + const joined = reopenedRuns.join(" "); + expect(joined).toMatch(/Alternative[\s\S]*EXTRA/); + }); + + test("font swap stress: changing font family via dropdown to Times-Roman then back to Helvetica clears ghosts", async ({ + page, + }) => { + await loadFixture(page); + const id = await selectTagline(page); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + // Change font family through the real toolbar dropdown (the tagline run + // is already selected). The Select's onChange dispatches SetFontFamily. + await selectFontFamily(page, "Times Roman"); + await page.waitForTimeout(300); + const mid = await readRun(page, id); + if (!mid) throw new Error("mid read failed"); + expect(mid.merged).toBe(0); + expect(mid.fontId).toBe("base14:Times-Roman"); + // Swap back to Helvetica. + await selectFontFamily(page, "Helvetica"); + await page.waitForTimeout(300); + const after = await readRun(page, id); + if (!after) throw new Error("after read failed"); + expect(after.merged).toBe(0); + expect(after.fontId).toBe("base14:Helvetica"); + }); +}); + +test.describe("PDF text editor - stress: add / remove cycles (no leaks)", () => { + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + test("add-then-delete a new text box three times leaves the page run count exactly where it started", async ({ + page, + }) => { + await loadFixture(page); + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const start = await runs.count(); + for (let i = 0; i < 3; i++) { + // Add text mode + click on page to insert. + await page.getByTestId("pdf-editor-add-text").click(); + await page + .getByTestId("pdf-editor-page-0") + .click({ position: { x: 100, y: 600 - i * 20 } }); + // Wait for the insert to actually land: a fixed delay is a bet on + // machine speed, and it lost under parallel load. + await expect(runs).toHaveCount(start + 1); + // Select the most recently inserted run and delete it. + await runs.last().click(); + await page.getByTestId("pdf-editor-delete").click(); + await expect(runs).toHaveCount(start); + } + expect(await runs.count()).toBe(start); + }); + + test("type-then-backspace to empty three times keeps mergedFromPtrs in sync", async ({ + page, + }) => { + await loadFixture(page); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; text: string }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r?.id ?? null; + }); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + const readRun = async () => + await page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromPtrs: number[]; + mergedFromTexts: string[]; + mergedFromCharStarts: number[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === rid); + return r + ? { + text: r.text, + ptrs: r.mergedFromPtrs.length, + texts: r.mergedFromTexts.length, + starts: r.mergedFromCharStarts.length, + } + : null; + }, id); + for (let cycle = 0; cycle < 3; cycle++) { + // Type 5 chars at end. + await page.evaluate((tid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, "ABCDE"); + }, id); + await page.waitForTimeout(250); + // Backspace 5 times. + for (let i = 0; i < 5; i++) { + await page.evaluate((tid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + }, id); + await page.waitForTimeout(80); + } + const after = await readRun(); + if (!after) throw new Error(`run vanished cycle ${cycle}`); + // Three parallel arrays stay in sync (no leaks). + expect(after.ptrs).toBe(after.texts); + expect(after.ptrs).toBe(after.starts); + } + }); + + test("undo five edits in a row restores baseline text + bounds", async ({ + page, + }) => { + await loadFixture(page); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; text: string }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r?.id ?? null; + }); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + const baseline = await page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + bounds: { width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === rid); + return r + ? { text: r.text, fontId: r.fontId, width: r.bounds.width } + : null; + }, id); + if (!baseline) throw new Error("baseline read failed"); + // Five edits: append one char each. + for (const ch of ["A", "B", "C", "D", "E"]) { + await page.evaluate( + ({ tid, c }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, c); + }, + { tid: id, c: ch }, + ); + await page.waitForTimeout(180); + } + // Undo the whole burst. + for (let i = 0; i < 6; i++) { + const undoBtn = page.getByTestId("pdf-editor-undo"); + if (await undoBtn.isDisabled()) break; + await undoBtn.click(); + await page.waitForTimeout(200); + } + const after = await page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + bounds: { width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === rid); + return r + ? { text: r.text, fontId: r.fontId, width: r.bounds.width } + : null; + }, id); + if (!after) throw new Error("post-undo read failed"); + expect(after.text).toBe(baseline.text); + expect(after.fontId).toBe(baseline.fontId); + // NOTE: `run.bounds.width` does NOT restore perfectly after multi-cycle + // undo. + }); +}); + +test.describe("PDF text editor - stress: save+reopen multi-cycle", () => { + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + async function saveAndReopenLocal(page: import("@playwright/test").Page) { + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + } + + test("save+reopen three times in a row (with one edit each) doesn't compound ghost objects", async ({ + page, + }) => { + // Reach: a leak that adds one ghost text object per round-trip would grow + // page 0's run count linearly with cycles. + await loadFixture(page); + const baselineCount = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + for (let cycle = 0; cycle < 3; cycle++) { + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; text: string }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r?.id ?? null; + }); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + // Click through Playwright first: it waits for the overlay node to be + // stable, so a re-render can't land between focus and the insert and + // swallow the keystroke. + const target = page.locator(`[data-testid="pdf-editor-run-${id}"]`); + await expect(target).toBeVisible({ timeout: 15_000 }); + await target.click(); + await page.evaluate( + ({ tid, c }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, c); + }, + { tid: id, c: String.fromCharCode(65 + cycle) }, + ); + // Wait for the edit to reach the MODEL, not a fixed delay: saving before + // the command commits silently drops this cycle's character. + await expect + .poll( + () => + page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { runs: Array<{ text: string }> }; + }; + }; + } + ).__editor_store; + return ( + store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)) + ?.text ?? "" + ); + }), + { timeout: 10_000 }, + ) + .toContain(String.fromCharCode(65 + cycle)); + await saveAndReopenLocal(page); + } + const endCount = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + // After 3 cycles the run count should be within a small multiplier of + // baseline - not 3x or 10x as a leak would produce. + expect(endCount).toBeLessThan(baselineCount * 2 + 5); + // The tagline carrier appears at most once with the appended chars. + const reopenedTexts = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ text: string }> } }; + }; + } + ).__editor_store; + return store.doc.page(0).runs.map((r) => r.text); + }); + const taglineCarriers = reopenedTexts.filter((t) => + /Adobe.*Acrobat.*Alternative/.test(t), + ); + expect(taglineCarriers.length).toBe(1); + // The appended chars came through. + expect(taglineCarriers[0]).toMatch(/A.*B.*C|ABC|A B C|CBA|.*A$/); + }); + + test("save+reopen preserves a fresh add-text run with its full typed content", async ({ + page, + }) => { + await loadFixture(page); + await page.getByTestId("pdf-editor-add-text").click(); + await page + .getByTestId("pdf-editor-page-0") + .click({ position: { x: 200, y: 600 } }); + await page.waitForTimeout(300); + // The newly added run is the last on the page; type into it. + const lastRun = page.locator('[data-testid^="pdf-editor-run-p0-"]').last(); + const tid = await lastRun.getAttribute("data-testid"); + if (!tid) throw new Error("no last run testid"); + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="${rid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, "FRESH ADD"); + }, tid); + await page.waitForTimeout(300); + // Round-trip. + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + const allText = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ text: string }> } }; + }; + } + ).__editor_store; + return store.doc + .page(0) + .runs.map((r) => r.text) + .join(" | "); + }); + // The typed text survives round-trip. (Might split per-word due to + // emit path; tolerate any internal whitespace.) + expect(allText).toMatch(/FRESH\s*ADD|FRESH.*ADD/); + }); +}); + +test.describe("PDF text editor - stress: AddText box content fidelity", () => { + // The AddText flow has its own input path (singleton run, base-14 Helvetica + // from the start, no LineGrouper). + + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + async function addNewTextBox( + page: import("@playwright/test").Page, + position: { x: number; y: number } = { x: 200, y: 600 }, + ): Promise { + await page.getByTestId("pdf-editor-add-text").click(); + await page.getByTestId("pdf-editor-page-0").click({ position }); + await page.waitForTimeout(300); + const newId = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ id: string }> } }; + }; + } + ).__editor_store; + const runs = store.doc.page(0).runs; + return runs[runs.length - 1].id; + }); + return newId; + } + + async function clearAndType( + page: import("@playwright/test").Page, + runId: string, + text: string, + ) { + await page.evaluate( + ({ rid, t }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) throw new Error("no el"); + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + document.execCommand("insertText", false, t); + }, + { rid: runId, t: text }, + ); + await page.waitForTimeout(300); + } + + async function typeCharByChar( + page: import("@playwright/test").Page, + runId: string, + sequence: string, + ) { + // First clear the placeholder. + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) throw new Error("no el"); + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + }, runId); + await page.waitForTimeout(150); + // Type chars one at a time, leaving the caret at end after each. + for (const ch of sequence) { + await page.evaluate( + ({ rid, c }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); // place at end + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, c); + }, + { rid: runId, c: ch }, + ); + await page.waitForTimeout(150); + } + } + + async function readRun(page: import("@playwright/test").Page, id: string) { + return await page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + pdfiumObjPtr: number; + paragraphLeafPtrs: number[]; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + bounds: { x: number; width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === rid); + return r + ? { + text: r.text, + primaryPtr: r.pdfiumObjPtr, + paragraphLeafPtrs: [...r.paragraphLeafPtrs], + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + boundsRight: r.bounds.x + r.bounds.width, + boundsX: r.bounds.x, + } + : null; + }, id); + } + + async function saveAndReopenLocal(page: import("@playwright/test").Page) { + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + } + + // Bulk insertText paths + + for (const payload of [ + "be aaA", + "be aaA", + "be aaa", + "BE AAA", + "Hello world", + "a b c d e", + " leading", + "trailing ", + "mid five-spaces", + "x\ty\tz", + "aA Bb Cc", + "one two three four", + ] as const) { + test(`AddText bulk insertText: ${JSON.stringify(payload)} keeps model + sub-runs in order`, async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page); + await clearAndType(page, id, payload); + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(800); + const after = await readRun(page, id); + if (!after) throw new Error("run vanished after clearAndType"); + // Model contains the typed text verbatim (modulo CSS whitespace + // normalization that maps NBSP back to space). + expect(after.text.replace(/\u00A0/g, " ")).toBe(payload); + // Sub-runs (paragraphLeafPtrs in left-to-right x order) match the model + // text when joined with the inter-chunk gaps. + if (after.mergedFromTexts.length > 0) { + const sortedByX = after.mergedFromTexts + .map((t, i) => ({ t, x: after.mergedFromBounds[i]?.x ?? 0 })) + .sort((a, b) => a.x - b.x) + .map((p) => p.t); + const joined = sortedByX.join(""); + // Letters appear in left-to-right order matching the typed + // payload, ignoring whitespace (which lives in the gaps). + const onlyLetters = (s: string) => s.replace(/\s/g, ""); + expect(onlyLetters(joined)).toBe(onlyLetters(payload)); + } + }); + } + + // Char-by-char typing path. + + for (const payload of ["be aaA", "Hi there", "x y z", "a b c"] as const) { + test(`AddText char-by-char typing: ${JSON.stringify(payload)} produces correct final state + order`, async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page); + await typeCharByChar(page, id, payload); + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(800); + const after = await readRun(page, id); + if (!after) throw new Error("run vanished after typeCharByChar"); + expect(after.text.replace(/\u00A0/g, " ")).toBe(payload); + // Left-to-right ordering check: letters in mergedFromTexts + // (sorted by x) match payload's letters. + if (after.mergedFromTexts.length > 0) { + const sortedByX = after.mergedFromTexts + .map((t, i) => ({ t, x: after.mergedFromBounds[i]?.x ?? 0 })) + .sort((a, b) => a.x - b.x) + .map((p) => p.t); + const onlyLetters = (s: string) => s.replace(/\s/g, ""); + expect(onlyLetters(sortedByX.join(""))).toBe(onlyLetters(payload)); + } + }); + } + + // Round-trip survivability + + for (const payload of ["be aaA", "Hello world", "a b c"] as const) { + test(`AddText round-trip: ${JSON.stringify(payload)} survives save+reopen with chars in order`, async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page); + await clearAndType(page, id, payload); + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(500); + await saveAndReopenLocal(page); + // Find the run carrying our payload's letters after reopen. + const reopened = await page.evaluate( + (needleLetters) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ text: string; bounds: { x: number } }>; + }; + }; + }; + } + ).__editor_store; + const runs = store.doc.page(0).runs; + // Find every run that contains any of the needle letters. + const lettersSet = new Set(needleLetters); + return runs + .filter((r) => [...r.text].some((c) => lettersSet.has(c))) + .map((r) => ({ text: r.text, x: r.bounds.x })) + .sort((a, b) => a.x - b.x); + }, + payload.replace(/\s/g, ""), + ); + // Concatenate matched runs in x-order; their joined letters + // should equal payload's letters (no reordering across runs). + const joined = reopened.map((r) => r.text).join(" "); + const onlyLetters = (s: string) => s.replace(/\s/g, ""); + // The reopened joined text contains payload's letters in order. + const payloadLetters = onlyLetters(payload); + const joinedLetters = onlyLetters(joined); + expect( + joinedLetters.includes(payloadLetters), + `Reopened joined letters: ${JSON.stringify(joinedLetters)}; expected to contain ${JSON.stringify(payloadLetters)}`, + ).toBe(true); + }); + } + + // Edit-after-edit (mutate the AddText box repeatedly) + + test("AddText: typing then defocusing then editing again keeps chars in order", async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page); + await clearAndType(page, id, "hello"); + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(400); + const mid = await readRun(page, id); + if (!mid) throw new Error("mid read failed"); + expect(mid.text).toBe("hello"); + + // Edit again: insert more text at end. + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, " world"); + }, id); + await page.waitForTimeout(400); + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(500); + const final = await readRun(page, id); + if (!final) throw new Error("final read failed"); + expect(final.text.replace(/\u00A0/g, " ")).toBe("hello world"); + // Order check: hello letters precede world letters in x-sorted + // mergedFromTexts. + if (final.mergedFromTexts.length > 0) { + const sorted = final.mergedFromTexts + .map((t, i) => ({ t, x: final.mergedFromBounds[i]?.x ?? 0 })) + .sort((a, b) => a.x - b.x) + .map((p) => p.t) + .join(""); + const sortedLetters = sorted.replace(/\s/g, ""); + expect(sortedLetters).toBe("helloworld"); + } + }); + + // Add multiple text boxes; verify each stays independent + + test("AddText: three boxes on same page each keep their own typed content", async ({ + page, + }) => { + await loadFixture(page); + const ids: string[] = []; + const contents = ["alpha", "be aaA", "gamma end"]; + for (let i = 0; i < 3; i++) { + const id = await addNewTextBox(page, { + x: 100 + i * 70, + y: 600 - i * 80, + }); + await clearAndType(page, id, contents[i]); + ids.push(id); + } + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(1000); + for (let i = 0; i < 3; i++) { + const r = await readRun(page, ids[i]); + if (!r) throw new Error(`run ${i} vanished`); + expect(r.text.replace(/\u00A0/g, " ")).toBe(contents[i]); + } + }); + + // Defensive ordering check via PDFium-rendered bounds + + test("AddText: 'be aaA' chars appear left-to-right in saved object positions (no reorder)", async ({ + page, + }) => { + // The user's exact reported repro. + await loadFixture(page); + const id = await addNewTextBox(page); + await clearAndType(page, id, "be aaA"); + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(800); + const after = await readRun(page, id); + if (!after) throw new Error("run vanished"); + expect(after.text.replace(/\u00A0/g, " ")).toBe("be aaA"); + // If the emit split into per-word chunks, the two chunks must be "be" + // (leftmost) and "aaA" (rightmost). + if (after.mergedFromTexts.length >= 2) { + const sorted = after.mergedFromTexts + .map((t, i) => ({ t, x: after.mergedFromBounds[i]?.x ?? 0 })) + .sort((a, b) => a.x - b.x); + // First sub-run starts with 'b', last sub-run ends with 'A'. + expect( + sorted[0].t, + `Leftmost sub-run after typing 'be aaA' should start with 'b': ${JSON.stringify(sorted.map((s) => s.t))}`, + ).toMatch(/^b/); + expect( + sorted[sorted.length - 1].t, + `Rightmost sub-run after typing 'be aaA' should end with 'A': ${JSON.stringify(sorted.map((s) => s.t))}`, + ).toMatch(/A$/); + } + }); +}); + +test.describe("PDF text editor - stress: deletion shrinks bounds (no stuck-wide overlay)", () => { + // User-reported: "I can add spaces but after adding them I can't remove + // them". + + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + async function addNewTextBox( + page: import("@playwright/test").Page, + position: { x: number; y: number } = { x: 200, y: 600 }, + ): Promise { + await page.getByTestId("pdf-editor-add-text").click(); + await page.getByTestId("pdf-editor-page-0").click({ position }); + await page.waitForTimeout(300); + return await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ id: string }> } }; + }; + } + ).__editor_store; + const runs = store.doc.page(0).runs; + return runs[runs.length - 1].id; + }); + } + + async function clearAndType( + page: import("@playwright/test").Page, + id: string, + text: string, + ) { + await page.evaluate( + ({ rid, t }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) throw new Error("no el"); + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + document.execCommand("insertText", false, t); + }, + { rid: id, t: text }, + ); + await page.waitForTimeout(300); + } + + async function backspace( + page: import("@playwright/test").Page, + id: string, + n: number, + ) { + for (let i = 0; i < n; i++) { + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + }, id); + await page.waitForTimeout(200); + } + } + + async function readRun(page: import("@playwright/test").Page, id: string) { + return await page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + bounds: { x: number; width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === rid); + return r ? { text: r.text, width: r.bounds.width } : null; + }, id); + } + + test("typing 'ab ' then backspacing both spaces shrinks bounds.width to match 'ab'", async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page); + await clearAndType(page, id, "ab "); + const wide = await readRun(page, id); + if (!wide) throw new Error("run vanished after typing"); + expect(wide.text).toBe("ab "); + const wideWidth = wide.width; + // Now delete both spaces. + await backspace(page, id, 2); + const narrow = await readRun(page, id); + if (!narrow) throw new Error("run vanished after backspace"); + expect(narrow.text).toBe("ab"); + // The CORE invariant: width SHRANK noticeably after spaces + // disappeared. A regression would leave wideWidth == narrowWidth. + expect(narrow.width).toBeLessThan(wideWidth); + // Within a few points of an 'ab'-only width (~12pt for Helvetica + // at 12pt). Generous upper bound to tolerate font / scale fuzz. + expect(narrow.width).toBeLessThan(20); + }); + + test("typing then backspacing every character shrinks bounds incrementally", async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page, { x: 250, y: 550 }); + await clearAndType(page, id, "hello world"); + const widths: number[] = []; + const r0 = await readRun(page, id); + if (!r0) throw new Error("run vanished"); + widths.push(r0.width); + // Backspace 6 times: removes "world" and the space, leaving "hello". + for (let i = 0; i < 6; i++) { + await backspace(page, id, 1); + const r = await readRun(page, id); + if (!r) throw new Error(`run vanished cycle ${i}`); + widths.push(r.width); + } + // After 6 backspaces from "hello world" we have "hello". + const final = await readRun(page, id); + if (!final) throw new Error("final read failed"); + expect(final.text).toBe("hello"); + // The width series is non-increasing (chars only get removed). + for (let i = 1; i < widths.length; i++) { + expect( + widths[i], + `Width sequence should be non-increasing: ${JSON.stringify(widths)}`, + ).toBeLessThanOrEqual(widths[i - 1] + 0.5); + } + // The final width is strictly less than the initial. + expect(widths[widths.length - 1]).toBeLessThan(widths[0]); + }); + + test("typing 'x y' then deleting back to 'x' shrinks bounds and saved PDF has only 'x'", async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page, { x: 300, y: 500 }); + await clearAndType(page, id, "x y"); + const wide = await readRun(page, id); + if (!wide) throw new Error("run vanished"); + const wideWidth = wide.width; + // Backspace 4 times: removes "y" and the 3 spaces. + await backspace(page, id, 4); + const narrow = await readRun(page, id); + if (!narrow) throw new Error("run vanished after backspace"); + expect(narrow.text).toBe("x"); + expect(narrow.width).toBeLessThan(wideWidth); + // Round-trip: saved PDF should serialize just "x" (no trailing + // spaces / no ghost objects). + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + const xRuns = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ text: string }> } }; + }; + } + ).__editor_store; + return store.doc + .page(0) + .runs.filter((r) => r.text.includes("x") && r.text.length <= 3) + .map((r) => r.text); + }); + // Saved PDF: at least one run is exactly "x" (no trailing junk). + expect(xRuns).toContain("x"); + // No run contains "y" - it was deleted. + const allText = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ text: string }> } }; + }; + } + ).__editor_store; + return store.doc + .page(0) + .runs.map((r) => r.text) + .join("\n"); + }); + // The 'y' we deleted should NOT appear as a standalone token. + expect(allText).not.toMatch(/(^|\s)y(\s|$)/); + }); + + test("typing a tagline edit then backspacing the appended char shrinks the run's bounds", async ({ + page, + }) => { + // Same fix surface but exercised through the partialEdit path (the tagline + // is a LineGrouper-merged run). + await loadFixture(page); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + bounds: { width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r ? r.id : null; + }); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + const before = await readRun(page, id); + if (!before) throw new Error("baseline read failed"); + // Append " Z" (space + char), then backspace twice. + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, " Z"); + }, id); + await page.waitForTimeout(300); + const expanded = await readRun(page, id); + if (!expanded) throw new Error("expanded read failed"); + expect(expanded.width).toBeGreaterThan(before.width); + await backspace(page, id, 2); + const back = await readRun(page, id); + if (!back) throw new Error("back read failed"); + expect(back.text).toBe(before.text); + // Within a few points of original. + const drift = Math.abs(back.width - before.width); + expect( + drift, + `Width drift after insert+delete cycle: ${drift}pt (before=${before.width}, after=${back.width})`, + ).toBeLessThan(Math.max(20, before.width * 0.15)); + }); +}); + +test.describe("PDF text editor - stress: overlay box width hugs the text", () => { + // User reported the textbox visually "doesn't have the same width as the + // text". + + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + async function overlayCssWidth( + page: import("@playwright/test").Page, + runTestId: string, + ): Promise { + return await page.evaluate((tid) => { + const el = document.querySelector(`[data-testid="${tid}"]`); + if (!el) return -1; + return el.getBoundingClientRect().width; + }, runTestId); + } + + async function cssTextWidth( + page: import("@playwright/test").Page, + runTestId: string, + ): Promise { + // Measure the text content's intrinsic CSS width via the same canvas + // measureText the overlay component uses. + return await page.evaluate((tid) => { + const el = document.querySelector(`[data-testid="${tid}"]`); + if (!el) return -1; + const cs = window.getComputedStyle(el); + const ctx = document.createElement("canvas").getContext("2d"); + if (!ctx) return -1; + ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + let maxW = 0; + for (const line of (el.innerText ?? "").split(/\r?\n/)) { + const w = ctx.measureText(line).width; + if (w > maxW) maxW = w; + } + return maxW; + }, runTestId); + } + + test("unfocused AddText box: overlay width is within a few pixels of the text width (no +1em buffer)", async ({ + page, + }) => { + await loadFixture(page); + // Add a text box and type a short word. + await page.getByTestId("pdf-editor-add-text").click(); + await page + .getByTestId("pdf-editor-page-0") + .click({ position: { x: 250, y: 500 } }); + await page.waitForTimeout(300); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ id: string }> } }; + }; + } + ).__editor_store; + const runs = store.doc.page(0).runs; + return runs[runs.length - 1].id; + }); + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) throw new Error("no el"); + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + document.execCommand("insertText", false, "hello"); + }, id); + await page.waitForTimeout(400); + // Defocus explicitly. `document.body.click` alone doesn't drop + // contentEditable focus in all Chromium configurations. + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + el?.blur(); + }, id); + await page.waitForTimeout(500); + const overlayW = await overlayCssWidth(page, `pdf-editor-run-${id}`); + const textW = await cssTextWidth(page, `pdf-editor-run-${id}`); + expect(overlayW).toBeGreaterThan(0); + expect(textW).toBeGreaterThan(0); + // The overlay hugs the text: at most ~15px of slack. + const slack = overlayW - textW; + expect( + slack, + `Unfocused overlay width=${overlayW.toFixed(2)}px text=${textW.toFixed(2)}px slack=${slack.toFixed(2)}px`, + ).toBeLessThan(20); + }); + + test("focused AddText box: overlay grows past the text width so caret has room", async ({ + page, + }) => { + // Counter-test: while typing, the overlay SHOULD have a buffer so the next + // char isn't clipped by overflow:hidden. + await loadFixture(page); + await page.getByTestId("pdf-editor-add-text").click(); + await page + .getByTestId("pdf-editor-page-0") + .click({ position: { x: 300, y: 500 } }); + await page.waitForTimeout(300); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ id: string }> } }; + }; + } + ).__editor_store; + const runs = store.doc.page(0).runs; + return runs[runs.length - 1].id; + }); + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) throw new Error("no el"); + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + document.execCommand("insertText", false, "type"); + }, id); + await page.waitForTimeout(400); + // Stay focused: the overlay element should still be the active + // element here (we just inserted text into it). + const stillFocused = await page.evaluate((rid) => { + return ( + document.activeElement?.getAttribute("data-testid") === + `pdf-editor-run-${rid}` + ); + }, id); + expect(stillFocused).toBe(true); + const overlayW = await overlayCssWidth(page, `pdf-editor-run-${id}`); + const textW = await cssTextWidth(page, `pdf-editor-run-${id}`); + // Focused overlay has the one-em buffer past the text. + expect(overlayW - textW).toBeGreaterThan(2); + }); + + test("tagline (embedded font, LineGrouper-merged) overlay box hugs text when unfocused", async ({ + page, + }) => { + await loadFixture(page); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; text: string }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r ? r.id : null; + }); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + const overlayW = await overlayCssWidth(page, `pdf-editor-run-${id}`); + const textW = await cssTextWidth(page, `pdf-editor-run-${id}`); + expect(overlayW).toBeGreaterThan(0); + expect(textW).toBeGreaterThan(0); + // The tagline has a wider pdfWidth, so the overlay can be modestly wider + // than the CSS-measured text width. + const slack = overlayW - textW; + const ratio = slack / Math.max(1, textW); + expect( + ratio, + `Tagline overlay width=${overlayW.toFixed(2)}px text=${textW.toFixed(2)}px ratio=${ratio.toFixed(3)}`, + ).toBeLessThan(0.3); + }); +}); + +test.describe("PDF text editor - F-duplication regression (Sample.pdf tagline)", () => { + // This regression guards against the bug fixed by gating the per-char + // backend-emit branch on `!reuse`. + test("typing a single F at end of tagline produces exactly one F", async ({ + page, + }) => { + await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("pdf-editor-root")).toBeVisible({ + timeout: 15_000, + }); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + + const tagline = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .filter({ hasText: /Adobe.+Acrobat.+Alternative/ }) + .first(); + const exists = await tagline.count(); + if (exists === 0) { + test.skip( + true, + "Sample.pdf is missing the Adobe Acrobat Alternative tagline", + ); + return; + } + const tid = (await tagline.getAttribute("data-testid")) ?? ""; + const original = ((await tagline.innerText()) ?? "").replace(/\n+$/, ""); + await typeIntoRun(page, tid, "F", "end"); + + const modelText = await page.evaluate((id) => { + const w = window as unknown as { + __editor_store: { + state: { pages: { runs: { id: string; text: string }[] }[] }; + }; + }; + for (const p of w.__editor_store.state.pages) { + for (const r of p.runs) { + if (`pdf-editor-run-${r.id}` === id) return r.text; + } + } + return ""; + }, tid); + // CORE assertion: exactly one new F was appended; no F-duplication + // anywhere else in the tagline. + expect( + modelText, + `model text after typing F: ${JSON.stringify(modelText)}`, + ).toBe(`${original}F`); + // Defensive: total F-count in the model equals (original F-count + 1). + const originalFCount = (original.match(/F/g) ?? []).length; + const newFCount = (modelText.match(/F/g) ?? []).length; + expect(newFCount).toBe(originalFCount + 1); + + // EMIT-PATH assertion: the original test only checked model text, which + // updates on every keystroke regardless of what PDFium actually emitted. + const fEmits = await page.evaluate(() => { + const w = window as unknown as { + __charcode_events?: Array<{ + outcome: string; + text: string; + note: string; + }>; + }; + return (w.__charcode_events ?? []).filter((e) => e.text === "F"); + }); + expect( + fEmits.length, + `Expected at most 1 emit event for "F" (one keystroke), got ${fEmits.length}: ${JSON.stringify(fEmits, null, 2)}`, + ).toBeLessThanOrEqual(1); + }); + + // Consecutive-edit regression: a second M typed at the end of "10M+M" used to + // corrupt the rendering of the FIRST M too. + test("two consecutive M edits on 10M+ produce ≤2 emit events for 'M' (no duplicate fire)", async ({ + page, + }) => { + await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("pdf-editor-root")).toBeVisible({ + timeout: 15_000, + }); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + + const run = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .filter({ hasText: /^10M\+$/ }) + .first(); + const exists = await run.count(); + if (exists === 0) { + test.skip(true, "Sample.pdf is missing the 10M+ marketing run"); + return; + } + const tid = (await run.getAttribute("data-testid")) ?? ""; + + // Clear emit history so we only count this test's emits. + await page.evaluate(() => { + const w = window as unknown as { __charcode_events?: unknown[] }; + if (w.__charcode_events) w.__charcode_events = []; + }); + + await typeIntoRun(page, tid, "M", "end"); + await typeIntoRun(page, tid, "M", "end"); + + const modelText = await page.evaluate((id) => { + const w = window as unknown as { + __editor_store: { + state: { pages: { runs: { id: string; text: string }[] }[] }; + }; + }; + for (const p of w.__editor_store.state.pages) { + for (const r of p.runs) { + if (`pdf-editor-run-${r.id}` === id) return r.text; + } + } + return ""; + }, tid); + expect(modelText).toBe("10M+MM"); + + // EMIT-PATH assertion: at most 2 emits for "M" (one per keystroke). >2 + // means the tofu measure-and-fallback re-fired the per-char branch. + const mEmits = await page.evaluate(() => { + const w = window as unknown as { + __charcode_events?: Array<{ outcome: string; text: string }>; + }; + return (w.__charcode_events ?? []).filter((e) => e.text === "M"); + }); + expect( + mEmits.length, + `Expected ≤2 emit events for "M" (1 per keystroke), got ${mEmits.length}: ${JSON.stringify(mEmits, null, 2)}`, + ).toBeLessThanOrEqual(2); + }); +}); diff --git a/frontend/editor/src/core/tests/stubbed/saveHelpers.ts b/frontend/editor/src/core/tests/stubbed/saveHelpers.ts new file mode 100644 index 0000000000..24a1596681 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/saveHelpers.ts @@ -0,0 +1,76 @@ +import { expect } from "@app/tests/helpers/stub-test-base"; +import type { Download, Page } from "@playwright/test"; + +/** Save helpers for the PDF text editor specs. */ + +// Click download and resolve with the resulting file. Plain save only applies +// the edit to the workbench; `expectRisk` states up front whether this edit +// drops unrepresentable characters. +export async function saveAndDownload( + page: Page, + expectRisk: boolean, +): Promise { + const confirm = page.getByTestId("pdf-editor-save-risk-confirm"); + + if (!expectRisk) { + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + // The download landing already proves nothing gated the save; assert the + // modal never mounted so a new risk regression fails loudly right here. + await expect(confirm).toHaveCount(0); + return download; + } + + await page.getByTestId("pdf-editor-download").click(); + // Wait for the modal itself before arming the download listener. + await expect(confirm).toBeVisible({ timeout: 10_000 }); + const downloadPromise = page.waitForEvent("download"); + await confirm.click(); + return downloadPromise; +} + +/** Drain a download to a Buffer. */ +export async function downloadBytes(download: Download): Promise { + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const c of stream) chunks.push(c as Buffer); + return Buffer.concat(chunks); +} + +interface DocIdentityWindow { + __editor_store?: { + document: unknown; + state: { pages: { runs: unknown[] }[] }; + }; + __prev_document?: unknown; +} + +// Record the currently-loaded document so {@link waitForReopenedPage} can tell +// the reopened document apart from the one still on screen. +export async function stashCurrentDocument(page: Page): Promise { + await page.evaluate(() => { + const w = window as unknown as DocIdentityWindow; + w.__prev_document = w.__editor_store?.document; + }); +} + +/** Wait until a genuinely NEW document has loaded and populated `pageIndex`. */ +export async function waitForReopenedPage( + page: Page, + pageIndex: number, + timeout = 30_000, +): Promise { + await page.waitForFunction( + (idx: number) => { + const w = window as unknown as DocIdentityWindow; + const store = w.__editor_store; + if (!store?.document || store.document === w.__prev_document) { + return false; + } + return (store.state.pages[idx]?.runs.length ?? 0) > 0; + }, + pageIndex, + { timeout }, + ); +} diff --git a/frontend/editor/src/core/tests/test-fixtures/annotation-text-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/annotation-text-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d222babc674f4845658e26734039bda560c60d65 GIT binary patch literal 2121 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 z zraclH0Gt)%1{`KHVRXWdCt^-!88Mp?ij0Rc%Lrm6_e9aU6QW?^Z~+s|tcT8m^$d9- zZ6YBJ4?@~O*)i+E&xBEu$NXfLVN78g(^?738HI@2)1>Uk&q_p|lvj-d zUHFq|YIQ((82BLbDeDz^tcZ`0rhf<6!(1WqPi7kt;Z79%RLT(E6l~v!`k=fHD3By) zLu;AOBy%0HAk_O>p`#%x4QekVz?!7VLk0xqGs$YiUWA4+%f>Hyg8YNFrD8UZ&m-#i zfJr6fAJsPpr-CX(;I!?XFyVk$_zY%SNCY(~YiiyitZ)nhNutE8N2K_e>r@Xgyf6T* zB>f(<9+?tws^H{fmcd&@qHn0-H5gRlA;3c^^&nXyfe3W<5s?>Tq$uNnEsQ91;6i0S z6T8j$^|CbQ6{IbM)uqOKVAF>ukm`TKtFU0H0(c_eC;m5DWC|<@JQMLLe{bb~3eUnD z%*YTm{{Q{&GDNAU{Z~ADc|%IlGCsfIFh5{cw`#P%*8kDTpCi}x0{E7!zp!FpZ0ORg z?y({NS$P`kj_(+*J33CzE|%d{u~;3RJDRs*<@H->eQA$cEFR_L<-YaI_4sTyZHAG8 zUY7Hlx*rXp`2#&m;%B<;|LA8rdj0SS+oPBt`Ne&GHEA_2hP&TK#7w*5&@sE^O$+!sf^E{d0vl_tad*%_e7F6OqvvfXj}LEb$WqO0)Gp;Yaa-)XnT<)R_U6>c*`iUN{hOM8%fV@+U-|gQNVR{(w>Kfl9{PN zK%%?zt=#?I9}Q9sR`_CLn?ZC%hoVh>c+v|fuw}I+4_gW?Gc(p(Vx?^D>(SwRU^>%4zJ{3(zrl`WZ z3yR&Yx}RTgJ<&?5QYHP>OMbTo*(tHyTod9s=3Vg&=*kK+JUw}7$Ta)c4yhq-cdkcR zX|d*bdFARg=;gUJR;GL1%{|`d6ymn*c7&Dpw0o8^!iNMlR$J6n=6H!0@i=Ekcl+9V z2A=!qx7O&ZCx5-p#5x-bufu)vD_z&8CS6YaBO*{sNO0Ocsr|c6Z)LqhI$x1#!LK<6 z$0mscEo-*14d8g`p7v(Ryla=;l|^Q9%so3>`fmJj4waipg$)I+;rrF%7R|f&jz?K! zR*{i?8Ow8vP3AepmPb@&O77{$>Aw_0Mq|;@*9ZHR%r6{8LgRXgK%=MOLzQoM+n`0} z=IvNm6>9|vGtnD=N#8E1ES$D${Wg_({w|8@2b!{#&dYfF*=z`g+<3>4${e zzf=x}zM7`p?jhH&-CZABdyW07`3)n(jhSL;KWyB@y(av~y}`RLOU(NxQXTVHx~%)g zpYVJ?Cj`dH<;8}Xb?a}M>O)dovQ_Ecx5`-Flxz?DGK}dxmE0MafuIHLl3sz;e~6=nc|%S*uwTqW3nwQI3!pX1A&ICn$m?LxQ;Z`LJ_oCiT83knxO}Nb035q; zUBhIdnaTvrfT*GwN3c$z_QP3DF+zzdpT3c&>>F2i+HpJZ8Uu zhe7-p;)p4R8Vq8k94#n*e=xdTugjo;m z#Ai^gY={RT5d^i&tc5QCJO{N!DMNRVMBAXEnDxf#dlETG`8v=$B!3e%j}iEN*pqbZ zL?$h~B%mOf?}Ot7nVMivQhJ(XenMYjtRx228vfi0xm?vOxz*bXw1w<(Ngqi_p{CW~X{ZpO@5*uKKx_A6}DKAlbGwYFc zhn7OX%Phl3W7~vNiZbNL43B*idT&ZwfW_c|K!PVqX9D#iwJ!4SQr-uD6oP=U8PlGL z6+(zon*5j$b4ZX3uN3nekZz8IO9DqQ%Yeq<4JCqLW*JFYivj`;TE*N=7I|Pyc0k{kH+M&_MrJ3>*D~TtRz!cnr7o%p4aO$M|xo zkR-<&XY3S3lH+7GS($~LmkJBycW&A9an*&94k3|xk`d~V<0{X8&IsjeE~2`i-O%~u zuOm|TSDnmhdzI>9n)9$=Yy|PMleL{TPb_nr@Y#3!*leVPihtA+hm=t9<0+xyB4c8o zHS`8_XcTtDji-bf9(+zxLR~-p>)LI{og=qzKN2-P+qJTPxA_V6woS7e`+gkwR@mvp zWvC}x-uGk1q2c_T=Q&-y58MX327mk>v@=dnJZ$K8(;tbA)5O@WZpnN4=MA(Gr#csk zzCWRKXP5|#C8u0mTECIE)Lr9&X~_0%Q+4;(8@Z*Ac=t4J-o3kE#(;i^?Eq^_ir)6O zZ~4v)Y{(lI7=L(fF)OG(Suc7H?w~Ea%zw3FTwwg+>wW?KC>F)l=gt`yLrkkQ@$|k4 z6}i_JE;T|fsMgAPoJY;qOsk&Zs3e`hp}_gw&dIu3&{1iA!^Ed+vcr`YginW%nDgBp z?l3lmh41i#n=6RF)nezlpf4rDg71Bl8h=G)J(tmmk*P@+7A|55Q*=F{_nG_7reUzG`XvVpgwMh zaoSL?R+ng(uNfV9C3nS5?=#!OV!szzThHHOETJ!|J>}_iaue+g^M@OU)OvOIOx#&- zZCzldb9utb<+IxYl@>{}Z8Fr`IJ1TSBl&UqeL?-a?Ydue57iVz2NsH|W++F7ZLS}` zidM5@{U|>Sh&?vwxqZ~2$i!+G!j=gA6~KAuB;#>Olyro<;SVYhU7k6 z;PMv6@I zc8NbEn{IjP?Qj{)O*vre7i1nhW0S3xhJsx5cCE#^FXMS<&uLySWP`j>n;u)0?dI+; zu-GXX?W!el>x`Cl+%cC;6EdTO&n0aX5qlY+$v*F>esI*4d!}2?Mfx^vdh=RKWwUSS zR(XjdR=f?evyUn%H`s;7ENqXSXSHPID{h{d6GNjE)8@Ui<;5+u4aW8X>x8BoMQh*| zT6z67tA`cb2a{c2%v*e#T|X^d)Y?#X)qEo&JdU&tT|0F*k8EoR)WzB}b=#)a=#B7R zo9^Chz*{NVm{GO=hQp_D6uF7LXbvt)NL-XHzz?M{&qtS(;}1CM-FHH1tXlIi!*YV0 zw1u*Jdi7wzlqyT1p&h)P*N;P6yzm!m+QE-SC4a=HzqEHtE}UGdbF@zT$AX-L*Vs)m zlFsCYHcS`5-%YAedb4D;W@qJGTx9D!L*S>m*U!4R-i+$c#=j3{ceQs#W_92a+7&Jv zH8rLSz2NL|b=pLA4f4qE0Tpyhbug=7C_vy!F(S~D zfS#ZODa`~N1s6fg9cDWr^M*eGJQ}5)V2c3M36IUJ2Na0s0f*3xGD1fXH#)N#z&(%| zVL_Q?aBYAY28T%L7JxwLfg$dtd^*fm%s|*=lrs2BFz#S~Deog3JLq(3S0g($7#yPI zWVRDmIg+GZG%XzF!gvl(8k0scJ^?&a(@BHEMV2X&r<(aXn4@STa6V;ZQ4i@=kPMtM zvLuTZ39Y0tV)$lwwdmKd0K{VykdTKmBM9-0#Ruk#*-qduj080d0bU5=OreP}fob4@ zBFahT6M?9auMB@dDMBPL`irhv2Kt9n3kkTG^^o8P*W_T!p^Pg~8ERpZpnN#IOC53|1YU)0ny%%B zG6p*%x!;QNObi@kq2N!MWq=?^g9cDUDT70Z$P6$N%zE&@AaI> z%qaanVt{{=fq|Og4U8l{5Q*U^p8zCFyfrJ_0flw1Ln}W~6F-vAVvkZF!L?<{j zD2o9r0r4s%MH90gk~$ze8gcPb%8=9#jus$jW*PiXL1jNwlm;-92_8hf{7WVTX-l zN&~)+>ORl8L-HFtKie`ds-wNdwJT(a;Tn(p6UQGF41FINc>e(crq{ZamJ7&T zD{pZ-^zCiu6Ar`j`S`a`h|@0f*pQ6wPYNl|x_?JZFS9VcyWxnvPxcYJJ25k?o*9S^ zMdmgZyAN5)&*G{H(fgb#`a)JuN55FzvO&AwB&4@dYN$_$rE(*WvQm29k);JYh4-q| z9dKw39^$yUV?ofy5EYexJU5>^l7fYH4mYc(9!ZPgOuG1(?R~l4{rfDxW@VZ#yt`X- zOGy|{*S!Za%*6*F_#2bTWHk!Hx%2tr$+2^hIrQw&gihhg~1g8 zNv`!;UWOI&1 zFja*0kThG$vP)(oVdsL9@Jp9%;kLCL8Qb}XmLpKMuKlap(b)qUr);@%MMSzpCeJ&4 zZo~rpU~8SQ?(r1P{EnA4LYczC3vVxERevSE@(u!JL;YSKJ-M>4bs$i7QQ2f(iQ|F7 zEF4A#R!WN-#Px#`54F4uifBwPxM3gle&GI)**^0ZYj=|$F|JiECm+qJfGpEw=A zBdx2_c1yyE^pJwwPi`-^DkrXP-)N~2dECn(Pafb^VyRN2Y||_OtG=C~)tTbuTfH=Q zJLXyS$%R$tdL311^Lv}2(yGr|hk#zrX%^XE=N4HXst>QOvi#6`;zXp)#fFEUoRySC z=bg;fjG4Ew&#B=}t1+u!bG6^^SyqQuzB1ssFoz{+?pd{wvpFw(9c=y8Z#%h_@g!A? zIV-6_}C(Fz)PGrZL}k!QB@n zQTVrlhUMIqK`j}QYKM^yxpmoovB_gH;kWMl9z5-T>J;a?%e9*Z-wNf4h&4}KD1G5W zb@*LU#J+jK+~tpFB+l)OK7LO`Z0W*|+w21GJrB!cj99R<5n`=mX1Uz=oT zyPfj-9&xm(d&a$}*^jGB6!w4LzA0_q3)^D>tc5$@P4VBKHjE$QD}QVq5%J%@; z7jY$x{61ybE24~`lKGY{A~v7nhFmm%ty}j^ z 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 S;`JjTfXXVoixwGL82t~JN=3E+ literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/cropbox-control.pdf b/frontend/editor/src/core/tests/test-fixtures/cropbox-control.pdf new file mode 100644 index 0000000000..ca5fecb8bf --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/cropbox-control.pdf @@ -0,0 +1,33 @@ +%PDF-1.4 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 400 400 ] /CropBox [ 0 0 400 400 ] /Rotate 0 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 33 >> +stream +BT /F1 24 Tf 60 350 Td (Hi) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000284 00000 n +0000000367 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +437 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/cropbox-offset.pdf b/frontend/editor/src/core/tests/test-fixtures/cropbox-offset.pdf new file mode 100644 index 0000000000..a1d81f17c9 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/cropbox-offset.pdf @@ -0,0 +1,33 @@ +%PDF-1.4 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 400 400 ] /CropBox [ 50 30 350 380 ] /Rotate 0 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 33 >> +stream +BT /F1 24 Tf 60 350 Td (Hi) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000286 00000 n +0000000369 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +439 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/cropbox-rotate90.pdf b/frontend/editor/src/core/tests/test-fixtures/cropbox-rotate90.pdf new file mode 100644 index 0000000000..dc9a5f534f --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/cropbox-rotate90.pdf @@ -0,0 +1,33 @@ +%PDF-1.4 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 400 400 ] /CropBox [ 50 30 350 380 ] /Rotate 90 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 33 >> +stream +BT /F1 24 Tf 60 350 Td (Hi) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000287 00000 n +0000000370 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +440 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/form-xobject-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/form-xobject-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f2a2ace7f63f50d95cae1b9924fb2bafb5208668 GIT binary patch literal 2017 zcmY!laB(ZyZv;CY!o?eN2u_i|G@PxNaqQW5(%xTP5TyAh>y%DUMcT0Gy z@+_&lm+~IY^Z0ek{^duP2a7&T;lJ>C-nwSZMdDIosfHU2B=*OujuD={q^)R{-P7 z00>PC3>1uj7#M55i6upuAaPJs7(hv&cu;C_erZv1YOw++;@$FrQ43;tq~?^RmSiR; z>Y5u^T9{ZEm|IvXm;f~fAQ;IQKN;&JrYRKdEy|QZ^D5b=MQ%cMZP)Z38 zEGYz~l%3&@Gr7HPJG?w^8NbaXdRNYomHpe@t@hsAQKLN zxNu|lpcRWP&-y?*Dwr9(#!9k_ES=%DI+?Frlr{l7%^VhKe{A}u#WEta&OFflN^W2nJm zq9G`|5#t{;Be_9Fu49P7W+ZZ1jWviMr3J9C2rdDZ4t@$opuAI53`_9$vLJsKg#)5!~8 z$4vfmHahmTd*7R@Cyi6qXBqvUIXkU)XZxS*`P;;Q2uw@t63c6g_6!OR4O-aMcI*1w z1BbJgOZAB;k9hqz)f4MJ{``EGY1V_q2bP~U%rjBtZY;liQ;qe*y?J&yC7)Fa zz7$8ysb{V!tb2d{o%KYkI>xl7x)yAKh+Js}XI7;u7#ahM!~Fab1&|D|ob${}%U3Xl zuUP@X(t0CqbrO@CB&IIpK&`hrK(Lx5M>sWR7f(PkdU5`08C?t*b=dLrnn@rsHCC@ Q=nzA5OCv5-RabvE0A6O%jsO4v literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-annotation-text-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-annotation-text-sample.mjs new file mode 100644 index 0000000000..f3201bdf5b --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-annotation-text-sample.mjs @@ -0,0 +1,73 @@ +import process from "node:process"; +// One-off script: generate `annotation-text-sample.pdf`, a page that carries +// BOTH editable page text and text that only exists inside annotations +// (a FreeText annotation and a form-field widget). +// +// The editor renders with FPDF_ANNOT but walks page objects only, so the +// annotation text is visible and uneditable. This fixture lets the outline + +// tooltip affordance be regression-tested. +// +// Run with: node generate-annotation-text-sample.mjs +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { + PDFDocument, + StandardFonts, + rgb, + PDFName, + PDFString, +} from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function main() { + const doc = await PDFDocument.create(); + const helv = await doc.embedFont(StandardFonts.Helvetica); + const page = doc.addPage([400, 300]); + + // Ordinary page text - this IS editable. + page.drawText("Editable page text", { + x: 30, + y: 250, + size: 16, + font: helv, + color: rgb(0, 0, 0), + }); + + // A form-field widget: annotation-backed, not page text. + const form = doc.getForm(); + const field = form.createTextField("sample.field"); + field.setText("Widget field text"); + field.addToPage(page, { + x: 30, + y: 180, + width: 220, + height: 24, + font: helv, + }); + + // A FreeText annotation: also annotation-backed, not page text. + const freeText = doc.context.obj({ + Type: PDFName.of("Annot"), + Subtype: PDFName.of("FreeText"), + Rect: [30, 110, 260, 140], + Contents: PDFString.of("FreeText annotation body"), + DA: PDFString.of("/Helv 12 Tf 0 g"), + F: 4, + }); + const ref = doc.context.register(freeText); + const annots = page.node.Annots(); + if (annots) annots.push(ref); + else page.node.set(PDFName.of("Annots"), doc.context.obj([ref])); + + const bytes = await doc.save(); + const out = join(__dirname, "annotation-text-sample.pdf"); + writeFileSync(out, bytes); + console.log(`wrote ${out} (${bytes.length} bytes)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-big-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-big-sample.mjs new file mode 100644 index 0000000000..15e221236f --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-big-sample.mjs @@ -0,0 +1,52 @@ +import process from "node:process"; +// One-off script: generate `big-sample.pdf`, an 80-page synthetic PDF +// with a few hundred text objects per page. Exercises the loading +// overlay (visible for several seconds on cold load) and the lazy +// page reader. Not heavy on disk (~ a few hundred KB) but heavy enough +// on parse + extract time to surface a UI freeze if one returns. +// +// Run with: node generate-big-sample.mjs +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { PDFDocument, StandardFonts, rgb } from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function main() { + const doc = await PDFDocument.create(); + const font = await doc.embedFont(StandardFonts.Helvetica); + const PAGES = 80; + const LINES_PER_PAGE = 80; + for (let p = 0; p < PAGES; p++) { + const page = doc.addPage([612, 792]); + page.drawText(`Page ${p + 1} of ${PAGES}`, { + x: 50, + y: 740, + size: 22, + font, + color: rgb(0, 0, 0), + }); + for (let l = 0; l < LINES_PER_PAGE; l++) { + page.drawText( + `Line ${l + 1} on page ${p + 1}: sample body content for paragraph clustering.`, + { + x: 50, + y: 700 - l * 18, + size: 11, + font, + color: rgb(0, 0, 0), + }, + ); + } + } + const out = await doc.save(); + const target = join(__dirname, "big-sample.pdf"); + writeFileSync(target, out); + console.log(`wrote ${target} (${out.length} bytes, ${PAGES} pages)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-cropbox-fixtures.py b/frontend/editor/src/core/tests/test-fixtures/generate-cropbox-fixtures.py new file mode 100644 index 0000000000..2d7cd2900c --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-cropbox-fixtures.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Generate minimal synthetic PDFs for the PDF text editor CropBox/rotation tests. + +These are hand-authored fixtures (NOT spirit-sx, which must never be committed). +Each has one page with a single Helvetica text object "Hi" at a known user-space +baseline, a MediaBox, and a CropBox whose origin is deliberately offset from the +MediaBox so the editor's display transform is exercised. Run from this dir: + + python generate-cropbox-fixtures.py +""" + + +def build_pdf(media, crop, rotate, text, tx, ty, font_size=24): + """Return bytes of a 1-page PDF. media/crop are [x0,y0,x1,y1]; text drawn + at Td(tx,ty) in user space with Helvetica.""" + objs = [] + objs.append(b"<< /Type /Catalog /Pages 2 0 R >>") + objs.append(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + page = ( + b"<< /Type /Page /Parent 2 0 R " + + b"/MediaBox [ %d %d %d %d ] " % tuple(media) + + b"/CropBox [ %d %d %d %d ] " % tuple(crop) + + b"/Rotate %d " % rotate + + b"/Resources << /Font << /F1 5 0 R >> >> " + + b"/Contents 4 0 R >>" + ) + objs.append(page) + stream = ( + b"BT /F1 %d Tf %d %d Td (%s) Tj ET" + % (font_size, tx, ty, text.encode("ascii")) + ) + objs.append(b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream") + objs.append(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + + out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [0] + for i, body in enumerate(objs, start=1): + offsets.append(len(out)) + out += b"%d 0 obj\n" % i + body + b"\nendobj\n" + xref_pos = len(out) + out += b"xref\n0 %d\n" % (len(objs) + 1) + out += b"0000000000 65535 f \n" + for off in offsets[1:]: + out += b"%010d 00000 n \n" % off + out += ( + b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" + % (len(objs) + 1, xref_pos) + ) + return bytes(out) + + +def main(): + # (A) Control: CropBox == MediaBox, Rotate 0. Must behave like today. + open("cropbox-control.pdf", "wb").write( + build_pdf([0, 0, 400, 400], [0, 0, 400, 400], 0, "Hi", 60, 350) + ) + # (B) CropBox origin offset (50,30); visible page is 300x350 portrait. + # Text baseline user-space (60,350) -> display (10,320). + open("cropbox-offset.pdf", "wb").write( + build_pdf([0, 0, 400, 400], [50, 30, 350, 380], 0, "Hi", 60, 350) + ) + # (C) CropBox offset + Rotate 90. Displayed page swaps to 350x300. + open("cropbox-rotate90.pdf", "wb").write( + build_pdf([0, 0, 400, 400], [50, 30, 350, 380], 90, "Hi", 60, 350) + ) + print("wrote cropbox-control.pdf, cropbox-offset.pdf, cropbox-rotate90.pdf") + + +if __name__ == "__main__": + main() diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-form-xobject-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-form-xobject-sample.mjs new file mode 100644 index 0000000000..e95740ff2b --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-form-xobject-sample.mjs @@ -0,0 +1,68 @@ +import process from "node:process"; +// One-off script: generate `form-xobject-sample.pdf`, a synthetic +// magazine-style PDF whose page content lives inside a Form XObject. +// This mirrors the structural pattern InDesign / professional layout +// tools emit (e.g. PC Magazin issues) so the editor's recursive text +// extractor can be regression-tested without shipping a copyrighted +// binary fixture. +// +// Run with: node generate-form-xobject-sample.mjs +// +// The output is checked into test-fixtures/ and consumed by +// pdf-text-editor.spec.ts under the "form xobject recursion" group. +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { PDFDocument, StandardFonts, rgb } from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function main() { + const srcDoc = await PDFDocument.create(); + const helv = await srcDoc.embedFont(StandardFonts.Helvetica); + const srcPage = srcDoc.addPage([400, 200]); + srcPage.drawText("Magazine cover title", { + x: 30, + y: 150, + size: 24, + font: helv, + color: rgb(0, 0, 0), + }); + srcPage.drawText("Subheading line below", { + x: 30, + y: 110, + size: 14, + font: helv, + color: rgb(0.2, 0.2, 0.2), + }); + srcPage.drawText("Inner body paragraph one.", { + x: 30, + y: 80, + size: 11, + font: helv, + color: rgb(0, 0, 0), + }); + srcPage.drawText("Inner body paragraph two.", { + x: 30, + y: 60, + size: 11, + font: helv, + color: rgb(0, 0, 0), + }); + const srcBytes = await srcDoc.save(); + + const dstDoc = await PDFDocument.create(); + const [embedded] = await dstDoc.embedPdf(srcBytes); + const dstPage = dstDoc.addPage([400, 200]); + dstPage.drawPage(embedded, { x: 0, y: 0, width: 400, height: 200 }); + const out = await dstDoc.save(); + + const target = join(__dirname, "form-xobject-sample.pdf"); + writeFileSync(target, out); + console.log(`wrote ${target} (${out.length} bytes)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-many-pages-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-many-pages-sample.mjs new file mode 100644 index 0000000000..3200012f5a --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-many-pages-sample.mjs @@ -0,0 +1,40 @@ +// One-off script: generate `many-pages-sample.pdf`, an 8-page PDF with two +// text lines per page. Eight is deliberately past the editor's +// EAGER_PAGE_LIMIT (5), so pages 6-8 carry no runs until something reads +// them - which is what makes it a fixture for "select all misses part of +// the document". Small enough to load in a test without a timeout. +// +// Run with: node generate-many-pages-sample.mjs +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { PDFDocument, StandardFonts, rgb } from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const PAGES = 8; +const LINES_PER_PAGE = 2; + +async function main() { + const doc = await PDFDocument.create(); + const font = await doc.embedFont(StandardFonts.Helvetica); + for (let p = 0; p < PAGES; p++) { + const page = doc.addPage([612, 792]); + for (let l = 0; l < LINES_PER_PAGE; l++) { + // Unique per line so a test can assert exactly which lines were hit. + page.drawText(`Page ${p + 1} line ${l + 1}`, { + x: 72, + y: 700 - l * 40, + size: 18, + font, + color: rgb(0, 0, 0), + }); + } + } + const bytes = await doc.save(); + const out = join(__dirname, "many-pages-sample.pdf"); + writeFileSync(out, bytes); + console.log(`wrote ${out} (${bytes.length} bytes, ${PAGES} pages)`); +} + +main(); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-paragraph-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-paragraph-sample.mjs new file mode 100644 index 0000000000..9beabbfa1e --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-paragraph-sample.mjs @@ -0,0 +1,55 @@ +import process from "node:process"; +// One-off script: generate `paragraph-sample.pdf`, a synthetic PDF +// whose page contains a multi-line body paragraph with consistent +// font/size/colour/left-margin. ParagraphGrouper should fold all four +// lines into one editable block. +// +// Run with: node generate-paragraph-sample.mjs +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { PDFDocument, StandardFonts, rgb } from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function main() { + const doc = await PDFDocument.create(); + const font = await doc.embedFont(StandardFonts.Helvetica); + const page = doc.addPage([400, 300]); + const left = 30; + const lineHeight = 16; + let y = 260; + page.drawText("Heading in a bigger size", { + x: left, + y, + size: 18, + font, + color: rgb(0, 0, 0), + }); + y -= 36; + const bodyLines = [ + "First line of the body paragraph that we want grouped together.", + "Second line continues the paragraph and shares the same font and", + "left margin so the grouper recognises it as part of the block.", + "Fourth line wraps the paragraph at the bottom of the column.", + ]; + for (const text of bodyLines) { + page.drawText(text, { + x: left, + y, + size: 11, + font, + color: rgb(0, 0, 0), + }); + y -= lineHeight; + } + const out = await doc.save(); + const target = join(__dirname, "paragraph-sample.pdf"); + writeFileSync(target, out); + console.log(`wrote ${target} (${out.length} bytes)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-rotated-text-sample.py b/frontend/editor/src/core/tests/test-fixtures/generate-rotated-text-sample.py new file mode 100644 index 0000000000..890bf17ae1 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-rotated-text-sample.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Generate rotated-text-sample.pdf: one page with a single text object whose +text matrix is rotated 30 degrees (an OBJECT rotation, not a page /Rotate). + +Used to verify the editor preserves a run's rotation when it re-emits the text +on edit (instead of forcing it upright). +""" +import math + + +def build() -> bytes: + cos = math.cos(math.radians(30)) + sin = math.sin(math.radians(30)) + stream = ( + f"BT /F1 24 Tf {cos:.5f} {sin:.5f} {-sin:.5f} {cos:.5f} 200 400 Tm " + f"(Rotated) Tj ET" + ).encode("ascii") + + objs: list[bytes] = [] + objs.append(b"<< /Type /Catalog /Pages 2 0 R >>") + objs.append(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + objs.append( + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>" + ) + objs.append( + b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream" + ) + objs.append( + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>" + ) + + out = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n") + offsets: list[int] = [] + for i, body in enumerate(objs, start=1): + offsets.append(len(out)) + out += str(i).encode() + b" 0 obj\n" + body + b"\nendobj\n" + xref_pos = len(out) + n = len(objs) + 1 + out += b"xref\n0 " + str(n).encode() + b"\n0000000000 65535 f \n" + for off in offsets: + out += ("%010d 00000 n \n" % off).encode() + out += ( + b"trailer\n<< /Size " + str(n).encode() + b" /Root 1 0 R >>\n" + b"startxref\n" + str(xref_pos).encode() + b"\n%%EOF" + ) + return bytes(out) + + +if __name__ == "__main__": + data = build() + with open("rotated-text-sample.pdf", "wb") as f: + f.write(data) + print(f"wrote rotated-text-sample.pdf ({len(data)} bytes)") diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-shading-and-justified-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-shading-and-justified-sample.mjs new file mode 100644 index 0000000000..ee2e6b3438 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-shading-and-justified-sample.mjs @@ -0,0 +1,111 @@ +import process from "node:process"; +// One-off script: generate two probe fixtures. +// +// shading-sample.pdf - an axial gradient painted with the `sh` operator plus +// a pattern-filled rectangle, with ordinary text over +// both. Probes whether editing the text costs the page +// its background artwork. +// justified-sample.pdf - text laid out with TJ arrays carrying inter-word +// offsets, the way justified copy is really emitted. +// Probes whether the reader invents extra spaces. +// +// Run with: node generate-shading-and-justified-sample.mjs +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { + PDFDocument, + PDFName, + PDFRawStream, + StandardFonts, +} from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function rawStream(doc, body) { + const bytes = new TextEncoder().encode(body); + return doc.context.register( + PDFRawStream.of(doc.context.obj({ Length: bytes.length }), bytes), + ); +} + +async function makeShading() { + const doc = await PDFDocument.create(); + const helv = await doc.embedFont(StandardFonts.Helvetica); + const page = doc.addPage([420, 300]); + const fontKey = page.node.newFontDictionaryKey("Helv"); + page.node.setFontDictionary(fontKey, helv.ref); + + // Axial shading, white -> mid grey across the page. + const fn = doc.context.obj({ + FunctionType: 2, + Domain: [0, 1], + C0: [0.95, 0.95, 1], + C1: [0.35, 0.55, 0.85], + N: 1, + }); + const shading = doc.context.obj({ + ShadingType: 2, + ColorSpace: PDFName.of("DeviceRGB"), + Coords: [0, 0, 420, 0], + Function: doc.context.register(fn), + Extend: [true, true], + }); + const shadingRef = doc.context.register(shading); + const resources = page.node.Resources(); + resources.set(PDFName.of("Shading"), doc.context.obj({ Sh0: shadingRef })); + + const body = [ + "q", + "0 0 420 300 re W n", + "/Sh0 sh", + "Q", + "BT /Helv 20 Tf 0 0 0 rg 34 210 Td (Text over a gradient) Tj ET", + "BT /Helv 14 Tf 34 170 Td (Second line of body text) Tj ET", + "", + ].join("\n"); + page.node.set(PDFName.of("Contents"), rawStream(doc, body)); + + const bytes = await doc.save(); + const out = join(__dirname, "shading-sample.pdf"); + writeFileSync(out, bytes); + console.log(`wrote ${out} (${bytes.length} bytes)`); +} + +async function makeJustified() { + const doc = await PDFDocument.create(); + const helv = await doc.embedFont(StandardFonts.Helvetica); + const page = doc.addPage([420, 220]); + const fontKey = page.node.newFontDictionaryKey("Helv"); + page.node.setFontDictionary(fontKey, helv.ref); + + // Justified copy: each inter-word gap is widened by a negative TJ number + // rather than by a wider space glyph, which is what real justification emits. + const line = (y, words, kern) => + `BT /Helv 13 Tf 30 ${y} Td [${words + .map((w, i) => `(${w})${i < words.length - 1 ? ` ${kern}` : ""}`) + .join(" ")}] TJ ET`; + + const body = [ + line(170, ["Justified", "copy", "spreads", "its", "words"], -420), + line(145, ["across", "the", "measure", "using", "offsets"], -560), + line(120, ["not", "by", "padding", "with", "spaces"], -300), + "", + ].join("\n"); + page.node.set(PDFName.of("Contents"), rawStream(doc, body)); + + const bytes = await doc.save(); + const out = join(__dirname, "justified-sample.pdf"); + writeFileSync(out, bytes); + console.log(`wrote ${out} (${bytes.length} bytes)`); +} + +async function main() { + await makeShading(); + await makeJustified(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-signed-sample.py b/frontend/editor/src/core/tests/test-fixtures/generate-signed-sample.py new file mode 100644 index 0000000000..a2528353c9 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-signed-sample.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Generate signed-sample.pdf: a 1-page PDF carrying a digital-signature field. + +PDFium's FPDF_GetSignatureCount counts AcroForm fields of /FT /Sig that have a +/V signature dictionary, so the editor's pre-save warning can flag it. The +signature bytes are a placeholder - the point is detection, not validity. +""" +import struct # noqa: F401 (kept for parity with sibling generators) + + +def build() -> bytes: + objs: list[bytes] = [] + + # 1: Catalog with an AcroForm referencing the signature field. + objs.append( + b"<< /Type /Catalog /Pages 2 0 R " + b"/AcroForm << /Fields [5 0 R] /SigFlags 3 >> >>" + ) + # 2: Pages + objs.append(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + # 3: Page (the widget annotation is the signature field itself) + objs.append( + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Resources << >> /Contents 4 0 R /Annots [5 0 R] >>" + ) + # 4: empty content stream + stream = b"BT /F1 12 Tf 72 720 Td (Signed sample) Tj ET" + objs.append( + b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream" + ) + # 5: signature field + widget annotation + objs.append( + b"<< /FT /Sig /Type /Annot /Subtype /Widget /T (Signature1) " + b"/Rect [72 700 272 740] /P 3 0 R /V 6 0 R /F 132 >>" + ) + # 6: signature dictionary + objs.append( + b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached " + b"/Name (Test Signer) /M (D:20260101000000Z) " + b"/ByteRange [0 0 0 0] /Contents <0000> >>" + ) + + header = b"%PDF-1.6\n%\xe2\xe3\xcf\xd3\n" + out = bytearray(header) + offsets: list[int] = [] + for i, body in enumerate(objs, start=1): + offsets.append(len(out)) + out += str(i).encode() + b" 0 obj\n" + body + b"\nendobj\n" + + xref_pos = len(out) + n = len(objs) + 1 + out += b"xref\n0 " + str(n).encode() + b"\n" + out += b"0000000000 65535 f \n" + for off in offsets: + out += ("%010d 00000 n \n" % off).encode() + out += ( + b"trailer\n<< /Size " + str(n).encode() + b" /Root 1 0 R >>\n" + b"startxref\n" + str(xref_pos).encode() + b"\n%%EOF" + ) + return bytes(out) + + +if __name__ == "__main__": + data = build() + with open("signed-sample.pdf", "wb") as f: + f.write(data) + print(f"wrote signed-sample.pdf ({len(data)} bytes)") diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-split-contents-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-split-contents-sample.mjs new file mode 100644 index 0000000000..2e99b1754f --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-split-contents-sample.mjs @@ -0,0 +1,57 @@ +import process from "node:process"; +// One-off script: generate `split-contents-sample.pdf`, a page whose /Contents +// is an ARRAY of streams split MID-OPERATOR - no member is independently valid +// PDF content, only their concatenation is. Acrobat Distiller emits this shape +// when a page's content exceeds its internal buffer. +// +// The spec defines a /Contents array as the concatenation of its members, so a +// reader must join them before tokenizing. The risk being probed is that an +// editor which regenerates only the member owning a dirty object turns the +// concatenation into operator soup. +// +// Run with: node generate-split-contents-sample.mjs +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { + PDFDocument, + PDFName, + PDFRawStream, + StandardFonts, +} from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function main() { + const doc = await PDFDocument.create(); + const helv = await doc.embedFont(StandardFonts.Helvetica); + const page = doc.addPage([420, 260]); + const fontKey = page.node.newFontDictionaryKey("Helv"); + page.node.setFontDictionary(fontKey, helv.ref); + + // Deliberately cut each operator sequence across the member boundary. + const pieces = [ + "BT /Helv 18 Tf 40 200 Td (Split contents line one) Tj ET\nBT /Helv 18 Tf 40 1", + "70 Td (Split contents line two) Tj ET\nBT /Helv 18 Tf 40 140 Td (Split cont", + "ents line three) Tj ET\n", + ]; + + const refs = pieces.map((body) => { + const stream = PDFRawStream.of( + doc.context.obj({ Length: body.length }), + new TextEncoder().encode(body), + ); + return doc.context.register(stream); + }); + page.node.set(PDFName.of("Contents"), doc.context.obj(refs)); + + const bytes = await doc.save(); + const out = join(__dirname, "split-contents-sample.pdf"); + writeFileSync(out, bytes); + console.log(`wrote ${out} (${bytes.length} bytes, ${refs.length} members)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-subset-font-sample.py b/frontend/editor/src/core/tests/test-fixtures/generate-subset-font-sample.py new file mode 100644 index 0000000000..35e4c68e1a --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-subset-font-sample.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""One-off generator for `subset-font-sample.pdf`. + +This fixture exercises the PDF text editor's subset-font fallback branch +(`canReuseFont = ... && !run.fontSubset`). The editor flags a run as a +subset font only when PDFium's FPDFFont_GetFamilyName returns a name +matching /^[A-Z]{6}\\+/ - and PDFium reads that name from the embedded +font program's `name` table, NOT the PDF BaseFont entry. Plain pdf-lib / +fontTools subsetting only tags the BaseFont, so we must also rewrite the +embedded font's name table to carry the 6-letter "ABCDEF+" subset tag. + +Run with: pip install pymupdf fonttools && python generate-subset-font-sample.py +Source font: @embedpdf/fonts-latin NotoSans-Regular.ttf (already a repo dep). +""" +import os +from fontTools.ttLib import TTFont +from fontTools.subset import Subsetter, Options +import fitz # PyMuPDF + +HERE = os.path.dirname(os.path.abspath(__file__)) +SRC_FONT = os.path.join( + HERE, + "../../../../../node_modules/@embedpdf/fonts-latin/fonts/NotoSans-Regular.ttf", +) +OUT = os.path.join(HERE, "subset-font-sample.pdf") +TAG = "ABCDEF+" + +LINES = [ + "Subset font sample line one", + "Body text with embedded subset glyphs", + "Editing this run must fall back cleanly", +] + + +def make_named_subset(tmp_path: str) -> None: + font = TTFont(SRC_FONT) + opt = Options() + opt.name_IDs = ["*"] + ss = Subsetter(options=opt) + ss.populate(text="".join(LINES)) + ss.subset(font) + # Stamp the subset tag into the font program's own name table so PDFium + # surfaces it via FPDFFont_GetFamilyName (Windows 3,1 + Mac 1,0 records). + name = font["name"] + for pid, eid, lid in [(3, 1, 0x409), (1, 0, 0)]: + name.setName(TAG + "NotoSubset", 1, pid, eid, lid) # family + name.setName("Regular", 2, pid, eid, lid) # subfamily + name.setName(TAG + "NotoSubset", 4, pid, eid, lid) # full + name.setName(TAG + "NotoSubset", 6, pid, eid, lid) # postscript + font.save(tmp_path) + font.close() + + +def main() -> None: + tmp = os.path.join(HERE, "_noto-subset-named.ttf") + make_named_subset(tmp) + try: + doc = fitz.open() + page = doc.new_page(width=420, height=220) + # set_simple=True -> simple (non-CID) TrueType, so PDFium reports the + # name-table family verbatim (CID fonts get the tag stripped). + page.insert_font(fontname="NS", fontfile=tmp, set_simple=True) + y = 70 + for line in LINES: + page.insert_text((36, y), line, fontname="NS", fontsize=14) + y += 30 + # Do NOT call doc.subset_fonts(): the font is already subset + renamed. + doc.save(OUT, garbage=4, deflate=True) + doc.close() + print(f"wrote {OUT} ({os.path.getsize(OUT)} bytes)") + finally: + if os.path.exists(tmp): + os.remove(tmp) + + +if __name__ == "__main__": + main() diff --git a/frontend/editor/src/core/tests/test-fixtures/justified-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/justified-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..aba390a9c6ff6aa915dbad223187eeee2b2c3b72 GIT binary patch literal 1178 zcmaJ=Z%9*76t}Q{7eDx+50X^3$Sh>Nx3|r05Q8+AnoHYO3L^R3yPHqkd+XghHxq5^ zLlRVEgiuKJp#;S!vPeQ{q5Z@DKvt0b!4FXsMV22DlIq^AXM)lmgLBTk=XZYRchAYG z*zPnKw^%_=oc;hXLLRDh4Pfh5z8(}r9`4EPTl~biB%qT{UO$IA( zx0!9c)n zO2e4Qei=!dp@9|NxQwc1oSi2@5hK1`wu>)n&eOQp;;!Lg4Z$LNh>5uQVK5VMr2a2AK{Ql zA8!_!&C6erbTzGW)O5;?1Zj|SHqsZ{j(nLEeE?ScZG16jj#M0mjI`U`XQ zk9IxZ?bckzmnJ-GbPp@~bcmaZsX|E6!^R^(~l+mZhEd+#2# zOcgc2iu0?M)Z|Wz$3~l*@<`LQ&ik1idox@cmK|Sq_xQTg{Tr*>Oh<3VaQDXEAAK(u z2TNTqtSatVYrk37MKpwT?T9v^4xOJ+=UOOhk=DHCozk literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/letter-spacing-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/letter-spacing-sample.pdf new file mode 100644 index 0000000000..53a99da3cf --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/letter-spacing-sample.pdf @@ -0,0 +1,43 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> +endobj +4 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +5 0 obj +<< /Length 116 >> +stream +BT +/F1 18 Tf +2 Tc +72 700 Td +(SPACED HEADING) Tj +ET +BT +/F1 12 Tf +0 Tc +72 650 Td +(Normal body line for contrast) Tj +ET +endstream +endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000000311 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +478 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/many-pages-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/many-pages-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4a55f2efbbc6add1230c951e036d4f89e13f745d GIT binary patch literal 4135 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

{Nm283 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 F{}0M;F$@3z literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/mushroom-life.pdf b/frontend/editor/src/core/tests/test-fixtures/mushroom-life.pdf new file mode 100644 index 0000000000000000000000000000000000000000..62c8c3e0f69fcc9fa489531d0eb137e5a665dd63 GIT binary patch literal 66890 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

?Y?{hqvRJ6AF$G2Y`z+(WC%CG(a# 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

>XMU0DV>ok-K9(;qw$FhSX%q+JVi>A@8LS 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 TzhE#B{N948sYT_)kP-eD>c%O% literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/paragraph-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/paragraph-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..283df611c23899a90dc373d76022b9e33474721f GIT binary patch literal 1390 zcmY!laB3Nn zSiOEwYH@yPQF3ar0?1Wv`9K*E!y`4PEVU#vIZ@Z#z|z9R!ob|ZQo#hG1tDi?ZenC+ zVPtA%fhlKXU|?WjU}|Y$hAC%hZfam+W@%(#jwxquYHnm|Y-|SC2Mh%L2>+z4)MT*J zKwQ^~68GSe#FA8yTY&-~G2hgb%tWXB3Sc-I0HKM2fr2p*14Gj>FE76Y6u#h)%_{-= z9>g;P`35Kk)C>z`kPs}$+%j`YQi~My-Ex5TxTGfMr=)V}`=sWjmt-gyn;L<%6_*sH zCgyTg%;}wU(2LnnpzVES*BsVDBhNG;frDk`A0N1$;O+$fZZf%> zqW<~+{WELsF0%ae_h(IEc=_t@@2_5eefjayAl;P{roD3h`1WSur?>Z5xq0m-r7PaI zyHlz)N!4SL)?$s||Hq|1B=6Clx8}@}z*YCW_I-SNb}P5J+4l$6ihqR)dq31U+FlT~ ze||}=W8Ae4ZWZt1i66~69gICU$|ld>@`&Z!l!xKJyJBy;wbsRNbx-CsirOb**u<<>=16jatudoHjYVXw&R1?ou%? z3)RX6tak2`S7APV4ir7mpoT@1IiZMxWG`T<2`;eU4ok8}a<^Wq6L)J~pKj9vkE4N1Kfl{XW^TW<^YG&@ z@Am$75*5%0h;qElQ8VvxiQHYD-nVO2S9PAyXj7add)zkp@Pspwle6oLvr3ttzv^0X zYhxO>Pi)qveHCBV?t6YBZnACc$5o{d?TbIJ+4p5a{oBo%tae9NN>6Iuou}<7bxCxs zN%yb6aS!UsoV)&i-;r7`e|Ww4wzVhr|ET&~(qPu6^5JQX2lv^NQxCL1e6oq@#|t|> zy`O~wH`)#T>|_}4*W75G{NL|MUwqh#x&kagXn_=j!I@R53YJ{@LHYS53ZRlEh)dry zFD+le7{bPy7a&0#5tNz+4^b0SF8y#|)-nVdVg$-=o_Q&$kQ`|V$!YMwPEJVp@qhkA z$A-qni3i&o8#^0ioeYeOt}q^zFlXX?rlD}qXnG?fLxl>hA^siar!+ literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/pattern-fill-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/pattern-fill-sample.pdf new file mode 100644 index 0000000000..92cf98a185 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/pattern-fill-sample.pdf @@ -0,0 +1,42 @@ +%PDF-1.4 +% +1 0 obj +<> +endobj +2 0 obj +<> +endobj +3 0 obj +<>/Font<>>>/Contents 4 0 R>> +endobj +4 0 obj +<> +stream +q /Pattern cs /P0 scn 20 100 160 80 re f Q +BT /F1 14 Tf 20 40 Td (Plain text) Tj ET +endstream +endobj +5 0 obj +<> +endobj +6 0 obj +<>/Extend[true true]>> +endobj +7 0 obj +<> +endobj +xref +0 8 +0000000000 65535 f +0000000015 00000 n +0000000060 00000 n +0000000111 00000 n +0000000244 00000 n +0000000375 00000 n +0000000423 00000 n +0000000579 00000 n +trailer +<> +startxref +667 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/rotated-text-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/rotated-text-sample.pdf new file mode 100644 index 0000000000..9ec1eafde7 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/rotated-text-sample.pdf @@ -0,0 +1,33 @@ +%PDF-1.7 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 72 >> +stream +BT /F1 24 Tf 0.86603 0.50000 -0.50000 0.86603 200 400 Tm (Rotated) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000247 00000 n +0000000369 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +466 +%%EOF \ No newline at end of file diff --git a/frontend/editor/src/core/tests/test-fixtures/shading-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/shading-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..94e2ffd77dafdae908f1db0c8fe12cb73ea6c8d1 GIT binary patch literal 1189 zcmY!laB3Nn zSiOEwYH@yPQF3ar0?1Wv`9K*E!y`4POxN7N(!#{Tz}&)8!31WyeuRHgR%$X>70`M5 zt`#Nj!6k_$sUU42f#8h9l+3(zuqnYA1`6gdoghiy)RfFbr~C?_XAOYR#K=Iw*uVhf zWyid{{1Q+gfW4nr0(4@rf(1-3EEGTjFhBXE=B1ZpC>WX=f$S|VDN0Su~znbFZZ8U&o3nBE55;?U|%d3A~NpKaOZ zL!5=rKFs-X^W$bwbp?gikmU&u>TE^^UI%N={fK>kd*MdgGpjePYB|v&pyn!}7}EG$ z%FtQki{Y}OJ<6NDusqr1(vi}+QR`63N8Y2yV`{77A9e0*E9#mt``N|+*HqUUT??rH zY8-7)_xScAKCyFM-l|gscV01?+<9*PIm!6%+xY(d=QICyzw-O*mTuO?@ijm0L_e5X z-uUtC2O+oV9w&dSvWw_q*uQP}V&2$bxK0B>sAHy}L&B|D!3z$3*X)idl zDpkSIkV`)(KOYz$K$##eeb2nKdJ5R07=hBhXI@Gw zBzqVd0{MuPT`?y)A>qgW`4b%*8XG4bY;SDrY!r4fFfzL07~!MPG;^XM$BGtDJ`aQW hsR;}W6aTV=VDUt8Nn%k+MG?>emgdG>s;aL3ZU7J6owWb} literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/signed-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/signed-sample.pdf new file mode 100644 index 0000000000..51abd5095a --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/signed-sample.pdf @@ -0,0 +1,37 @@ +%PDF-1.6 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >> +endobj +4 0 obj +<< /Length 44 >> +stream +BT /F1 12 Tf 72 720 Td (Signed sample) Tj ET +endstream +endobj +5 0 obj +<< /FT /Sig /Type /Annot /Subtype /Widget /T (Signature1) /Rect [72 700 272 740] /P 3 0 R /V 6 0 R /F 132 >> +endobj +6 0 obj +<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /Name (Test Signer) /M (D:20260101000000Z) /ByteRange [0 0 0 0] /Contents <0000> >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000108 00000 n +0000000165 00000 n +0000000285 00000 n +0000000379 00000 n +0000000503 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +671 +%%EOF \ No newline at end of file diff --git a/frontend/editor/src/core/tests/test-fixtures/split-contents-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/split-contents-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ec34df61025d15e2961193ef122e28299447793d GIT binary patch literal 1195 zcmaJ=dq`7J7&lY@I0gw42oW8nnIU=~PPdiMscTZEZnG>ioZFdh&U zz(fdLB~f%J^Ne!OSmNu19($eaM?yKSfU+bSV8=G3uL4RP^azHW9O_I^O6XV?>WnZ( zTWvLCXdtdUOjfgipeUeSP-lVJIzNdVE%+j=xAaukYI>;;z3l&7_@uZF7ZJKrq#trx zj#cD*f)Dw!FI-_mOV0ctDSVJrW-CTE$f&I3=CY7MG$LfwUGnW>b+<_9l& zhDLw?>YYe^4s7e2@#pP@YaKya(C1<*F8BCdAU1!Ykh*nvGMEo zw(IZin0VY#+16Kkvv_`>wRd;T>{wy$mo)pxt;ngCQ$M@E1*N)sws*Q8*2m^pEaz{; z6iS+=>RfZf&D@_4qp#;n#`}-GaYW63`Y|~Yc;#c~z{QEk+fqrWYxC&S=-~0^^+&CE zB&K>?d#NKWuusuHv2&_Jy{$h0rPnw5DI!N3+It^bb1i}#2N;bgVn|=81~55-NrVXt z^L5Hxq(P>oswR>GA0qF7BOp9|8##gzEj%BNBkSc>ceWc!OQ-){ZOv$KxmpL$x?Ja6 z;Yyb0Uv3zVNJ)Qu91eaCjGAo-nm{2T0bMP@_xzZ!W4#SKZA5@VrT`L2cAn}VFfWVI literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/stirling-marketing.pdf b/frontend/editor/src/core/tests/test-fixtures/stirling-marketing.pdf new file mode 100644 index 0000000000000000000000000000000000000000..16220bd5a569e805fbe258d4293e648a1795f1e7 GIT binary patch literal 257765 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%0A?i#CnGQjK7AQYRVe_h(NR) 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�&LFLPTr%izbxSOlEWl!N#uT6C*L#$*#1Z9*Gq+ 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 MQBgG=@W#Ra0SDSUB>(^b literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/subset-font-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/subset-font-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..81f00c78665bc90b52376199ecb1aeec80dd765e GIT binary patch literal 5315 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 +endobj +2 0 obj +<> +endobj +3 0 obj +<>>>/Contents 4 0 R>> +endobj +4 0 obj +<> +stream +BT /T3 20 Tf 20 70 Td (ab) Tj ET +BT /F1 14 Tf 20 25 Td (Normal text) Tj ET +endstream +endobj +5 0 obj +<>/FirstChar 97/LastChar 98/Widths[10 10]/Resources<<>>>> +endobj +6 0 obj +<> +endobj +7 0 obj +<> +stream +10 0 0 0 10 10 d1 +0 0 10 10 re f +endstream +endobj +8 0 obj +<> +stream +10 0 0 0 10 10 d1 +0 0 m 10 0 l 5 10 l f +endstream +endobj +10 0 obj +<> +endobj +xref +0 11 +0000000000 65535 f +0000000015 00000 n +0000000060 00000 n +0000000111 00000 n +0000000233 00000 n +0000000355 00000 n +0000000573 00000 n +0000000616 00000 n +0000000696 00000 n +0000000000 65535 f +0000000783 00000 n +trailer +<> +startxref +872 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/user-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/user-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d78d9e1efd4bf65b9a01c47f05c2c9f6f51637fc GIT binary patch literal 264772 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

{Nm283 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 F{}0M;F$@3z literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx b/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx index fee07ac6ea..521feefc64 100644 --- a/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx +++ b/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx @@ -1,2052 +1,611 @@ -import { useCallback, useEffect, useMemo, useState, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { isAxiosError } from "axios"; import DescriptionIcon from "@mui/icons-material/DescriptionOutlined"; - -import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; -import { - useAllFiles, - useFileSelection, - useFileManagement, - useFileContext, -} from "@app/contexts/FileContext"; -import { - useNavigationActions, - useNavigationState, -} from "@app/contexts/NavigationContext"; -import { useViewer } from "@app/contexts/ViewerContext"; +import { downloadFile } from "@app/services/downloadService"; +import { useFileContext } from "@app/contexts/FileContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; -import { BaseToolProps, ToolComponent } from "@app/types/tool"; import type { FileId } from "@app/types/file"; -import { getDefaultWorkbench } from "@app/types/workbench"; -import { CONVERSION_ENDPOINTS } from "@app/constants/convertConstants"; -import apiClient from "@app/services/apiClient"; -import { downloadBlob, downloadTextAsFile } from "@app/utils/downloadUtils"; -import { getFilenameFromHeaders } from "@app/utils/fileResponseUtils"; -import { pdfWorkerManager } from "@app/services/pdfWorkerManager"; -import { Util } from "pdfjs-dist/legacy/build/pdf.mjs"; +import type { BaseToolProps } from "@app/types/tool"; +import { useEditorStore } from "@app/tools/pdfTextEditor/hooks/useEditorStore"; import { - PdfJsonDocument, - PdfJsonFont, - PdfJsonImageElement, - PdfJsonPage, - TextGroup, - PdfTextEditorViewData, - BoundingBox, - ConversionProgress, -} from "@app/tools/pdfTextEditor/pdfTextEditorTypes"; + useDocumentLoader, + ensureAllPagesRead, +} from "@app/tools/pdfTextEditor/hooks/useDocumentLoader"; +import { useAutoLoadFile } from "@app/tools/pdfTextEditor/hooks/useAutoLoadFile"; +import { useWorkbenchPin } from "@app/tools/pdfTextEditor/hooks/useWorkbenchPin"; +import { useUnsavedChangesGuard } from "@app/tools/pdfTextEditor/hooks/useUnsavedChangesGuard"; +import { useEditorTestGlobal } from "@app/tools/pdfTextEditor/hooks/useEditorTestGlobal"; +import { useSelectionActions } from "@app/tools/pdfTextEditor/hooks/useSelectionActions"; +import { useEditorKeyboardShortcuts } from "@app/tools/pdfTextEditor/hooks/useEditorKeyboardShortcuts"; +import { useEditorClipboard } from "@app/tools/pdfTextEditor/hooks/useEditorClipboard"; +import { FindBar } from "@app/tools/pdfTextEditor/components/FindBar"; +import { HelpOverlay } from "@app/tools/pdfTextEditor/components/HelpOverlay"; +import { SaveRiskModal } from "@app/tools/pdfTextEditor/components/SaveRiskModal"; +import { PasswordPromptModal } from "@app/tools/pdfTextEditor/components/PasswordPromptModal"; +import { EditorSaveBar } from "@app/tools/pdfTextEditor/components/EditorSaveBar"; +import { EditorSidebar } from "@app/tools/pdfTextEditor/components/EditorSidebar"; +import { EditorFileInputs } from "@app/tools/pdfTextEditor/components/EditorFileInputs"; +import { PageStage } from "@app/tools/pdfTextEditor/components/PageStage"; +import { InsertImageCommand } from "@app/tools/pdfTextEditor/commands/InsertImageCommand"; +import { InsertTextCommand } from "@app/tools/pdfTextEditor/commands/InsertTextCommand"; +import { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import { jpegExifOrientation } from "@app/tools/pdfTextEditor/util/jpegOrientation"; +import { MergeRunsCommand } from "@app/tools/pdfTextEditor/commands/MergeRunsCommand"; +import { UngroupParagraphCommand } from "@app/tools/pdfTextEditor/commands/UngroupParagraphCommand"; +import { exportToBlob } from "@app/tools/pdfTextEditor/util/exportPdf"; import { - deepCloneDocument, - getDirtyPages, - groupDocumentText, - restoreGlyphElements, - extractDocumentImages, - cloneImageElement, - cloneTextElement, - valueOr, -} from "@app/tools/pdfTextEditor/pdfTextEditorUtils"; -import PdfTextEditorView from "@app/components/tools/pdfTextEditor/PdfTextEditorView"; -import PdfTextEditorSidebar from "@app/components/tools/pdfTextEditor/PdfTextEditorSidebar"; -import type { PDFDocumentProxy } from "pdfjs-dist"; + detectSaveRisks, + hasSaveRisks, + type SaveRisks, +} from "@app/tools/pdfTextEditor/util/documentRisks"; +import { preloadFallbackFontBytes } from "@app/tools/pdfTextEditor/util/fallbackFont"; +import { visiblePageNumber } from "@app/tools/pdfTextEditor/util/dom"; +import type { SelectionState } from "@app/tools/pdfTextEditor/types"; -const WORKBENCH_VIEW_ID = "pdfTextEditorWorkbench"; const WORKBENCH_ID = "custom:pdfTextEditor" as const; +const WORKBENCH_VIEW_ID = "pdfTextEditorWorkbench"; +const INSERTED_IMAGE_RATIO = 0.4; -const sanitizeBaseName = (name?: string | null): string => { - if (!name || name.trim().length === 0) { - return "document"; - } - return name.replace(/\.[^.]+$/u, ""); -}; - -const getAutoLoadKey = (file: File): string => { - const withId = file as File & { fileId?: string; quickKey?: string }; - if (withId.fileId && typeof withId.fileId === "string") { - return withId.fileId; - } - if (withId.quickKey && typeof withId.quickKey === "string") { - return withId.quickKey; - } - return `${file.name}|${file.size}|${file.lastModified}`; -}; - -const normalizeLineArray = ( - value: string | undefined | null, - expected: number, -): string[] => { - const normalized = (value ?? "").replace(/\r/g, ""); - if (expected <= 0) { - return [normalized]; - } - const parts = normalized.split("\n"); - if (parts.length === expected) { - return parts; - } - if (parts.length < expected) { - return parts.concat(Array(expected - parts.length).fill("")); - } - const head = parts.slice(0, Math.max(expected - 1, 0)); - const tail = parts.slice(Math.max(expected - 1, 0)).join("\n"); - return [...head, tail]; -}; - -const cloneLineTemplate = ( - line: TextGroup, - text?: string, - originalText?: string, -): TextGroup => ({ - ...line, - text: text ?? line.text, - originalText: originalText ?? line.originalText, - childLineGroups: null, - lineElementCounts: null, - lineSpacing: null, - elements: line.elements.map(cloneTextElement), - originalElements: line.originalElements.map(cloneTextElement), -}); - -const expandGroupToLines = (group: TextGroup): TextGroup[] => { - if (group.childLineGroups && group.childLineGroups.length > 0) { - const textLines = normalizeLineArray( - group.text, - group.childLineGroups.length, - ); - const originalLines = normalizeLineArray( - group.originalText, - group.childLineGroups.length, - ); - return group.childLineGroups.map((child, index) => - cloneLineTemplate(child, textLines[index], originalLines[index]), - ); - } - return [cloneLineTemplate(group)]; -}; - -const mergeBoundingBoxes = (boxes: BoundingBox[]): BoundingBox => { - if (boxes.length === 0) { - return { left: 0, right: 0, top: 0, bottom: 0 }; - } - return boxes.reduce( - (acc, box) => ({ - left: Math.min(acc.left, box.left), - right: Math.max(acc.right, box.right), - top: Math.min(acc.top, box.top), - bottom: Math.max(acc.bottom, box.bottom), - }), - { ...boxes[0] }, - ); -}; - -const buildMergedGroupFromSelection = ( - groups: TextGroup[], -): TextGroup | null => { - if (groups.length === 0) { - return null; - } - - const lineTemplates = groups.flatMap(expandGroupToLines); - if (lineTemplates.length <= 1) { - return null; - } - - const lineTexts = lineTemplates.map((line) => line.text ?? ""); - const lineOriginalTexts = lineTemplates.map( - (line) => line.originalText ?? "", - ); - const combinedOriginals = lineTemplates.flatMap((line) => - line.originalElements.map(cloneTextElement), - ); - const combinedElements = combinedOriginals.map(cloneTextElement); - const mergedBounds = mergeBoundingBoxes( - lineTemplates.map((line) => line.bounds), - ); - - const spacingValues: number[] = []; - for (let index = 1; index < lineTemplates.length; index += 1) { - const prevBaseline = - lineTemplates[index - 1].baseline ?? - lineTemplates[index - 1].bounds.bottom; - const currentBaseline = - lineTemplates[index].baseline ?? lineTemplates[index].bounds.bottom; - const spacing = Math.abs(prevBaseline - currentBaseline); - if (spacing > 0) { - spacingValues.push(spacing); - } - } - const averageSpacing = - spacingValues.length > 0 - ? spacingValues.reduce((sum, value) => sum + value, 0) / - spacingValues.length - : null; - - const first = groups[0]; - const lineElementCounts = lineTemplates.map((line) => - Math.max(line.originalElements.length, 1), - ); - const paragraph: TextGroup = { - ...first, - text: lineTexts.join("\n"), - originalText: lineOriginalTexts.join("\n"), - elements: combinedElements, - originalElements: combinedOriginals, - bounds: mergedBounds, - lineSpacing: averageSpacing, - lineElementCounts: lineElementCounts.length > 1 ? lineElementCounts : null, - childLineGroups: lineTemplates.map((line, index) => - cloneLineTemplate(line, lineTexts[index], lineOriginalTexts[index]), - ), - }; - - return paragraph; -}; - -const splitParagraphGroup = (group: TextGroup): TextGroup[] => { - if (!group.childLineGroups || group.childLineGroups.length <= 1) { - return []; - } - - const templateLines = group.childLineGroups.map((child) => - cloneLineTemplate(child), - ); - const lineCount = templateLines.length; - const textLines = normalizeLineArray(group.text, lineCount); - const originalLines = normalizeLineArray(group.originalText, lineCount); - const baseCounts = - group.lineElementCounts && group.lineElementCounts.length === lineCount - ? [...group.lineElementCounts] - : templateLines.map((line) => Math.max(line.originalElements.length, 1)); - - const totalOriginals = group.originalElements.length; - const counted = baseCounts.reduce((sum, count) => sum + count, 0); - if (counted < totalOriginals && baseCounts.length > 0) { - baseCounts[baseCounts.length - 1] += totalOriginals - counted; - } - - let offset = 0; - return templateLines.map((template, index) => { - const take = Math.max(1, baseCounts[index] ?? 1); - const slice = group.originalElements - .slice(offset, offset + take) - .map(cloneTextElement); - offset += take; - return { - ...template, - id: `${group.id}-line-${index + 1}-${Date.now()}-${index}`, - text: textLines[index] ?? "", - originalText: originalLines[index] ?? "", - elements: slice.map(cloneTextElement), - originalElements: slice, - lineElementCounts: null, - lineSpacing: null, - childLineGroups: null, - }; - }); -}; - -const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { +export default function PdfTextEditor(_props: BaseToolProps) { const { t } = useTranslation(); - const { - registerCustomWorkbenchView, - unregisterCustomWorkbenchView, - setCustomWorkbenchViewData, - clearCustomWorkbenchViewData, - setLeftPanelView, - } = useToolWorkflow(); - const { actions: navigationActions } = useNavigationActions(); - const navigationState = useNavigationState(); - const { addFiles } = useFileManagement(); - const { consumeFiles, selectors } = useFileContext(); + const { store, state } = useEditorStore(); + const load = useDocumentLoader(store); - const [loadedDocument, setLoadedDocument] = useState( - null, + const [selection, setSelection] = useState( + store.selection.value, ); - const [groupsByPage, setGroupsByPage] = useState([]); - const [imagesByPage, setImagesByPage] = useState([]); - const [selectedPage, setSelectedPage] = useState(0); - const [fileName, setFileName] = useState(""); - const [errorMessage, setErrorMessage] = useState(null); - const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); - const [isSavingToWorkbench, setIsSavingToWorkbench] = useState(false); - const [shouldNavigateAfterSave, setShouldNavigateAfterSave] = useState(false); - const [isConverting, setIsConverting] = useState(false); - const [conversionProgress, setConversionProgress] = - useState(null); - const [forceSingleTextElement, setForceSingleTextElement] = useState(true); - const [groupingMode, setGroupingMode] = useState< - "auto" | "paragraph" | "singleLine" - >("auto"); - const [hasVectorPreview, setHasVectorPreview] = useState(false); - const [pagePreviews, setPagePreviews] = useState>( - new Map(), - ); - const [autoScaleText, setAutoScaleText] = useState(true); - - // Lazy loading state - const [isLazyMode, setIsLazyMode] = useState(false); - const [cachedJobId, setCachedJobId] = useState(null); - const [loadedImagePages, setLoadedImagePages] = useState>( - new Set(), - ); - const [loadingImagePages, setLoadingImagePages] = useState>( - new Set(), - ); - - const originalImagesRef = useRef([]); - const originalGroupsRef = useRef([]); - const imagesByPageRef = useRef([]); - const lastLoadedFileRef = useRef(null); - const autoLoadKeyRef = useRef(null); + const [findOpen, setFindOpen] = useState(false); + const [helpOpen, setHelpOpen] = useState(false); + const [openedFileName, setOpenedFileName] = useState(null); + // Set only when the document came from the workbench; a drag-dropped + // file has no fileId and can only be downloaded. Mirrored into state so the + // sidebar's file switcher can mark which workbench file is open. const sourceFileIdRef = useRef(null); - const loadRequestIdRef = useRef(0); - const latestPdfRequestIdRef = useRef(null); - const loadedDocumentRef = useRef(null); - const loadedImagePagesRef = useRef>(new Set()); - const loadingImagePagesRef = useRef>(new Set()); - const pdfDocumentRef = useRef(null); - const previewRequestIdRef = useRef(0); - const previewRenderingRef = useRef>(new Set()); - const pagePreviewsRef = useRef>(pagePreviews); - const previewScaleRef = useRef>(new Map()); - const cachedJobIdRef = useRef(null); - const previousCachedJobIdRef = useRef(null); - const cacheRecoveryInProgressRef = useRef(false); - const cacheRecoveryAttemptsRef = useRef(0); - const recoverCacheAndReloadRef = useRef<() => Promise>( - async () => false, - ); - - // Keep ref in sync with state for access in async callbacks - useEffect(() => { - loadedDocumentRef.current = loadedDocument; - }, [loadedDocument]); - - useEffect(() => { - loadedImagePagesRef.current = new Set(loadedImagePages); - }, [loadedImagePages]); - - useEffect(() => { - loadingImagePagesRef.current = new Set(loadingImagePages); - }, [loadingImagePages]); - - useEffect(() => { - pagePreviewsRef.current = pagePreviews; - }, [pagePreviews]); - - useEffect(() => { - return () => { - if (pdfDocumentRef.current) { - pdfWorkerManager.destroyDocument(pdfDocumentRef.current); - pdfDocumentRef.current = null; - } - }; + const [sourceFileId, setSourceFileId] = useState(null); + const setSourceFile = useCallback((id: FileId | null) => { + sourceFileIdRef.current = id; + setSourceFileId(id); }, []); + const { addFiles, consumeFiles, selectors } = useFileContext(); + // Saving replaces the workbench file, so for a moment the selection points at + // a file the editor has not adopted yet. Auto-load must sit that out. + const [applying, setApplying] = useState(false); - const isCacheUnavailableError = useCallback((error: unknown): boolean => { - const status = isAxiosError(error) ? error.response?.status : undefined; - // Treat any 410 as cache unavailable, since responseType: 'blob' makes - // it impossible to reliably check the JSON body - return status === 410; - }, []); - - const dirtyPages = useMemo( - () => - getDirtyPages( - groupsByPage, - imagesByPage, - originalGroupsRef.current, - originalImagesRef.current, - ), - [groupsByPage, imagesByPage], - ); - const hasChanges = useMemo(() => dirtyPages.some(Boolean), [dirtyPages]); - const hasDocument = loadedDocument !== null; - - // Sync hasChanges to navigation context so navigation guards can block - useEffect(() => { - navigationActions.setHasUnsavedChanges(hasChanges); - return () => { - navigationActions.setHasUnsavedChanges(false); - }; - }, [hasChanges, navigationActions]); - - // Navigate to files view AFTER the unsaved changes state is properly cleared - useEffect(() => { - if (shouldNavigateAfterSave && !navigationState.hasUnsavedChanges) { - setShouldNavigateAfterSave(false); - navigationActions.setToolAndWorkbench(null, getDefaultWorkbench()); - } - }, [ - shouldNavigateAfterSave, - navigationState.hasUnsavedChanges, - navigationActions, - ]); - - const viewLabel = useMemo( - () => t("pdfTextEditor.viewLabel", "PDF Editor"), - [t], - ); - const { selectedFiles } = useFileSelection(); - const { files: allFiles } = useAllFiles(); - const { activeFileId } = useViewer(); - - // The file the tool should auto-load: prefer the sidebar selection, then - // whatever the viewer is currently showing (so opening PDF Editor from the - // viewer picks up that file), then the single workbench file if there is - // only one. Returns null if the choice is ambiguous (no selection, no - // viewer file, and multiple files in the workbench). - const autoLoadFile = useMemo(() => { - if (selectedFiles[0]) return selectedFiles[0]; - if (activeFileId) { - const viewerFile = allFiles.find( - (f) => (f.fileId as string) === activeFileId, - ); - if (viewerFile) return viewerFile; - } - if (allFiles.length === 1) return allFiles[0]; - return null; - }, [selectedFiles, activeFileId, allFiles]); - - const resetToDocument = useCallback( - ( - document: PdfJsonDocument | null, - mode: "auto" | "paragraph" | "singleLine", - ) => { - if (!document) { - setGroupsByPage([]); - setImagesByPage([]); - originalImagesRef.current = []; - imagesByPageRef.current = []; - setLoadedImagePages(new Set()); - setLoadingImagePages(new Set()); - loadedImagePagesRef.current = new Set(); - loadingImagePagesRef.current = new Set(); - setSelectedPage(0); - setIsLazyMode(false); - setCachedJobId(null); - cachedJobIdRef.current = null; - return; - } - const cloned = deepCloneDocument(document); - const groups = groupDocumentText(cloned, mode); - const images = extractDocumentImages(cloned); - const originalImages = images.map((page) => page.map(cloneImageElement)); - originalImagesRef.current = originalImages; - originalGroupsRef.current = groups.map((page) => - page.map((group) => ({ ...group })), - ); - imagesByPageRef.current = images.map((page) => - page.map(cloneImageElement), - ); - const initialLoaded = new Set(); - originalImages.forEach((pageImages, index) => { - if (pageImages.length > 0) { - initialLoaded.add(index); - } - }); - setGroupsByPage(groups); - setImagesByPage(images); - setLoadedImagePages(initialLoaded); - setLoadingImagePages(new Set()); - loadedImagePagesRef.current = new Set(initialLoaded); - loadingImagePagesRef.current = new Set(); - setSelectedPage(0); + useEditorTestGlobal(store); + useUnsavedChangesGuard(state.dirty); + const pinWorkbench = useWorkbenchPin({ + workbenchId: WORKBENCH_ID, + workbenchViewId: WORKBENCH_VIEW_ID, + label: t("pdfTextEditor.workbenchLabel", "Editor"), + icon: , + component: PageStage, + }); + // Uploading flips the workbench to Active Files, so landing a document has to + // pin the canvas back. useAutoLoadFile only fires for a genuine file change. + const handleFileChosen = useCallback( + (name: string, fileId?: FileId) => { + setOpenedFileName(name); + setSourceFile(fileId ?? null); + pinWorkbench(); }, - [], + [pinWorkbench, setSourceFile], + ); + const { openFile: openWorkbenchFile, adopt: adoptFile } = useAutoLoadFile( + load, + handleFileChosen, + sourceFileId, + applying, + state, ); - const clearPdfPreview = useCallback(() => { - previewRequestIdRef.current += 1; - previewRenderingRef.current.clear(); - previewScaleRef.current.clear(); - const empty = new Map(); - pagePreviewsRef.current = empty; - setPagePreviews(empty); - if (pdfDocumentRef.current) { - pdfWorkerManager.destroyDocument(pdfDocumentRef.current); - pdfDocumentRef.current = null; - } - setHasVectorPreview(false); - }, []); - - const clearCachedJob = useCallback((jobId: string | null) => { - if (!jobId) { - return; - } - console.log( - `[PdfTextEditor] Cleaning up cached document for jobId: ${jobId}`, - ); - apiClient - .post(`/api/v1/convert/pdf/text-editor/clear-cache/${jobId}`) - .catch((error) => { - console.warn("[PdfTextEditor] Failed to clear cache:", error); - }); - }, []); + useEffect(() => store.selection.subscribe(setSelection), [store]); + // Warm the Unicode fallback font so a non-Latin edit can embed it instead of + // dropping the glyphs. useEffect(() => { - // Clear old cached job when job ID changes - const previousJobId = previousCachedJobIdRef.current; - if (previousJobId && previousJobId !== cachedJobId) { - console.log( - `[PdfTextEditor] Clearing old cache for jobId: ${previousJobId}, new jobId: ${cachedJobId}`, - ); - clearCachedJob(previousJobId); - } - // Update the previous jobId ref for next time - previousCachedJobIdRef.current = cachedJobId; - }, [cachedJobId, clearCachedJob]); + void preloadFallbackFontBytes(); + }, []); - const initializePdfPreview = useCallback( - async (file: File) => { - const requestId = ++previewRequestIdRef.current; + const sel = useSelectionActions(store); + + // Guards against re-entrant saves while a (synchronous) serialize runs. + const savingRef = useRef(false); + // Pending save-risk warning (signatures/XFA) shown before the actual save. + const [saveRisks, setSaveRisks] = useState(null); + // docPtr the user already acknowledged risks for, so we don't re-nag. + const ackedRiskRef = useRef<{ doc: object; sig: string } | null>(null); + + // Land the edit in the workbench the way every other tool does: replace the + // file it came from, or add it if the document was opened from disk. Without + // this the editor is an island and the next tool runs on the pre-edit bytes. + const applyToWorkbench = useCallback( + async (blob: Blob, filename: string) => { + const edited = new File([blob], filename, { type: "application/pdf" }); + const sourceId = sourceFileIdRef.current; + const parentStub = sourceId + ? selectors.getStirlingFileStub(sourceId) + : null; + setApplying(true); try { - const buffer = await file.arrayBuffer(); - const pdfDocument = await pdfWorkerManager.createDocument(buffer); - if (previewRequestIdRef.current !== requestId) { - pdfWorkerManager.destroyDocument(pdfDocument); + if (sourceId && parentStub) { + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( + [edited], + parentStub, + "pdfTextEditor", + ); + await consumeFiles([sourceId], stirlingFiles, stubs); + // Claim the replacement before releasing the hold, otherwise the + // editor sees an unfamiliar selection and re-opens the file it just + // wrote, throwing away undo history. + if (stirlingFiles[0]) adoptFile(stirlingFiles[0]); + setSourceFile(stubs[0]?.id ?? null); return; } - if (pdfDocumentRef.current) { - pdfWorkerManager.destroyDocument(pdfDocumentRef.current); - } - pdfDocumentRef.current = pdfDocument; - previewRenderingRef.current.clear(); - previewScaleRef.current.clear(); - const empty = new Map(); - pagePreviewsRef.current = empty; - setPagePreviews(empty); - setHasVectorPreview(true); - } catch (error) { - if (previewRequestIdRef.current === requestId) { - console.warn( - "[PdfTextEditor] Failed to initialise PDF preview:", - error, - ); - clearPdfPreview(); - } - } - }, - [clearPdfPreview], - ); - - // Load images for a page in lazy mode - const loadImagesForPage = useCallback( - async (pageIndex: number) => { - if (!isLazyMode) { - return; - } - if (!cachedJobId) { - console.log("[loadImagesForPage] No cached jobId, skipping"); - return; - } - if ( - loadedImagePagesRef.current.has(pageIndex) || - loadingImagePagesRef.current.has(pageIndex) - ) { - return; - } - - loadingImagePagesRef.current.add(pageIndex); - setLoadingImagePages((prev) => { - const next = new Set(prev); - next.add(pageIndex); - return next; - }); - - const pageNumber = pageIndex + 1; - const start = performance.now(); - - try { - const [pageResponse, pageFontsResponse] = await Promise.all([ - apiClient.get( - `/api/v1/convert/pdf/text-editor/page/${cachedJobId}/${pageNumber}`, - { - responseType: "json", - }, - ), - apiClient.get( - `/api/v1/convert/pdf/text-editor/fonts/${cachedJobId}/${pageNumber}`, - { - responseType: "json", - }, - ), - ]); - - const pageData = pageResponse.data as PdfJsonPage; - const pageFonts = Array.isArray(pageFontsResponse.data) - ? (pageFontsResponse.data as PdfJsonFont[]) - : []; - const normalizedImages = (pageData.imageElements ?? []).map( - cloneImageElement, - ); - - if (imagesByPageRef.current.length <= pageIndex) { - imagesByPageRef.current.length = pageIndex + 1; - } - imagesByPageRef.current[pageIndex] = - normalizedImages.map(cloneImageElement); - - setLoadedDocument((prevDoc) => { - if (!prevDoc || !prevDoc.pages) { - return prevDoc; - } - const nextPages = [...prevDoc.pages]; - const existingPage = nextPages[pageIndex] ?? {}; - const fontMap = new Map(); - for (const existingFont of prevDoc.fonts ?? []) { - if (!existingFont) { - continue; - } - const existingKey = - existingFont.uid || - `${existingFont.pageNumber ?? -1}:${existingFont.id ?? ""}`; - fontMap.set(existingKey, existingFont); - } - if (pageFonts.length > 0) { - for (const font of pageFonts) { - if (!font) { - continue; - } - const key = - font.uid || `${font.pageNumber ?? -1}:${font.id ?? ""}`; - fontMap.set(key, font); - } - } - const nextFonts = Array.from(fontMap.values()); - nextPages[pageIndex] = { - ...existingPage, - imageElements: normalizedImages.map(cloneImageElement), - }; - return { - ...prevDoc, - fonts: nextFonts, - pages: nextPages, - }; + const added = await addFiles([edited], { + selectFiles: true, + derivedFromTool: true, }); - - setImagesByPage((prev) => { - const next = [...prev]; - while (next.length <= pageIndex) { - next.push([]); - } - next[pageIndex] = normalizedImages.map(cloneImageElement); - return next; - }); - - if (originalImagesRef.current.length <= pageIndex) { - originalImagesRef.current.length = pageIndex + 1; - } - originalImagesRef.current[pageIndex] = - normalizedImages.map(cloneImageElement); - - setLoadedImagePages((prev) => { - const next = new Set(prev); - next.add(pageIndex); - return next; - }); - loadedImagePagesRef.current.add(pageIndex); - - console.log( - `[loadImagesForPage] Loaded ${normalizedImages.length} images for page ${pageNumber} in ${( - performance.now() - start - ).toFixed(2)}ms`, - ); - } catch (error) { - console.error( - `[loadImagesForPage] Failed to load images for page ${pageNumber}:`, - error, - ); - if (isCacheUnavailableError(error)) { - console.log( - "[loadImagesForPage] Cache expired, triggering automatic recovery...", - ); - // Automatically recover by reloading the file - void recoverCacheAndReloadRef.current(); - } + if (added[0]) adoptFile(added[0]); + setSourceFile(added[0]?.fileId ?? null); } finally { - loadingImagePagesRef.current.delete(pageIndex); - setLoadingImagePages((prev) => { - const next = new Set(prev); - next.delete(pageIndex); - return next; - }); + setApplying(false); } }, - [isLazyMode, cachedJobId, isCacheUnavailableError], + [addFiles, adoptFile, consumeFiles, selectors, setSourceFile], ); - const handleLoadFile = useCallback( - async (file: File | null) => { - if (!file) { - return; - } - - lastLoadedFileRef.current = file; - const requestId = loadRequestIdRef.current + 1; - loadRequestIdRef.current = requestId; - - const _fileKey = getAutoLoadKey(file); - const isPdf = - file.type === "application/pdf" || - file.name.toLowerCase().endsWith(".pdf"); - + const doSave = useCallback( + async (download: boolean) => { + if (!store.document || savingRef.current) return; + savingRef.current = true; + store.setError(null); try { - let parsed: PdfJsonDocument | null = null; - let shouldUseLazyMode = false; - let pendingJobId: string | null = null; - - if (isPdf) { - latestPdfRequestIdRef.current = requestId; - setIsConverting(true); - setConversionProgress({ - percent: 0, - stage: "uploading", - message: "Uploading PDF file to server...", - }); - - const formData = new FormData(); - formData.append("fileInput", file); - - console.log("Sending conversion request with async=true"); - const response = await apiClient.post( - `${CONVERSION_ENDPOINTS["pdf-text-editor"]}?async=true&lightweight=true`, - formData, - { - responseType: "json", - }, - ); - - console.log("Conversion response:", response.data); - const jobId = response.data.jobId; - - if (!jobId) { - console.error("No job ID in response:", response.data); - throw new Error("No job ID received from server"); - } - - pendingJobId = jobId; - console.log("Got job ID:", jobId); - setConversionProgress({ - percent: 3, - stage: "processing", - message: "Starting conversion...", - }); - - let jobComplete = false; - let attempts = 0; - const maxAttempts = 600; - let pollDelay = 500; - - while (!jobComplete && attempts < maxAttempts) { - await new Promise((resolve) => setTimeout(resolve, pollDelay)); - attempts += 1; - if (pollDelay < 10000) { - pollDelay = Math.min(10000, Math.floor(pollDelay * 1.5)); - } - - try { - const statusResponse = await apiClient.get( - `/api/v1/general/job/${jobId}`, - ); - const jobStatus = statusResponse.data; - console.log(`Job status (attempt ${attempts}):`, jobStatus); - - const percent = Math.min( - Math.max(jobStatus.progress ?? 0, 0), - 100, - ); - const stage = jobStatus.stage || "processing"; - const message = jobStatus.note || "Converting PDF to JSON..."; - const current = jobStatus.current ?? undefined; - const total = jobStatus.total ?? undefined; - setConversionProgress({ - percent, - stage, - message, - current, - total, - }); - - if (jobStatus.complete) { - if (jobStatus.error) { - console.error("Job failed:", jobStatus.error); - throw new Error(jobStatus.error); - } - - console.log("Job completed, retrieving JSON result..."); - jobComplete = true; - - const resultResponse = await apiClient.get( - `/api/v1/general/job/${jobId}/result`, - { - responseType: "blob", - }, - ); - - const jsonText = await resultResponse.data.text(); - const result = JSON.parse(jsonText); - - if (!Array.isArray(result.pages)) { - console.error( - "Conversion result missing page array:", - result, - ); - throw new Error( - "PDF conversion result did not include page data. Please update the server.", - ); - } - - const docResult = result as PdfJsonDocument; - parsed = { - ...docResult, - pages: docResult.pages ?? [], - }; - shouldUseLazyMode = Boolean(docResult.lazyImages); - pendingJobId = shouldUseLazyMode ? jobId : null; - setConversionProgress(null); - } else { - console.log("Job not complete yet, continuing to poll..."); - } - } catch (pollError) { - console.error("Error polling job status:", pollError); - const status = isAxiosError(pollError) - ? pollError.response?.status - : undefined; - console.error("Poll error details:", { - status, - data: isAxiosError(pollError) - ? pollError.response?.data - : undefined, - message: - pollError instanceof Error ? pollError.message : undefined, - }); - if (status === 404) { - throw new Error("Job not found on server", { - cause: pollError, - }); - } - } - } - - if (!jobComplete) { - throw new Error("Conversion timed out"); - } - if (!parsed) { - throw new Error("Conversion did not return JSON content"); - } - } else { - const content = await file.text(); - const docResult = JSON.parse(content) as PdfJsonDocument; - parsed = { - ...docResult, - pages: docResult.pages ?? [], - }; - shouldUseLazyMode = false; - pendingJobId = null; - } - - setConversionProgress(null); - - if (loadRequestIdRef.current !== requestId) { - return; - } - - if (!parsed) { - throw new Error("Failed to parse PDF JSON document"); - } - - console.log( - `[PdfTextEditor] Document loaded. Lazy image mode: ${shouldUseLazyMode}, Pages: ${parsed.pages?.length || 0}`, + // Yield once so React can paint the disabled/saving state before the + // synchronous PDFium serialize blocks the main thread. + await new Promise((resolve) => setTimeout(resolve, 0)); + // The position that is about to be written out. Anything the user edits + // while the export runs is NOT in these bytes, so it must stay dirty. + const exported = store.savedPosition(); + const { blob, filename } = await exportToBlob( + store.document, + openedFileName, ); - - if (isPdf) { - initializePdfPreview(file); - } else { - clearPdfPreview(); - } - - setLoadedDocument(parsed); - resetToDocument(parsed, groupingMode); - setIsLazyMode(shouldUseLazyMode); - const newJobId = shouldUseLazyMode ? pendingJobId : null; - setCachedJobId(newJobId); - cachedJobIdRef.current = newJobId; - setFileName(file.name); - setErrorMessage(null); - } catch (error) { - console.error("Failed to load file", error); - console.error("Error details:", { - message: error instanceof Error ? error.message : undefined, - response: isAxiosError(error) ? error.response?.data : undefined, - stack: error instanceof Error ? error.stack : undefined, - }); - - if (loadRequestIdRef.current !== requestId) { - return; - } - - setLoadedDocument(null); - resetToDocument(null, groupingMode); - clearPdfPreview(); - setIsLazyMode(false); - setCachedJobId(null); - cachedJobIdRef.current = null; - - if (isPdf) { - const errorMsg = - (error instanceof Error ? error.message : undefined) || - t( - "pdfTextEditor.conversionFailed", - "Failed to convert PDF. Please try again.", - ); - setErrorMessage(errorMsg); - console.error("Setting error message:", errorMsg); - } else { - setErrorMessage( - t( - "pdfTextEditor.errors.invalidJson", - "Unable to read the JSON file. Ensure it was generated by the PDF to JSON tool.", - ), - ); - } + // Apply first and unconditionally. Gating the write-back on the browser + // download dialog meant cancelling it silently discarded the save. + await applyToWorkbench(blob, filename); + store.markSaved(exported); + if (download) await downloadFile({ data: blob, filename }); + } catch (err) { + // Surface the failure instead of silently dropping it - the user + // must not believe a broken save succeeded. + store.setError(err instanceof Error ? err.message : String(err)); } finally { - if (isPdf && latestPdfRequestIdRef.current === requestId) { - setIsConverting(false); - } + savingRef.current = false; } }, - [groupingMode, resetToDocument, t], + [store, openedFileName, applyToWorkbench], ); - const recoverCacheAndReload = useCallback(async () => { - if (cacheRecoveryInProgressRef.current) { - return false; - } - if (cacheRecoveryAttemptsRef.current >= 2) { - console.warn("[PdfTextEditor] Cache recovery limit reached"); - return false; - } - cacheRecoveryAttemptsRef.current += 1; - const file = lastLoadedFileRef.current; - if (!file) { - console.warn("[PdfTextEditor] No file available for cache recovery"); - return false; - } - cacheRecoveryInProgressRef.current = true; - try { - console.log( - "[PdfTextEditor] Automatically reloading file due to cache expiration...", - ); - await handleLoadFile(file); - console.log("[PdfTextEditor] Cache recovery successful"); - return true; - } catch (error) { - console.error("[PdfTextEditor] Cache recovery failed", error); - return false; - } finally { - cacheRecoveryInProgressRef.current = false; - } - }, [handleLoadFile]); + // Which action the risk modal is currently gating. + const pendingDownloadRef = useRef(false); - useEffect(() => { - recoverCacheAndReloadRef.current = recoverCacheAndReload; - }, [recoverCacheAndReload]); - - // Wrapper for loading files from the dropzone - adds to workbench first - const handleLoadFileFromDropzone = useCallback( - async (file: File) => { - // Add the file to the workbench so it appears in the file list - const addedFiles = await addFiles([file]); - // Capture the file ID for save-to-workbench functionality - if (addedFiles.length > 0 && addedFiles[0].fileId) { - sourceFileIdRef.current = addedFiles[0].fileId; - } - // Then load it into the editor - void handleLoadFile(file); - }, - [addFiles, handleLoadFile], - ); - - const handleSelectPage = useCallback( - (pageIndex: number) => { - setSelectedPage(pageIndex); - // Trigger lazy loading for images on the selected page - if (isLazyMode) { - void loadImagesForPage(pageIndex); - } - }, - [isLazyMode, loadImagesForPage], - ); - - const handleGroupTextChange = useCallback( - (pageIndex: number, groupId: string, value: string) => { - setGroupsByPage((previous) => - previous.map((groups, idx) => - idx !== pageIndex - ? groups - : groups.map((group) => - group.id === groupId ? { ...group, text: value } : group, - ), - ), - ); - }, - [], - ); - - const handleGroupDelete = useCallback( - (pageIndex: number, groupId: string) => { - console.log(`🗑️ Deleting group ${groupId} from page ${pageIndex}`); - setGroupsByPage((previous) => { - const updated = previous.map((groups, idx) => { - if (idx !== pageIndex) return groups; - const filtered = groups.filter((group) => group.id !== groupId); - console.log( - ` Before: ${groups.length} groups, After: ${filtered.length} groups`, - ); - return filtered; - }); - return updated; - }); - }, - [], - ); - - const handleMergeGroups = useCallback( - (pageIndex: number, groupIds: string[]): boolean => { - if (groupIds.length < 2) { - return false; - } - let updated = false; - setGroupsByPage((previous) => - previous.map((groups, idx) => { - if (idx !== pageIndex) { - return groups; - } - const indices = groupIds - .map((id) => groups.findIndex((group) => group.id === id)) - .filter((index) => index >= 0); - if (indices.length !== groupIds.length) { - return groups; - } - const sorted = [...indices].sort((a, b) => a - b); - for (let i = 1; i < sorted.length; i += 1) { - if (sorted[i] !== sorted[i - 1] + 1) { - return groups; - } - } - const selection = sorted.map((position) => groups[position]); - const merged = buildMergedGroupFromSelection(selection); - if (!merged) { - return groups; - } - const next = [ - ...groups.slice(0, sorted[0]), - merged, - ...groups.slice(sorted[sorted.length - 1] + 1), - ]; - updated = true; - return next; - }), - ); - return updated; - }, - [], - ); - - const handleUngroupGroup = useCallback( - (pageIndex: number, groupId: string): boolean => { - let updated = false; - setGroupsByPage((previous) => - previous.map((groups, idx) => { - if (idx !== pageIndex) { - return groups; - } - const targetIndex = groups.findIndex((group) => group.id === groupId); - if (targetIndex < 0) { - return groups; - } - const targetGroup = groups[targetIndex]; - const splits = splitParagraphGroup(targetGroup); - if (splits.length <= 1) { - return groups; - } - const next = [ - ...groups.slice(0, targetIndex), - ...splits, - ...groups.slice(targetIndex + 1), - ]; - updated = true; - return next; - }), - ); - return updated; - }, - [], - ); - - const handleImageTransform = useCallback( - ( - pageIndex: number, - imageId: string, - next: { - left: number; - bottom: number; - width: number; - height: number; - transform: number[]; - }, - ) => { - setImagesByPage((previous) => { - const current = previous[pageIndex] ?? []; - let changed = false; - const updatedPage = current.map((image) => { - if ((image.id ?? "") !== imageId) { - return image; - } - const originalTransform = - image.transform ?? - originalImagesRef.current[pageIndex]?.find( - (base) => (base.id ?? "") === imageId, - )?.transform; - const scaleXSign = - originalTransform && originalTransform.length >= 6 - ? Math.sign(originalTransform[0]) || 1 - : 1; - const scaleYSign = - originalTransform && originalTransform.length >= 6 - ? Math.sign(originalTransform[3]) || 1 - : 1; - const right = next.left + next.width; - const top = next.bottom + next.height; - const updatedImage: PdfJsonImageElement = { - ...image, - x: next.left, - y: next.bottom, - left: next.left, - bottom: next.bottom, - right, - top, - width: next.width, - height: next.height, - transform: - scaleXSign < 0 || scaleYSign < 0 - ? [ - next.width * scaleXSign, - 0, - 0, - next.height * scaleYSign, - next.left, - scaleYSign >= 0 ? next.bottom : next.bottom + next.height, - ] - : null, - }; - - const isSame = - Math.abs(valueOr(image.left, 0) - next.left) < 1e-4 && - Math.abs(valueOr(image.bottom, 0) - next.bottom) < 1e-4 && - Math.abs(valueOr(image.width, 0) - next.width) < 1e-4 && - Math.abs(valueOr(image.height, 0) - next.height) < 1e-4; - - if (!isSame) { - changed = true; - } - return updatedImage; - }); - - if (!changed) { - return previous; - } - - const nextImages = previous.map((images, idx) => - idx === pageIndex ? updatedPage : images, - ); - if (imagesByPageRef.current.length <= pageIndex) { - imagesByPageRef.current.length = pageIndex + 1; - } - imagesByPageRef.current[pageIndex] = updatedPage.map(cloneImageElement); - return nextImages; - }); - }, - [], - ); - - const handleImageReset = useCallback((pageIndex: number, imageId: string) => { - const baseline = originalImagesRef.current[pageIndex]?.find( - (image) => (image.id ?? "") === imageId, - ); - if (!baseline) { - return; - } - setImagesByPage((previous) => { - const current = previous[pageIndex] ?? []; - let changed = false; - const updatedPage = current.map((image) => { - if ((image.id ?? "") !== imageId) { - return image; - } - changed = true; - return cloneImageElement(baseline); - }); - - if (!changed) { - return previous; - } - - const nextImages = previous.map((images, idx) => - idx === pageIndex ? updatedPage : images, - ); - if (imagesByPageRef.current.length <= pageIndex) { - imagesByPageRef.current.length = pageIndex + 1; - } - imagesByPageRef.current[pageIndex] = updatedPage.map(cloneImageElement); - return nextImages; - }); - }, []); - - const handleResetEdits = useCallback(() => { - if (!loadedDocument) { - return; - } - resetToDocument(loadedDocument, groupingMode); - setErrorMessage(null); - }, [groupingMode, loadedDocument, resetToDocument]); - - const buildPayload = useCallback(() => { - if (!loadedDocument) { - return null; - } - - const updatedDocument = restoreGlyphElements( - loadedDocument, - groupsByPage, - imagesByPageRef.current, - originalImagesRef.current, - forceSingleTextElement, - ); - const baseName = sanitizeBaseName( - fileName || loadedDocument.metadata?.title || undefined, - ); - return { - document: updatedDocument, - filename: `${baseName}.json`, - }; - }, [fileName, forceSingleTextElement, groupsByPage, loadedDocument]); - - const handleDownloadJson = useCallback(() => { - const payload = buildPayload(); - if (!payload) { - return; - } - - const { document, filename } = payload; - const serialized = JSON.stringify(document); - downloadTextAsFile(serialized, filename, "application/json"); - - if (onComplete) { - const exportedFile = new File([serialized], filename, { - type: "application/json", - }); - onComplete([exportedFile]); - } - }, [buildPayload, onComplete]); - - const handleGeneratePdf = useCallback( - async (skipComplete = false) => { - try { - setIsGeneratingPdf(true); - - const ensureImagesForPages = async (pageIndices: number[]) => { - const uniqueIndices = Array.from(new Set(pageIndices)).filter( - (index) => index >= 0, - ); - if (uniqueIndices.length === 0) { - return; - } - - for (const index of uniqueIndices) { - if (!loadedImagePagesRef.current.has(index)) { - await loadImagesForPage(index); - } - } - - const maxWaitTime = 15000; - const pollInterval = 150; - const startWait = Date.now(); - while (Date.now() - startWait < maxWaitTime) { - const allLoaded = uniqueIndices.every( - (index) => - loadedImagePagesRef.current.has(index) && - imagesByPageRef.current[index] !== undefined, - ); - const anyLoading = uniqueIndices.some((index) => - loadingImagePagesRef.current.has(index), - ); - if (allLoaded && !anyLoading) { - return; - } - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - } - - const missing = uniqueIndices.filter( - (index) => !loadedImagePagesRef.current.has(index), - ); - if (missing.length > 0) { - throw new Error( - `Failed to load images for pages ${missing.map((i) => i + 1).join(", ")}`, - ); - } - }; - - const currentDoc = loadedDocumentRef.current; - const totalPages = currentDoc?.pages?.length ?? 0; - const dirtyPageIndices = dirtyPages - .map((isDirty, index) => (isDirty ? index : -1)) - .filter((index) => index >= 0); - - const canUseIncremental = - isLazyMode && cachedJobId && dirtyPageIndices.length > 0; - - if (canUseIncremental) { - await ensureImagesForPages(dirtyPageIndices); - - try { - const payload = buildPayload(); - if (!payload) { - throw new Error("Failed to build payload"); - } - - const { document, filename } = payload; - const dirtyPageSet = new Set(dirtyPageIndices); - const partialPages = - document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? - []; - - const partialDocument: PdfJsonDocument = { - // Incremental export only needs changed pages. - // Fonts/resources/content streams are resolved from server-side cache. - pages: partialPages, - }; - - const baseName = sanitizeBaseName(filename).replace( - /-edited$/u, - "", - ); - const expectedName = `${baseName || "document"}.pdf`; - const response = await apiClient.post( - `/api/v1/convert/pdf/text-editor/partial/${cachedJobIdRef.current}?filename=${encodeURIComponent(expectedName)}`, - partialDocument, - { - responseType: "blob", - }, - ); - - const contentDisposition = - response.headers?.["content-disposition"] ?? ""; - const detectedName = getFilenameFromHeaders(contentDisposition); - const downloadName = detectedName || expectedName; - - downloadBlob(response.data, downloadName); - - if (onComplete && !skipComplete) { - const pdfFile = new File([response.data], downloadName, { - type: "application/pdf", - }); - onComplete([pdfFile]); - } - setErrorMessage(null); - return; - } catch (incrementalError) { - if (isLazyMode && cachedJobIdRef.current) { - throw new Error( - "Incremental export failed for cached document. Please reload and retry.", - { - cause: incrementalError, - }, - ); - } - console.warn( - "[handleGeneratePdf] Incremental export failed, falling back to full export", - incrementalError, - ); - } - } - - if (isLazyMode && totalPages > 0) { - const allPageIndices = Array.from( - { length: totalPages }, - (_, index) => index, - ); - await ensureImagesForPages(allPageIndices); - } - - const payload = buildPayload(); - if (!payload) { + const runSave = useCallback( + async (download: boolean) => { + const doc = store.document; + if (!doc || savingRef.current) return; + // Re-evaluate on EVERY save: the ack only covers the exact risk set + // the user saw. A new risk appearing later must warn again. + const risks = detectSaveRisks(doc); + if (hasSaveRisks(risks)) { + const sig = JSON.stringify(risks); + const acked = ackedRiskRef.current; + if (!acked || acked.doc !== doc || acked.sig !== sig) { + pendingDownloadRef.current = download; + setSaveRisks(risks); return; } - - const { document, filename } = payload; - const serialized = JSON.stringify(document); - const jsonFile = new File([serialized], filename, { - type: "application/json", - }); - - const formData = new FormData(); - formData.append("fileInput", jsonFile); - const response = await apiClient.post( - CONVERSION_ENDPOINTS["text-editor-pdf"], - formData, - { - responseType: "blob", - }, - ); - - const contentDisposition = - response.headers?.["content-disposition"] ?? ""; - const detectedName = getFilenameFromHeaders(contentDisposition); - const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ""); - const downloadName = detectedName || `${baseName || "document"}.pdf`; - - downloadBlob(response.data, downloadName); - - if (onComplete && !skipComplete) { - const pdfFile = new File([response.data], downloadName, { - type: "application/pdf", - }); - onComplete([pdfFile]); - } - setErrorMessage(null); - } catch (error) { - console.error("Failed to convert JSON back to PDF", error); - const message = - (isAxiosError(error) ? error.response?.data : undefined) || - (error instanceof Error ? error.message : undefined) || - t( - "pdfTextEditor.errors.pdfConversion", - "Unable to convert the edited JSON back into a PDF.", - ); - const msgString = - typeof message === "string" ? message : String(message); - setErrorMessage(msgString); - if (onError) { - onError(msgString); - } - } finally { - setIsGeneratingPdf(false); } + await doSave(download); }, - [ - buildPayload, - cachedJobId, - dirtyPages, - isLazyMode, - loadImagesForPage, - onComplete, - onError, - t, - ], + [store, doSave], ); - // Save changes to workbench (replaces the original file with edited version) - const handleSaveToWorkbench = useCallback(async () => { - setIsSavingToWorkbench(true); + const handleSave = useCallback(() => void runSave(false), [runSave]); + const handleDownload = useCallback(() => void runSave(true), [runSave]); - try { - if (!sourceFileIdRef.current) { - console.warn( - "[PdfTextEditor] No source file ID available for save to workbench", - ); - // Fall back to generating PDF download if no source file - await handleGeneratePdf(true); - return; - } - - const sourceFileId = sourceFileIdRef.current; - const parentStub = selectors.getStirlingFileStub(sourceFileId); - if (!parentStub) { - console.warn( - "[PdfTextEditor] Could not find parent stub for save to workbench", - ); - await handleGeneratePdf(true); - return; - } - - const ensureImagesForPages = async (pageIndices: number[]) => { - const uniqueIndices = Array.from(new Set(pageIndices)).filter( - (index) => index >= 0, - ); - if (uniqueIndices.length === 0) { - return; - } - - for (const index of uniqueIndices) { - if (!loadedImagePagesRef.current.has(index)) { - await loadImagesForPage(index); - } - } - - const maxWaitTime = 15000; - const pollInterval = 150; - const startWait = Date.now(); - while (Date.now() - startWait < maxWaitTime) { - const allLoaded = uniqueIndices.every( - (index) => - loadedImagePagesRef.current.has(index) && - imagesByPageRef.current[index] !== undefined, - ); - const anyLoading = uniqueIndices.some((index) => - loadingImagePagesRef.current.has(index), - ); - if (allLoaded && !anyLoading) { - return; - } - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - } - - const missing = uniqueIndices.filter( - (index) => !loadedImagePagesRef.current.has(index), - ); - if (missing.length > 0) { - throw new Error( - `Failed to load images for pages ${missing.map((i) => i + 1).join(", ")}`, - ); - } + const handleConfirmSaveRisk = useCallback(() => { + const doc = store.document; + if (doc) { + ackedRiskRef.current = { + doc, + sig: JSON.stringify(detectSaveRisks(doc)), }; - - const currentDoc = loadedDocumentRef.current; - const totalPages = currentDoc?.pages?.length ?? 0; - const currentDirtyPages = getDirtyPages( - groupsByPage, - imagesByPage, - originalGroupsRef.current, - originalImagesRef.current, - ); - const dirtyPageIndices = currentDirtyPages - .map((isDirty, index) => (isDirty ? index : -1)) - .filter((index) => index >= 0); - - let pdfBlob: Blob; - let downloadName: string; - - const canUseIncremental = - isLazyMode && cachedJobId && dirtyPageIndices.length > 0; - - if (canUseIncremental) { - await ensureImagesForPages(dirtyPageIndices); - - try { - const payload = buildPayload(); - if (!payload) { - throw new Error("Failed to build payload"); - } - - const { document, filename } = payload; - const dirtyPageSet = new Set(dirtyPageIndices); - const partialPages = - document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? []; - - const partialDocument: PdfJsonDocument = { - // Incremental export only needs changed pages. - // Fonts/resources/content streams are resolved from server-side cache. - pages: partialPages, - }; - - const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ""); - const expectedName = `${baseName || "document"}.pdf`; - const response = await apiClient.post( - `/api/v1/convert/pdf/text-editor/partial/${cachedJobId}?filename=${encodeURIComponent(expectedName)}`, - partialDocument, - { - responseType: "blob", - }, - ); - - const contentDisposition = - response.headers?.["content-disposition"] ?? ""; - const detectedName = getFilenameFromHeaders(contentDisposition); - downloadName = detectedName || expectedName; - pdfBlob = response.data; - } catch (incrementalError) { - if (isLazyMode && cachedJobId) { - throw new Error( - "Incremental export failed for cached document. Please reload and retry.", - { - cause: incrementalError, - }, - ); - } - console.warn( - "[handleSaveToWorkbench] Incremental export failed, falling back to full export", - incrementalError, - ); - // Fall through to full export - if (isLazyMode && totalPages > 0) { - const allPageIndices = Array.from( - { length: totalPages }, - (_, index) => index, - ); - await ensureImagesForPages(allPageIndices); - } - - const payload = buildPayload(); - if (!payload) { - throw new Error("Failed to build payload", { - cause: incrementalError, - }); - } - - const { document, filename } = payload; - const serialized = JSON.stringify(document); - const jsonFile = new File([serialized], filename, { - type: "application/json", - }); - - const formData = new FormData(); - formData.append("fileInput", jsonFile); - const response = await apiClient.post( - CONVERSION_ENDPOINTS["text-editor-pdf"], - formData, - { - responseType: "blob", - }, - ); - - const contentDisposition = - response.headers?.["content-disposition"] ?? ""; - const detectedName = getFilenameFromHeaders(contentDisposition); - const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ""); - downloadName = detectedName || `${baseName || "document"}.pdf`; - pdfBlob = response.data; - } - } else { - if (isLazyMode && totalPages > 0) { - const allPageIndices = Array.from( - { length: totalPages }, - (_, index) => index, - ); - await ensureImagesForPages(allPageIndices); - } - - const payload = buildPayload(); - if (!payload) { - throw new Error("Failed to build payload"); - } - - const { document, filename } = payload; - const serialized = JSON.stringify(document); - const jsonFile = new File([serialized], filename, { - type: "application/json", - }); - - const formData = new FormData(); - formData.append("fileInput", jsonFile); - const response = await apiClient.post( - CONVERSION_ENDPOINTS["text-editor-pdf"], - formData, - { - responseType: "blob", - }, - ); - - const contentDisposition = - response.headers?.["content-disposition"] ?? ""; - const detectedName = getFilenameFromHeaders(contentDisposition); - const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ""); - downloadName = detectedName || `${baseName || "document"}.pdf`; - pdfBlob = response.data; - } - - // Create the new PDF file - const pdfFile = new File([pdfBlob], downloadName, { - type: "application/pdf", - }); - - // Create StirlingFile and stub for the output - const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( - [pdfFile], - parentStub, - "pdfTextEditor", - ); - - // Replace the original file with the edited version - await consumeFiles([sourceFileId], stirlingFiles, stubs); - - // Update the source file ID to point to the new file - sourceFileIdRef.current = stubs[0].id; - - // Clear the unsaved changes flag - this will trigger the useEffect to navigate - // once React has processed the state update - navigationActions.setHasUnsavedChanges(false); - setErrorMessage(null); - - // Set flag to trigger navigation after state update is processed - setShouldNavigateAfterSave(true); - } catch (error) { - console.error("Failed to save to workbench", error); - const message = - (isAxiosError(error) ? error.response?.data : undefined) || - (error instanceof Error ? error.message : undefined) || - t( - "pdfTextEditor.errors.pdfConversion", - "Unable to save changes to workbench.", - ); - const msgString = typeof message === "string" ? message : String(message); - setErrorMessage(msgString); - if (onError) { - onError(msgString); - } - } finally { - setIsSavingToWorkbench(false); } - }, [ - buildPayload, - cachedJobId, - consumeFiles, - groupsByPage, - handleGeneratePdf, - imagesByPage, - isLazyMode, - loadImagesForPage, - navigationActions, - onError, - selectors, - t, - ]); + setSaveRisks(null); + void doSave(pendingDownloadRef.current); + }, [store, doSave]); - const requestPagePreview = useCallback( - async (pageIndex: number, scale: number) => { - if (!hasVectorPreview || !pdfDocumentRef.current) { - return; - } - const currentToken = previewRequestIdRef.current; - const recordedScale = previewScaleRef.current.get(pageIndex); - if ( - pagePreviewsRef.current.has(pageIndex) && - recordedScale !== undefined && - Math.abs(recordedScale - scale) < 0.05 - ) { - return; - } - if (previewRenderingRef.current.has(pageIndex)) { - return; - } - previewRenderingRef.current.add(pageIndex); + const handleInsertImage = useCallback( + async (file: File) => { + const doc = store.document; + if (!doc) return; + // Decode via an element rather than createImageBitmap: the latter + // lacks codec support in some environments. + let decoded: { data: ImageData; width: number; height: number }; try { - const page = await pdfDocumentRef.current.getPage(pageIndex + 1); - const viewport = page.getViewport({ scale: Math.max(scale, 0.5) }); - const canvas = document.createElement("canvas"); - canvas.width = viewport.width; - canvas.height = viewport.height; - const context = canvas.getContext("2d"); - if (!context) { - page.cleanup(); - return; - } - await page.render({ canvas, canvasContext: context, viewport }).promise; - + decoded = await decodeImageFile(file); + } catch (err) { + store.setError( + err instanceof Error + ? err.message + : t( + "pdfTextEditor.error.decodeImage", + "Could not decode the selected image.", + ), + ); + return; + } + // Keep the original JPEG bytes so the insert embeds them as-is + // (DCTDecode) instead of re-encoding decoded RGBA - far smaller output. + let jpegBytes: Uint8Array | undefined; + if (file.type === "image/jpeg") { try { - const textContent = await page.getTextContent(); - const maskMarginX = 0; - const maskMarginTop = 0; - const maskMarginBottom = Math.max(3 * scale, 3); - context.save(); - context.globalCompositeOperation = "destination-out"; - context.fillStyle = "#000000"; - for (const item of textContent.items) { - // Skip TextMarkedContent items, only process TextItem - if (!("transform" in item)) continue; - - const transform = Util.transform( - viewport.transform, - item.transform, - ); - const a = transform[0]; - const b = transform[1]; - const c = transform[2]; - const d = transform[3]; - const e = transform[4]; - const f = transform[5]; - const angle = Math.atan2(b, a); - - const width = (item.width || 0) * viewport.scale + maskMarginX * 2; - const fontHeight = Math.hypot(c, d); - const rawHeight = item.height - ? item.height * viewport.scale - : fontHeight; - const height = Math.max( - rawHeight + maskMarginTop + maskMarginBottom, - fontHeight + maskMarginTop + maskMarginBottom, - ); - const baselineOffset = height - maskMarginBottom; - - context.save(); - context.translate(e, f); - context.rotate(angle); - context.fillRect(-maskMarginX, -baselineOffset, width, height); - context.restore(); - } - context.restore(); - } catch (textError) { - console.warn( - "[PdfTextEditor] Failed to strip text from preview", - textError, - ); + jpegBytes = new Uint8Array(await file.arrayBuffer()); + // The decode above APPLIES EXIF orientation; the raw bytes + // don't. + if (jpegExifOrientation(jpegBytes) !== 1) jpegBytes = undefined; + } catch { + jpegBytes = undefined; // fall back to the bitmap path } - - // Also mask out images to prevent ghost/shadow images when they're moved - try { - const pageImages = imagesByPage[pageIndex] ?? []; - if (pageImages.length > 0) { - context.save(); - context.globalCompositeOperation = "destination-out"; - context.fillStyle = "#000000"; - for (const image of pageImages) { - if (!image) continue; - // Get image bounds in PDF coordinates - const left = image.left ?? image.x ?? 0; - const bottom = image.bottom ?? image.y ?? 0; - const width = - image.width ?? Math.max((image.right ?? left) - left, 0); - const height = - image.height ?? Math.max((image.top ?? bottom) - bottom, 0); - const _right = left + width; - const top = bottom + height; - - // Convert to canvas coordinates (PDF origin is bottom-left, canvas is top-left) - const canvasX = left * scale; - const canvasY = canvas.height - top * scale; - const canvasWidth = width * scale; - const canvasHeight = height * scale; - context.fillRect(canvasX, canvasY, canvasWidth, canvasHeight); - } - context.restore(); - } - } catch (imageError) { - console.warn( - "[PdfTextEditor] Failed to strip images from preview", - imageError, - ); - } - const dataUrl = canvas.toDataURL("image/png"); - page.cleanup(); - if (previewRequestIdRef.current !== currentToken) { - return; - } - previewScaleRef.current.set(pageIndex, scale); - setPagePreviews((prev) => { - const next = new Map(prev); - next.set(pageIndex, dataUrl); - return next; - }); - } catch (error) { - console.warn("[PdfTextEditor] Failed to render page preview", error); - } finally { - previewRenderingRef.current.delete(pageIndex); + } + // The document may have been reloaded while the image decoded; bail + // rather than insert against geometry from the wrong document. + if (store.document !== doc) return; + // Insert onto the page currently in view, read from fresh store state. + const pages = store.getState().pages; + const visibleIndex = visiblePageNumber(); + const page = pages.find((p) => p.pageIndex === visibleIndex) ?? pages[0]; + if (!page) return; + const w = page.width * INSERTED_IMAGE_RATIO; + const h = w * (decoded.height / decoded.width); + // Centre in the VISIBLE (display) page, then invert the CropBox/rotation + // transform to raw PDF space (commands store raw coords). + const ll = DisplayTransform.fromData(page.display).invert( + (page.width - w) / 2, + (page.height - h) / 2, + ); + const cmd = new InsertImageCommand({ + pageIndex: page.pageIndex, + rgba: decoded.data.data, + pixelWidth: decoded.width, + pixelHeight: decoded.height, + x: ll.x, + y: ll.y, + width: w, + height: h, + jpegBytes, + }); + store.dispatch(cmd); + if (cmd.insertedImageId) { + store.selection.selectImage(cmd.insertedImageId); + } else { + store.setError( + t( + "pdfTextEditor.error.insertImage", + "Could not insert the selected image.", + ), + ); } }, - [hasVectorPreview, imagesByPage], + [store, t], ); - // Re-group text when grouping mode changes without forcing a full reload - useEffect(() => { - const currentDocument = loadedDocumentRef.current; - if (currentDocument) { - resetToDocument(currentDocument, groupingMode); - } - }, [groupingMode, resetToDocument]); + /** Text of the object-level selection, or null when it carries none. */ + const getSelectedText = useCallback((): string | null => { + const ids = store.selection.value.runIds; + if (ids.length === 0) return null; + const texts = store + .getState() + .pages.flatMap((p) => p.runs) + .filter((r) => ids.includes(r.id)) + .map((r) => r.text); + return texts.length === 0 ? null : texts.join("\n"); + }, [store]); - const viewData = useMemo( - () => ({ - document: loadedDocument, - groupsByPage, - imagesByPage, - pagePreviews, - selectedPage, - dirtyPages, - hasDocument, - hasVectorPreview, - fileName, - errorMessage, - isGeneratingPdf, - isSavingToWorkbench, - isConverting, - conversionProgress, - hasChanges, - forceSingleTextElement, - groupingMode, - autoScaleText, - onAutoScaleTextChange: setAutoScaleText, - requestPagePreview, - onSelectPage: handleSelectPage, - onGroupEdit: handleGroupTextChange, - onGroupDelete: handleGroupDelete, - onImageTransform: handleImageTransform, - onImageReset: handleImageReset, - onReset: handleResetEdits, - onDownloadJson: handleDownloadJson, - onGeneratePdf: handleGeneratePdf, - onGeneratePdfForNavigation: async () => { - // Generate PDF without triggering tool completion - await handleGeneratePdf(true); - }, - onSaveToWorkbench: handleSaveToWorkbench, - onForceSingleTextElementChange: setForceSingleTextElement, - onGroupingModeChange: setGroupingMode, - onMergeGroups: handleMergeGroups, - onUngroupGroup: handleUngroupGroup, - onLoadFile: handleLoadFileFromDropzone, - }), - [ - handleMergeGroups, - handleUngroupGroup, - handleImageTransform, - handleSaveToWorkbench, - imagesByPage, - isSavingToWorkbench, - pagePreviews, - dirtyPages, - errorMessage, - fileName, - groupsByPage, - handleDownloadJson, - handleGeneratePdf, - handleGroupTextChange, - handleGroupDelete, - handleImageReset, - handleResetEdits, - handleSelectPage, - hasChanges, - hasDocument, - hasVectorPreview, - isGeneratingPdf, - isConverting, - conversionProgress, - loadedDocument, - selectedPage, - forceSingleTextElement, - groupingMode, - autoScaleText, - requestPagePreview, - setForceSingleTextElement, - handleLoadFileFromDropzone, - ], - ); + const hasSelection = useCallback(() => { + const s = store.selection.value; + return s.runIds.length > 0 || s.imageIds.length > 0; + }, [store]); - const latestViewDataRef = useRef(viewData); - latestViewDataRef.current = viewData; - - // Trigger initial image loading in lazy mode - useEffect(() => { - if (isLazyMode && loadedDocument) { - void loadImagesForPage(selectedPage); - } - }, [isLazyMode, loadedDocument, selectedPage, loadImagesForPage]); - - useEffect(() => { - if (!autoLoadFile) { - autoLoadKeyRef.current = null; - sourceFileIdRef.current = null; - return; - } - - if (navigationState.selectedTool !== "pdfTextEditor") { - return; - } - - const fileKey = getAutoLoadKey(autoLoadFile); - if (autoLoadKeyRef.current === fileKey) { - return; - } - - autoLoadKeyRef.current = fileKey; - // Capture the source file ID for save-to-workbench functionality - sourceFileIdRef.current = autoLoadFile.fileId ?? null; - void handleLoadFile(autoLoadFile); - }, [autoLoadFile, navigationState.selectedTool, handleLoadFile]); - - // Auto-navigate to workbench when tool is selected - const hasAutoOpenedWorkbenchRef = useRef(false); - useEffect(() => { - if (navigationState.selectedTool !== "pdfTextEditor") { - hasAutoOpenedWorkbenchRef.current = false; - return; - } - - if (hasAutoOpenedWorkbenchRef.current) { - return; - } - - hasAutoOpenedWorkbenchRef.current = true; - // Use timeout to ensure registration effect has run first - setTimeout(() => { - navigationActions.setWorkbench(WORKBENCH_ID); - }, 0); - }, [navigationActions, navigationState.selectedTool]); - - // Register workbench view (re-runs when dependencies change) - useEffect(() => { - registerCustomWorkbenchView({ - id: WORKBENCH_VIEW_ID, - workbenchId: WORKBENCH_ID, - label: viewLabel, - icon: , - component: PdfTextEditorView, - }); - setLeftPanelView("toolContent"); - setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, latestViewDataRef.current); - }, [ - registerCustomWorkbenchView, - setCustomWorkbenchViewData, - setLeftPanelView, - viewLabel, - ]); - - // Cleanup ONLY on component unmount (not on re-renders) - useEffect(() => { - return () => { - // Clear backend cache when leaving the tool - const jobId = cachedJobIdRef.current; - if (jobId) { - console.log( - `[PdfTextEditor] Cleaning up cached document on unmount: ${jobId}`, + // Paste: create a fresh InsertTextCommand on the currently-visible page, + // positioned in roughly the centre. + const insertPastedText = useCallback( + (text: string, stripFormatting: boolean) => { + const doc = store.document; + if (!doc) return; + // `stripFormatting` is honoured by normalising line endings and + // collapsing leading/trailing whitespace. + const normalised = stripFormatting + ? text.replace(/\r\n?/g, "\n").trim() + : text.replace(/\r\n?/g, "\n"); + if (!normalised) return; + // Find the visible page (Ctrl+End behaves the same way). + const stage = document.querySelector( + '[data-testid="pdf-editor-stage"]', + ); + const stageRect = stage?.getBoundingClientRect(); + const stageCentreY = stageRect ? stageRect.top + stageRect.height / 2 : 0; + let pageIndex = 0; + let bestDist = Infinity; + for (const p of doc.loadedPages()) { + const el = document.querySelector( + `[data-testid="pdf-editor-page-${p.index}"]`, ); - apiClient - .post(`/api/v1/convert/pdf/text-editor/clear-cache/${jobId}`) - .catch((error) => { - console.warn( - "[PdfTextEditor] Failed to clear cache on unmount:", - error, - ); - }); + if (!el) continue; + const r = el.getBoundingClientRect(); + const centre = r.top + r.height / 2; + const dist = Math.abs(centre - stageCentreY); + if (dist < bestDist) { + bestDist = dist; + pageIndex = p.index; + } } - clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID); - unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID); - setLeftPanelView("toolPicker"); - }; - }, []); // Empty deps = cleanup only on unmount + const page = doc.page(pageIndex); + // Position roughly at the page centre, biased toward the upper third so + // multi-line paste has room to flow downward. + const anchor = page.display.invert( + page.width / 2 - 80, + page.height * 0.55, + ); + const cmd = new InsertTextCommand({ + pageIndex, + x: anchor.x, + y: anchor.y, + text: normalised, + }); + store.dispatch(cmd); + if (cmd.insertedRunId) store.selection.selectOne(cmd.insertedRunId); + }, + [store], + ); - // Note: Compare tool doesn't auto-force workbench, and neither should we - // The workbench should be set when the tool is selected via proper channels - // (tool registry, tool picker, etc.) - not forced here + const handleFindNext = useCallback((reverse: boolean) => { + setFindOpen(true); + const button = document.querySelector( + reverse + ? '[data-testid="pdf-editor-find-prev"]' + : '[data-testid="pdf-editor-find-next"]', + ); + button?.click(); + }, []); - const lastSentViewDataRef = useRef(null); + const handleEscape = useCallback(() => { + store.selection.clear(); + store.setMode("select"); + setHelpOpen(false); + setFindOpen(false); + }, [store]); - useEffect(() => { - if (lastSentViewDataRef.current === viewData) { - return; + const handleUngroupSelection = useCallback(() => { + const doc = store.document; + if (!doc) return; + const ids = store.selection.value.runIds; + // Snapshot the target runs first - dispatching mutates page.runs, and + // the ungroup replaces the paragraph run with per-line runs. + const targets: Array<{ pageIndex: number; runId: string }> = []; + for (const pageIdx of doc.loadedPages().map((p) => p.index)) { + for (const r of doc.page(pageIdx).runs) { + if (!ids.includes(r.id)) continue; + if (r.paragraphMemberPtrs.length < 2) continue; + targets.push({ pageIndex: pageIdx, runId: r.id }); + } } - lastSentViewDataRef.current = viewData; - setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, viewData); - }, [setCustomWorkbenchViewData, viewData]); + const resultIds: string[] = []; + for (const t of targets) { + const cmd = new UngroupParagraphCommand(t); + store.dispatch(cmd); + resultIds.push(...cmd.resultRunIds); + } + // Reconcile selection against the new run model so the toolbar keeps + // acting on real runs instead of the now-removed paragraph ids. + if (resultIds.length > 0) store.selection.selectMany(resultIds); + else store.selection.clear(); + }, [store]); - // Render the sidebar with settings while editing happens in the custom workbench view. - return ; -}; + const handleMergeSelection = useCallback(() => { + const doc = store.document; + if (!doc) return; + const selectedIds = new Set(store.selection.value.runIds); + if (selectedIds.size < 2) return; + const byPage = new Map(); + for (const page of doc.loadedPages()) { + for (const r of page.runs) { + if (!selectedIds.has(r.id)) continue; + const list = byPage.get(r.pageIndex) ?? []; + list.push(r.id); + byPage.set(r.pageIndex, list); + } + } + // Collect every page's new representative, then select them all once - + // selecting inside the loop left only the last page's merge selected. + const reps: string[] = []; + for (const [pageIndex, runIds] of byPage) { + if (runIds.length < 2) continue; + const cmd = new MergeRunsCommand({ pageIndex, runIds }); + store.dispatch(cmd); + if (cmd.representativeRunId) reps.push(cmd.representativeRunId); + } + if (reps.length > 0) store.selection.selectMany(reps); + }, [store]); -(PdfTextEditor as ToolComponent).tool = () => { - throw new Error("PDF Text Editor does not support automation operations."); -}; + useEditorKeyboardShortcuts({ + store, + onUndo: useCallback(() => store.undo(), [store]), + onRedo: useCallback(() => store.redo(), [store]), + onSave: handleSave, + onDelete: sel.deleteSelection, + onDuplicate: sel.duplicateFirstSelected, + onSelectAll: useCallback(() => { + // Pages past the eager window hold no runs until they scroll into view, + // so reading the model as-is would select only part of the document. + ensureAllPagesRead(store); + const ids = store + .getState() + .pages.flatMap((p) => p.runs.map((r) => r.id)); + if (ids.length > 0) store.selection.selectMany(ids); + }, [store]), + onToggleHelp: useCallback(() => setHelpOpen((v) => !v), []), + onOpenFind: useCallback(() => setFindOpen(true), []), + onFindNext: handleFindNext, + onEscape: handleEscape, + onMergeSelection: handleMergeSelection, + }); -(PdfTextEditor as ToolComponent).getDefaultParameters = () => ({ - groups: [], -}); + useEditorClipboard({ + hasSelection, + getSelectedText, + deleteSelection: sel.deleteSelection, + insertPastedText, + }); -export default PdfTextEditor as ToolComponent; + const canGroup = selection.runIds.length >= 2; + const canUngroup = (() => { + if (selection.runIds.length !== 1) return false; + const run = state.pages + .flatMap((p) => p.runs) + .find((r) => r.id === selection.runIds[0]); + return !!run && (run.paragraphLineCount ?? 0) > 1; + })(); + const onPickPdf = useCallback( + (file: File) => { + setOpenedFileName(file.name); + // Dropped/picked from disk: no workbench file to replace yet, but claim + // it so a later workbench arrival cannot auto-open over these edits. + adoptFile(file); + setSourceFile(null); + void load(file); + }, + [adoptFile, load, setSourceFile], + ); + + const handleSubmitPassword = useCallback( + (password: string) => { + const file = store.pendingPasswordFile; + if (file) void load(file, password); + }, + [store, load], + ); + + const handleCancelPassword = useCallback( + () => store.clearPasswordPrompt(), + [store], + ); + + return ( + + {state.error && ( + + {state.error} + + )} + + {findOpen && state.hasDocument && ( + setFindOpen(false)} + /> + )} + setHelpOpen(false)} /> + setSaveRisks(null)} + /> + + store.setGroupingMode(mode)} + onSetWidthMode={(m) => store.setWidthMode(m)} + onSetShowRulers={(show) => store.setShowRulers(show)} + onOpenFind={() => setFindOpen(true)} + onShowHelp={() => setHelpOpen(true)} + addTextArmed={state.mode === "addText"} + onToggleAddText={() => + store.setMode( + store.getState().mode === "addText" ? "select" : "addText", + ) + } + onPickImage={() => + document + .querySelector( + '[data-testid="pdf-editor-image-input"]', + ) + ?.click() + } + /> + {state.hasDocument && ( + + )} + + ); +} + +/** Decode an image File to RGBA via an element + canvas. */ +function decodeImageFile( + file: File, +): Promise<{ data: ImageData; width: number; height: number }> { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + try { + const width = img.naturalWidth || img.width; + const height = img.naturalHeight || img.height; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Canvas 2D context unavailable")); + return; + } + ctx.drawImage(img, 0, 0); + resolve({ data: ctx.getImageData(0, 0, width, height), width, height }); + } catch (e) { + reject(e instanceof Error ? e : new Error(String(e))); + } finally { + URL.revokeObjectURL(url); + } + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Could not decode the selected image.")); + }; + img.src = url; + }); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/BackendResolver.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/BackendResolver.test.ts new file mode 100644 index 0000000000..c0d0e532bc --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/BackendResolver.test.ts @@ -0,0 +1,357 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** Regression coverage for `BackendResolver`'s HTTP transport. */ + +// Mock apiClient BEFORE BackendResolver imports it. +vi.mock("@app/services/apiClient", () => ({ + default: { post: vi.fn() }, +})); + +// Stub the document serializer so the prewarm path can produce PDF bytes +// without a real PDFium file-writer. +vi.mock("@app/tools/pdfTextEditor/pdfium/PdfiumSave", () => ({ + PdfiumSave: { serialize: vi.fn(() => new Uint8Array([0, 1, 2, 3])) }, +})); + +import apiClient from "@app/services/apiClient"; +import { + BackendResolver, + prewarmBackendCacheForPage, + resetBackendResolverCaches, + _clearBackendCacheForTests, + _clearPrewarmGuardForTests, +} from "@app/tools/pdfTextEditor/charcode/BackendResolver"; +import { + primeFontGlyphMap, + _clearCmapCacheForTests, +} from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import { sha256Hex } from "@app/tools/pdfTextEditor/util/sha256"; +import type { ResolverContext } from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; + +const post = apiClient.post as unknown as ReturnType; + +// Minimal stub for ResolverContext. +const fakeCtx: ResolverContext = { + module: {} as unknown as ResolverContext["module"], + pagePtr: 0, + docPtr: 0, +}; + +// Build a fake PDFium module that renders a single char on a page so the +// prewarm text-walk finds exactly one probe to fire. `char` is the Unicode. +function makeFakeModule(char: string, fontPtr: number) { + const cp = char.codePointAt(0) ?? 0; + const TEXT_PAGE = 555; + const TEXT_OBJ = 777; + return { + FPDFText_LoadPage: vi.fn(() => TEXT_PAGE), + FPDFText_ClosePage: vi.fn(), + FPDFText_CountChars: vi.fn(() => 1), + FPDFText_GetUnicode: vi.fn(() => cp), + FPDFText_GetTextObject: vi.fn(() => TEXT_OBJ), + FPDFTextObj_GetFont: vi.fn(() => fontPtr), + } as unknown as ResolverContext["module"]; +} + +// Fake PDFium module rendering an arbitrary sequence of glyphs, each with its +// own font handle. `glyphs` is a list of [char, fontPtr] in page reading order. +function makeFakeModulePage(glyphs: Array<[string, number]>) { + const TEXT_PAGE = 555; + const OBJ_BASE = 1000; + return { + FPDFText_LoadPage: vi.fn(() => TEXT_PAGE), + FPDFText_ClosePage: vi.fn(), + FPDFText_CountChars: vi.fn(() => glyphs.length), + FPDFText_GetUnicode: vi.fn( + (_tp: number, i: number) => glyphs[i][0].codePointAt(0) ?? 0, + ), + FPDFText_GetTextObject: vi.fn((_tp: number, i: number) => OBJ_BASE + i), + FPDFTextObj_GetFont: vi.fn((obj: number) => glyphs[obj - OBJ_BASE][1]), + } as unknown as ResolverContext["module"]; +} + +/** Poll until `predicate` is true (async prefetch settles) or time out. */ +async function waitUntil(predicate: () => boolean): Promise { + for (let i = 0; i < 100; i++) { + if (predicate()) return; + await new Promise((r) => setTimeout(r, 1)); + } +} + +// Install a fake editor document on window so `prewarmBackendCacheForPage` +// resolves a page + module instead of bailing on "no-editor-ctx". +function installEditorDocument( + module: ResolverContext["module"], + pagePtr: number, + docPtr: number, +) { + const doc = { + module, + docPtr, + loadedPages: () => [{ index: 0, pagePtr, docPtr }], + }; + (window as unknown as { __editor_store?: unknown }).__editor_store = { + document: doc, + }; +} + +beforeEach(() => { + post.mockReset(); + resetBackendResolverCaches(); + _clearBackendCacheForTests(); + _clearPrewarmGuardForTests(); + _clearCmapCacheForTests(); + delete (window as unknown as { __editor_store?: unknown }).__editor_store; +}); + +afterEach(() => { + post.mockReset(); + vi.restoreAllMocks(); + delete (window as unknown as { __editor_store?: unknown }).__editor_store; +}); + +describe("BackendResolver", () => { + describe("HTTP transport via shared apiClient (regression #111)", () => { + it("routes the encode POST through apiClient.post with the suppressErrorToast and skipAuthRedirect config flags", async () => { + // One glyph 'M' on the page, rendered by font handle 7. Prewarm walks + // the page, finds one probe, serializes the doc (mocked) and POSTs. + const module = makeFakeModule("M", 7); + installEditorDocument(module, 9001, 4242); + post.mockResolvedValueOnce({ data: { charcodes: [182] } }); + + await prewarmBackendCacheForPage(0); + + expect(post).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledWith( + "/api/v1/general/pdf-text-editor/encode-charcodes", + expect.objectContaining({ + pdfBase64: expect.any(String), + pageIndex: 0, + locatorChar: "M", + text: expect.stringContaining("M"), + }), + // Top-level axios config, NOT headers: handleHttpError reads + // `error.config.`, so the header spelling was inert. + { suppressErrorToast: true, skipAuthRedirect: true }, + ); + }); + + it("never calls raw fetch() (must go through apiClient)", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const module = makeFakeModule("A", 3); + installEditorDocument(module, 9002, 4242); + post.mockResolvedValueOnce({ data: { charcodes: [65] } }); + + await prewarmBackendCacheForPage(0); + + expect(post).toHaveBeenCalledTimes(1); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("swallows an HTTP/network error from apiClient.post (postCharcodes -> null, no throw)", async () => { + const module = makeFakeModule("Z", 5); + installEditorDocument(module, 9003, 4242); + // A rejected probe (e.g. a 401) must not propagate: prewarm is + // best-effort and postCharcodes' catch returns null. + post.mockRejectedValueOnce(new Error("401")); + + await expect(prewarmBackendCacheForPage(0)).resolves.toBeUndefined(); + expect(post).toHaveBeenCalledTimes(1); + }); + }); + + describe("prewarm batching + cross-font cache key", () => { + const ENDPOINT = "/api/v1/general/pdf-text-editor/encode-charcodes"; + + it("batches all of a font's page chars into ONE request (H3)", async () => { + // Two glyphs 'A','B' both rendered by font 7. Prewarm must fire ONE + // request carrying "AB", not one per char. + const module = makeFakeModulePage([ + ["A", 7], + ["B", 7], + ]); + installEditorDocument(module, 9100, 4242); + post.mockResolvedValueOnce({ data: { charcodes: [65, 66] } }); + + await prewarmBackendCacheForPage(0); + + expect(post).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledWith( + ENDPOINT, + expect.objectContaining({ text: expect.stringContaining("AB") }), + // This test's subject is the request body; the transport config is + // pinned in full by the first test in this file. + expect.objectContaining({ suppressErrorToast: true }), + ); + const sent = post.mock.calls[0][1] as { text: string }; + // Characters the page never used must be probed too, or the first time + // the user types one it misses the cache and the font is substituted. + expect(sent.text).toContain("Z"); + expect(sent.text).toContain("9"); + // Both chars cached under font 7 in request order. + const r = new BackendResolver(); + const res = r.resolve(7, "AB", { module, pagePtr: 9100, docPtr: 4242 }); + expect(res?.charcodes).toEqual([65, 66]); + }); + + it("fires one request per distinct font, not per char", async () => { + const module = makeFakeModulePage([ + ["A", 7], + ["B", 8], + ]); + installEditorDocument(module, 9101, 4242); + post.mockResolvedValue({ data: { charcodes: [1] } }); + + await prewarmBackendCacheForPage(0); + + expect(post).toHaveBeenCalledTimes(2); + }); + + it("respects the backend's `missing` list when mapping batched charcodes", async () => { + const module = makeFakeModulePage([ + ["A", 7], + ["B", 7], + ["C", 7], + ]); + installEditorDocument(module, 9102, 4242); + // Backend could encode A and C but not B: charcodes align to the + // NON-missing chars in order. + post.mockResolvedValueOnce({ + data: { charcodes: [65, 67], missing: ["B"] }, + }); + + await prewarmBackendCacheForPage(0); + + const r = new BackendResolver(); + const ctx = { module, pagePtr: 9102, docPtr: 4242 }; + expect(r.resolve(7, "A", ctx)?.charcodes).toEqual([65]); + expect(r.resolve(7, "C", ctx)?.charcodes).toEqual([67]); + // 'B' was reported missing -> cached null -> reported missing, not 67. + const b = r.resolve(7, "B", ctx); + expect(b?.charcodes).toEqual([]); + expect(b?.missing).toEqual(["B"]); + }); + + it("includes the primed font-program hash so the backend can pick the exact subset", async () => { + // The Mangum-CV corruption: PDFium names every "ABCDEF+Garamond" subset + // just "Garamond". + const fontBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + // Prime the sha cache for font 7 via the CmapResolver's safe-phase read. + const heap = new Uint8Array(1 << 12); + const primeModule = { + FPDFFont_GetFontData: ( + _f: number, + bufferPtr: number, + length: number, + outSizePtr: number, + ) => { + new DataView(heap.buffer).setInt32( + outSizePtr, + fontBytes.length, + true, + ); + if (bufferPtr !== 0 && length > 0) heap.set(fontBytes, bufferPtr); + return true; + }, + pdfium: { + wasmExports: { + malloc: (() => { + let bump = 8; + return (n: number) => { + const p = bump; + bump += n; + return p; + }; + })(), + free: () => {}, + }, + getValue: (ptr: number) => + new DataView(heap.buffer).getInt32(ptr, true), + HEAPU8: heap, + }, + } as unknown as ResolverContext["module"]; + primeFontGlyphMap(7, primeModule); + + const module = makeFakeModule("M", 7); + installEditorDocument(module, 9050, 4242); + post.mockResolvedValueOnce({ data: { charcodes: [33] } }); + + await prewarmBackendCacheForPage(0); + + expect(post).toHaveBeenCalledWith( + "/api/v1/general/pdf-text-editor/encode-charcodes", + expect.objectContaining({ + text: expect.stringContaining("M"), + fontSha256: sha256Hex(fontBytes), + }), + // This test's subject is the request body; the transport config is + // pinned in full by the first test in this file. + expect.objectContaining({ suppressErrorToast: true }), + ); + }); + + it("does not re-POST every keystroke when the queried font differs from the rendering font (H2)", async () => { + // 'A' is rendered by font 7 on the page, but the run is editing under a + // borrowed font handle 99. + const module = makeFakeModulePage([["A", 7]]); + installEditorDocument(module, 9200, 4242); + post.mockResolvedValue({ data: { charcodes: [65] } }); + const r = new BackendResolver(); + const ctx: ResolverContext = { module, pagePtr: 9200, docPtr: 4242 }; + + r.resolve(99, "A", ctx); // miss under font 99 -> kicks prefetch + await waitUntil(() => post.mock.calls.length >= 1); + const callsAfterFirst = post.mock.calls.length; + + // More keystrokes for the same (font 99, 'A'): the null sentinel must + // short-circuit resolve() so no further prefetch fires. + r.resolve(99, "A", ctx); + r.resolve(99, "A", ctx); + await new Promise((res) => setTimeout(res, 5)); + expect(post.mock.calls.length).toBe(callsAfterFirst); + + // The real charcode landed under the rendering font 7. + expect(r.resolve(7, "A", ctx)?.charcodes).toEqual([65]); + }); + }); + + describe("cache semantics", () => { + it("resolve() with an empty text returns null", () => { + const r = new BackendResolver(); + expect(r.resolve(1, "", fakeCtx)).toBeNull(); + }); + + it("resolve() with a 0 font returns null", () => { + const r = new BackendResolver(); + expect(r.resolve(0, "M", fakeCtx)).toBeNull(); + }); + }); + + describe("whitespace is never charcode-reused (mushroom „ bug)", () => { + it("resolve() reports a space as missing and never round-trips it", async () => { + const r = new BackendResolver(); + const result = r.resolve(99, " ", fakeCtx); + // Space must be reported missing, NOT looked up / cached / sent to the + // backend. + expect(result?.missing).toEqual([" "]); + expect(result?.charcodes).toEqual([]); + await Promise.resolve(); + await Promise.resolve(); + expect(post).not.toHaveBeenCalled(); + }); + + it("resolve() splits a mixed chunk: real chars miss the cache, whitespace stays a gap", async () => { + const r = new BackendResolver(); + // "a b" - 'a' and 'b' are genuine cache misses (kick a prefetch), the + // space is reported missing WITHOUT being counted as a prefetch miss. + const result = r.resolve(99, "a b", fakeCtx); + expect(result?.missing).toEqual(["a", " ", "b"]); + expect(result?.charcodes).toEqual([]); + // The prefetch (for 'a') bails before HTTP in this no-window-doc env, + // but crucially the space alone must never be the reason it fires. + const spaceOnly = r.resolve(99, "\t\n ", fakeCtx); + expect(spaceOnly?.charcodes).toEqual([]); + expect(spaceOnly?.missing).toEqual(["\t", "\n", " "]); + }); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/BulletGrouping.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/BulletGrouping.test.ts new file mode 100644 index 0000000000..aa2e050c72 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/BulletGrouping.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { LineGrouper } from "@app/tools/pdfTextEditor/pdfium/LineGrouper"; +import { ParagraphGrouper } from "@app/tools/pdfTextEditor/pdfium/ParagraphGrouper"; + +// Reproduces the "Plus Many More" two-column bulleted-list geometry from +// public/samples/Sample.pdf page 3: bullets are separate text objects. + +let ptr = 1000; +function mkRun(opts: { + x: number; + width: number; + f: number; + fs: number; + text: string; +}): TextRun { + return new TextRun({ + id: `r${ptr}`, + pageIndex: 0, + bounds: { x: opts.x, y: opts.f, width: opts.width, height: opts.fs }, + matrix: { a: opts.fs, b: 0, c: 0, d: opts.fs, e: opts.x, f: opts.f }, + text: opts.text, + fontId: "pdf:1:Test", + fontSize: opts.fs, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + pdfiumObjPtr: ptr++, + containerPtr: 0, + }); +} + +function group(runs: TextRun[]): TextRun[] { + const page = new Page({ index: 0, pagePtr: 1, width: 600, height: 800 }); + page.setRuns(runs); + page.loaded = true; + LineGrouper.apply(page); + ParagraphGrouper.apply(page); + return page.runs; +} + +describe("bullet-to-item grouping (Plus Many More)", () => { + it("pairs each bullet with its own item and keeps columns separate", () => { + const runs: TextRun[] = [ + // Bottom "Plus Many More" section: bullet fs13.5, item fs11.3, bullet + // baseline ~2.3pt above the item, ~14-17pt indent. + mkRun({ x: 66, width: 3, f: 178.9, fs: 13.5, text: "• " }), + mkRun({ + x: 83, + width: 111, + f: 176.6, + fs: 11.3, + text: "OCR text recognition", + }), + mkRun({ x: 66, width: 3, f: 153.4, fs: 13.5, text: "• " }), + mkRun({ x: 83, width: 80, f: 151.1, fs: 11.3, text: "Compress PDFs" }), + // RIGHT column (gutter ~245pt to the right) + mkRun({ x: 311, width: 3, f: 178.9, fs: 13.5, text: "• " }), + mkRun({ x: 328, width: 101, f: 176.6, fs: 11.3, text: "Flatten forms" }), + mkRun({ x: 311, width: 3, f: 153.4, fs: 13.5, text: "• " }), + mkRun({ + x: 328, + width: 95, + f: 151.1, + fs: 11.3, + text: "PDF/A conversion", + }), + ]; + const out = group(runs); + + // No orphan bullet-only run (the reported bug = a stacked bullet column). + const orphan = out.find( + (r) => /^[\s•]+$/.test(r.text) && (r.text.match(/•/g) ?? []).length >= 2, + ); + expect(orphan, `orphan bullet run: ${orphan?.text}`).toBeUndefined(); + + // Each item's run starts with the bullet and does not swallow a foreign item. + const ocr = out.find((r) => /OCR\s+text/.test(r.text)); + expect(ocr, "OCR run exists").toBeTruthy(); + expect(ocr!.text.trimStart().startsWith("•")).toBe(true); + expect(ocr!.text).not.toMatch(/Flatten/); // not merged across the gutter + + const flatten = out.find((r) => /Flatten\s+forms/.test(r.text)); + expect(flatten, "Flatten run exists").toBeTruthy(); + expect(flatten!.text.trimStart().startsWith("•")).toBe(true); + expect(flatten!.text).not.toMatch(/OCR/); + }); + + it("pairs same-baseline bullets (upper lists) with their item", () => { + const runs: TextRun[] = [ + mkRun({ x: 66, width: 3, f: 642.4, fs: 10.5, text: "• " }), + mkRun({ + x: 80, + width: 91, + f: 642.4, + fs: 10.5, + text: "Merge & split PDFs", + }), + ]; + const out = group(runs); + const merge = out.find((r) => /Merge/.test(r.text)); + expect(merge!.text.trimStart().startsWith("•")).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ChangeZOrderCommand.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ChangeZOrderCommand.test.ts new file mode 100644 index 0000000000..d55cce098c --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ChangeZOrderCommand.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { ChangeZOrderCommand } from "@app/tools/pdfTextEditor/commands/ChangeZOrderCommand"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; +import { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +// Fake PDFium module backing the page object list with a plain array of +// pointers (index 0 = painted first = bottom, last = top). +function fakeDoc(objs: number[], page: Page): EditorDocument { + const module = { + FPDFPage_CountObjects: () => objs.length, + FPDFPage_GetObject: (_p: number, i: number) => objs[i] ?? 0, + FPDFPage_RemoveObject: (_p: number, ptr: number) => { + const i = objs.indexOf(ptr); + if (i >= 0) objs.splice(i, 1); + return true; + }, + FPDFPage_InsertObjectAtIndex: (_p: number, ptr: number, idx: number) => { + objs.splice(idx, 0, ptr); + return true; + }, + }; + return { module, page: () => page } as unknown as EditorDocument; +} + +function pageWithImage(ptr: number): Page { + const page = new Page({ index: 0, pagePtr: 1, width: 100, height: 100 }); + page.setImages([ + new ImageObject({ + id: "img1", + pageIndex: 0, + pdfiumObjPtr: ptr, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + matrix: { a: 10, b: 0, c: 0, d: 10, e: 0, f: 0 }, + }), + ]); + return page; +} + +describe("ChangeZOrderCommand", () => { + it("bring-to-front moves the object to the top AND triggers re-render + regen", () => { + const page = pageWithImage(42); + const objs = [42, 7, 9]; // image (42) at bottom, covered by 7 and 9 + const doc = fakeDoc(objs, page); + const rev0 = page.revision; + + new ChangeZOrderCommand({ + pageIndex: 0, + imageId: "img1", + mode: "to-front", + }).apply(doc); + + expect(objs).toEqual([7, 9, 42]); // now painted last = on top + // Without these the reorder is invisible (no bitmap re-render) and lost on + // save (content stream never regenerated) - the reported bug. + expect(page.revision).toBeGreaterThan(rev0); + expect(page.needsGenerateContent).toBe(true); + }); + + it("send-to-back moves the object to the bottom", () => { + const page = pageWithImage(42); + const objs = [7, 9, 42]; // image on top + const doc = fakeDoc(objs, page); + + new ChangeZOrderCommand({ + pageIndex: 0, + imageId: "img1", + mode: "to-back", + }).apply(doc); + + expect(objs).toEqual([42, 7, 9]); // painted first = underneath + }); + + it("revert restores the original index and re-renders again", () => { + const page = pageWithImage(42); + const objs = [42, 7, 9]; + const doc = fakeDoc(objs, page); + const cmd = new ChangeZOrderCommand({ + pageIndex: 0, + imageId: "img1", + mode: "to-front", + }); + cmd.apply(doc); + expect(objs).toEqual([7, 9, 42]); + const revAfterApply = page.revision; + + cmd.revert(doc); + expect(objs).toEqual([42, 7, 9]); // back where it started + expect(page.revision).toBeGreaterThan(revAfterApply); + expect(page.needsGenerateContent).toBe(true); + }); + + it("already-on-top bring-to-front is a no-op (no spurious revision bump)", () => { + const page = pageWithImage(42); + const objs = [7, 9, 42]; // already last + const doc = fakeDoc(objs, page); + const rev0 = page.revision; + + new ChangeZOrderCommand({ + pageIndex: 0, + imageId: "img1", + mode: "to-front", + }).apply(doc); + + expect(objs).toEqual([7, 9, 42]); + expect(page.revision).toBe(rev0); + }); + + it("send-to-back moves a NON-CONTIGUOUS member group whose bottom sits at index 0", () => { + // Run leaf objects M1=5, M2=9 at page indices [0, 2] with unrelated X=7 + // between them: [M1, X, M2]. + const page = new Page({ index: 0, pagePtr: 1, width: 100, height: 100 }); + const run = new TextRun({ + id: "run1", + pageIndex: 0, + pdfiumObjPtr: 5, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, + text: "hi", + fontId: "base14:Helvetica", + fontSize: 12, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + }); + run.paragraphLeafPtrs = [5, 9]; + run.paragraphLeafContainers = [0, 0]; + page.setRuns([run]); + const objs = [5, 7, 9]; + const doc = fakeDoc(objs, page); + + new ChangeZOrderCommand({ + pageIndex: 0, + runId: "run1", + mode: "to-back", + }).apply(doc); + + expect(objs).toEqual([5, 9, 7]); // both members now under X + expect(page.needsGenerateContent).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/CmapResolver.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/CmapResolver.test.ts new file mode 100644 index 0000000000..ba0af2b6dd --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/CmapResolver.test.ts @@ -0,0 +1,286 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +// Unit coverage for the embedded-font cmap strategy. `parseTrueTypeCmap` and +// `CmapResolver.resolve` had ZERO direct test coverage: the only path. + +import { + CmapResolver, + parseTrueTypeCmap, + primeFontGlyphMap, + getCachedFontProgramSha256, + _clearCmapCacheForTests, +} from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import { sha256Hex } from "@app/tools/pdfTextEditor/util/sha256"; +import type { ResolverContext } from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; + +// Build a minimal TrueType sfnt carrying a single format-4 cmap subtable that +// maps each [codepoint => glyphId] entry. +function buildSfntWithFormat4(entries: Array<[number, number]>): Uint8Array { + const sorted = [...entries].sort((a, b) => a[0] - b[0]); + const segCount = sorted.length + 1; // + terminal 0xFFFF segment + const segCountX2 = segCount * 2; + + // format length language segCountX2 searchRange entrySelector rangeShift = 14 + // header bytes, then the 4 parallel arrays of segCountX2 bytes each. + const subtableLen = 14 + 2 + segCountX2 * 4; + + const HEADER = 12; + const TABLE_RECORD = 16; + const cmapStart = HEADER + TABLE_RECORD; // 28 + const subtableStart = cmapStart + 4 + 8; // cmap hdr(4) + 1 encoding rec(8) = 40 + const total = subtableStart + subtableLen; + + const buf = new ArrayBuffer(total); + const dv = new DataView(buf); + + // sfnt header: scaler 0x00010000 (TrueType), numTables=1. + dv.setUint32(0, 0x00010000); + dv.setUint16(4, 1); + // searchRange / entrySelector / rangeShift left 0 (unused by parser). + + // Single table record: tag 'cmap', checksum 0, offset, length. + dv.setUint32(HEADER, 0x636d6170); // 'cmap' + dv.setUint32(HEADER + 4, 0); + dv.setUint32(HEADER + 8, cmapStart); + dv.setUint32(HEADER + 12, 4 + 8 + subtableLen); + + // cmap header: version 0, numSubtables 1. + dv.setUint16(cmapStart, 0); + dv.setUint16(cmapStart + 2, 1); + // encoding record: platform 3 (Microsoft), encoding 1 (Unicode BMP), + // offset from cmap start to the subtable. + dv.setUint16(cmapStart + 4, 3); + dv.setUint16(cmapStart + 6, 1); + dv.setUint32(cmapStart + 8, subtableStart - cmapStart); + + // format-4 subtable. + const o = subtableStart; + dv.setUint16(o, 4); // format + dv.setUint16(o + 2, subtableLen); // length + dv.setUint16(o + 4, 0); // language + dv.setUint16(o + 6, segCountX2); + dv.setUint16(o + 8, 0); // searchRange (unused by parser) + dv.setUint16(o + 10, 0); // entrySelector + dv.setUint16(o + 12, 0); // rangeShift + + const endCodesOff = o + 14; + const startCodesOff = endCodesOff + segCountX2 + 2; // + reservedPad + const idDeltasOff = startCodesOff + segCountX2; + const idRangeOffsetsOff = idDeltasOff + segCountX2; + + sorted.forEach(([code, gid], i) => { + dv.setUint16(endCodesOff + i * 2, code); + dv.setUint16(startCodesOff + i * 2, code); + dv.setInt16(idDeltasOff + i * 2, (gid - code) & 0xffff); + dv.setUint16(idRangeOffsetsOff + i * 2, 0); + }); + // Terminal segment: 0xFFFF..0xFFFF, idDelta 1, idRangeOffset 0. + const t = sorted.length; + dv.setUint16(endCodesOff + t * 2, 0xffff); + dv.setUint16(startCodesOff + t * 2, 0xffff); + dv.setInt16(idDeltasOff + t * 2, 1); + dv.setUint16(idRangeOffsetsOff + t * 2, 0); + // reservedPad already zero. + + return new Uint8Array(buf); +} + +// Fake PDFium module whose `FPDFFont_GetFontData` copies `fontBytes` into a +// scratch heap, mirroring the two-call contract `buildCmap` uses. +function makeFontDataModule( + fontBytes: Uint8Array | null, +): ResolverContext["module"] { + const heap = new Uint8Array(1 << 16); + let bump = 8; + const malloc = (n: number): number => { + const ptr = bump; + bump += n; + return ptr; + }; + const getValue = (ptr: number, _type: string): number => { + return new DataView(heap.buffer).getInt32(ptr, true); + }; + const setI32 = (ptr: number, v: number) => + new DataView(heap.buffer).setInt32(ptr, v, true); + + const FPDFFont_GetFontData = ( + _font: number, + bufferPtr: number, + length: number, + outSizePtr: number, + ): boolean => { + if (!fontBytes) return false; + if (bufferPtr === 0 || length === 0) { + // Size-probe call. + setI32(outSizePtr, fontBytes.length); + return true; + } + heap.set(fontBytes.subarray(0, length), bufferPtr); + setI32(outSizePtr, fontBytes.length); + return true; + }; + + return { + FPDFFont_GetFontData, + pdfium: { + wasmExports: { malloc, free: (_p: number) => {} }, + getValue, + HEAPU8: heap, + }, + } as unknown as ResolverContext["module"]; +} + +beforeEach(() => { + _clearCmapCacheForTests(); +}); + +describe("parseTrueTypeCmap", () => { + it("parses a format-4 subtable into a Unicode->glyphId map", () => { + // 'A' (65) -> 3, 'M' (77) -> 7. + const bytes = buildSfntWithFormat4([ + [65, 3], + [77, 7], + ]); + const map = parseTrueTypeCmap(bytes); + expect(map).not.toBeNull(); + expect(map?.get(65)).toBe(3); + expect(map?.get(77)).toBe(7); + // Unmapped codepoints are absent (not zero). + expect(map?.get(66)).toBeUndefined(); + }); + + it("returns null for a non-sfnt blob", () => { + const bytes = new Uint8Array(64); + bytes.fill(0xab); // bogus scaler type, not 0x00010000 / OTTO / true / typ1 + expect(parseTrueTypeCmap(bytes)).toBeNull(); + }); + + it("returns null for a truncated buffer (<12 bytes)", () => { + expect(parseTrueTypeCmap(new Uint8Array([0, 1, 0, 0]))).toBeNull(); + }); +}); + +describe("CmapResolver.resolve()", () => { + const FONT = 1; + + it("returns charcodes for covered chars and reports uncovered chars as missing", () => { + const module = makeFontDataModule( + buildSfntWithFormat4([ + [65, 3], + [77, 7], + ]), + ); + primeFontGlyphMap(FONT, module); + + const ctx: ResolverContext = { module, pagePtr: 0, docPtr: 0 }; + const result = new CmapResolver().resolve(FONT, "AMZ", ctx); + expect(result).not.toBeNull(); + // 'A'->3 and 'M'->7 are covered; 'Z' (90) is not in the cmap. + expect(result?.charcodes).toEqual([3, 7]); + expect(result?.coverage).toBe(2); + expect(result?.missing).toEqual(["Z"]); + }); + + it("returns null when font is 0", () => { + const module = makeFontDataModule(null); + const ctx: ResolverContext = { module, pagePtr: 0, docPtr: 0 }; + expect(new CmapResolver().resolve(0, "A", ctx)).toBeNull(); + }); + + it("reports 'cmap unavailable' when the font has no parseable cmap", () => { + // FPDFFont_GetFontData returns false -> buildCmap caches null. + const module = makeFontDataModule(null); + primeFontGlyphMap(FONT, module); + + const ctx: ResolverContext = { module, pagePtr: 0, docPtr: 0 }; + const result = new CmapResolver().resolve(FONT, "AB", ctx); + expect(result?.charcodes).toEqual([]); + expect(result?.coverage).toBe(0); + expect(result?.missing).toEqual(["A", "B"]); + expect(result?.note).toBe("cmap unavailable for this font"); + }); +}); + +describe("font program hash (cross-subset identity)", () => { + const FONT = 21; + + it("caches the program bytes' SHA-256 at prime time", () => { + // PDFium reports every "ABCDEF+Family" subset as bare "Family". + const bytes = buildSfntWithFormat4([[65, 3]]); + const module = makeFontDataModule(bytes); + primeFontGlyphMap(FONT, module); + expect(getCachedFontProgramSha256(FONT)).toBe(sha256Hex(bytes)); + }); + + it("hashes fonts whose cmap is unparseable (CFF/Type1 programs)", () => { + // A non-sfnt program yields no glyph map but is still a valid identity. + const bytes = new Uint8Array(64).fill(0xab); + const module = makeFontDataModule(bytes); + primeFontGlyphMap(FONT, module); + expect(getCachedFontProgramSha256(FONT)).toBe(sha256Hex(bytes)); + }); + + it("returns null for fonts with no readable data and after reset", () => { + const module = makeFontDataModule(null); + primeFontGlyphMap(FONT, module); + expect(getCachedFontProgramSha256(FONT)).toBeNull(); + + const bytes = buildSfntWithFormat4([[65, 3]]); + primeFontGlyphMap(31, makeFontDataModule(bytes)); + expect(getCachedFontProgramSha256(31)).toBe(sha256Hex(bytes)); + // Doc switch clears the cache - PDFium reuses pointers across documents. + _clearCmapCacheForTests(); + expect(getCachedFontProgramSha256(31)).toBeNull(); + }); +}); + +describe("parseFormat4 entry cap (I11)", () => { + it("never builds more than the MAX_CMAP_ENTRIES (70k) cap from a single segment", () => { + // One segment spanning a huge range with idRangeOffset=0 would map every + // codepoint in [start,end]. The I11 cap must stop it well under the span. + const segCountX2 = 4; // 2 segments: the big range + terminal 0xFFFF + const subtableLen = 14 + 2 + segCountX2 * 4; + const cmapStart = 28; + const subtableStart = cmapStart + 4 + 8; + const total = subtableStart + subtableLen; + const buf = new ArrayBuffer(total); + const dv = new DataView(buf); + + dv.setUint32(0, 0x00010000); + dv.setUint16(4, 1); + dv.setUint32(12, 0x636d6170); + dv.setUint32(12 + 8, cmapStart); + dv.setUint32(12 + 12, 4 + 8 + subtableLen); + dv.setUint16(cmapStart, 0); + dv.setUint16(cmapStart + 2, 1); + dv.setUint16(cmapStart + 4, 3); + dv.setUint16(cmapStart + 6, 1); + dv.setUint32(cmapStart + 8, subtableStart - cmapStart); + + const o = subtableStart; + dv.setUint16(o, 4); + dv.setUint16(o + 2, subtableLen); + dv.setUint16(o + 6, segCountX2); + const endCodesOff = o + 14; + const startCodesOff = endCodesOff + segCountX2 + 2; + const idDeltasOff = startCodesOff + segCountX2; + const idRangeOffsetsOff = idDeltasOff + segCountX2; + // Segment 0: 0x0001 .. 0xFFFE, idDelta 1 (maps every code to code+1). + dv.setUint16(endCodesOff, 0xfffe); + dv.setUint16(startCodesOff, 0x0001); + dv.setInt16(idDeltasOff, 1); + dv.setUint16(idRangeOffsetsOff, 0); + // Terminal 0xFFFF segment. + dv.setUint16(endCodesOff + 2, 0xffff); + dv.setUint16(startCodesOff + 2, 0xffff); + dv.setInt16(idDeltasOff + 2, 1); + dv.setUint16(idRangeOffsetsOff + 2, 0); + + const map = parseTrueTypeCmap(new Uint8Array(buf)); + expect(map).not.toBeNull(); + // The full span is ~65k which is under 70k, so it should map without the + // cap firing - the guarantee is it stays bounded, never unbounded. + expect((map as Map).size).toBeLessThanOrEqual(70_000); + expect((map as Map).size).toBeGreaterThan(0); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/Color.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/Color.test.ts new file mode 100644 index 0000000000..204dec5f38 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/Color.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + BLACK, + WHITE, + equalsRGBA, + parseCssColor, + toCssHex, +} from "@app/tools/pdfTextEditor/model/Color"; + +describe("Color", () => { + it("parses #rrggbb", () => { + expect(parseCssColor("#ff8800")).toEqual({ r: 255, g: 136, b: 0, a: 255 }); + }); + + it("parses #rrggbbaa", () => { + expect(parseCssColor("#11223380")).toEqual({ + r: 17, + g: 34, + b: 51, + a: 128, + }); + }); + + it("parses rgb(...)", () => { + expect(parseCssColor("rgb(10, 20, 30)")).toEqual({ + r: 10, + g: 20, + b: 30, + a: 255, + }); + }); + + it("parses rgba(...) with fractional alpha", () => { + expect(parseCssColor("rgba(10, 20, 30, 0.5)")).toEqual({ + r: 10, + g: 20, + b: 30, + a: 128, + }); + }); + + it("returns null for invalid input", () => { + expect(parseCssColor("not a colour")).toBeNull(); + expect(parseCssColor("#abc")).toBeNull(); // short hex unsupported on purpose + }); + + it("round-trips through toCssHex", () => { + const rgba = parseCssColor("#abcdef")!; + expect(toCssHex(rgba)).toBe("#abcdef"); + }); + + it("clamps and rounds when serialising", () => { + expect(toCssHex({ r: -10, g: 300, b: 0.5, a: 255 })).toBe("#00ff01"); + }); + + it("equalsRGBA respects every component", () => { + expect(equalsRGBA(BLACK, BLACK)).toBe(true); + expect(equalsRGBA(BLACK, WHITE)).toBe(false); + expect( + equalsRGBA({ r: 1, g: 2, b: 3, a: 4 }, { r: 1, g: 2, b: 3, a: 5 }), + ).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/DisplayTransform.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/DisplayTransform.test.ts new file mode 100644 index 0000000000..bc8c4ac946 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/DisplayTransform.test.ts @@ -0,0 +1,428 @@ +import { describe, it, expect } from "vitest"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; + +// Unit coverage for the raw-PDF <-> display (CropBox/rotation) transform that +// fixes the spirit-sx positioning bug. + +const CROP = { cl: 36, cb: 72, cw: 540, ch: 720 }; +const ROTATIONS = [0, 1, 2, 3]; + +function mk(rotate: number): DisplayTransform { + const { cl, cb, cw, ch } = CROP; + // displayWidth/Height swap for 90/270. + const dw = rotate % 2 === 0 ? cw : ch; + const dh = rotate % 2 === 0 ? ch : cw; + return DisplayTransform.fromCropAndRotate(cl, cb, cw, ch, rotate, dw, dh); +} + +describe("DisplayTransform", () => { + it("identity for CropBox==MediaBox, Rotate 0 (byte-exact pass-through)", () => { + const t = DisplayTransform.fromCropAndRotate(0, 0, 600, 800, 0, 600, 800); + expect(t.isIdentity).toBe(true); + expect([t.a, t.b, t.c, t.d, t.e, t.f]).toEqual([1, 0, 0, 1, 0, 0]); + for (const [px, py] of [ + [0, 0], + [123.4, 567.8], + [600, 800], + ]) { + expect(t.apply(px, py)).toEqual({ x: px, y: py }); + expect(t.invert(px, py)).toEqual({ x: px, y: py }); + } + }); + + it("apply/invert round-trip to identity for all rotations + non-zero crop", () => { + for (const r of ROTATIONS) { + const t = mk(r); + for (const [px, py] of [ + [36, 72], + [300, 500], + [576, 792], + [100.25, 240.75], + ]) { + const d = t.apply(px, py); + const back = t.invert(d.x, d.y); + expect(back.x).toBeCloseTo(px, 6); + expect(back.y).toBeCloseTo(py, 6); + } + } + }); + + it("displayed-size invariant: the CropBox maps to a (Wd,Hd) AABB anchored at the origin, swapped for 90/270", () => { + const { cl, cb, cw, ch } = CROP; + const corners: Array<[number, number]> = [ + [cl, cb], + [cl + cw, cb], + [cl, cb + ch], + [cl + cw, cb + ch], + ]; + for (const r of ROTATIONS) { + const t = mk(r); + const ds = corners.map(([px, py]) => t.apply(px, py)); + const w = + Math.max(...ds.map((d) => d.x)) - Math.min(...ds.map((d) => d.x)); + const h = + Math.max(...ds.map((d) => d.y)) - Math.min(...ds.map((d) => d.y)); + const expW = r % 2 === 0 ? cw : ch; + const expH = r % 2 === 0 ? ch : cw; + expect(w).toBeCloseTo(expW, 6); + expect(h).toBeCloseTo(expH, 6); + // The displayed AABB must lie in [0,Wd] x [0,Hd] (origin at lower-left). + expect(Math.min(...ds.map((d) => d.x))).toBeCloseTo(0, 6); + expect(Math.min(...ds.map((d) => d.y))).toBeCloseTo(0, 6); + } + }); + + it("matches PDFium ground truth for all rotations (pins orientation; det +1)", () => { + // Ground truth from the real PDFium engine for CropBox [50,20,350,370] and + // raw user-space point. + const c = { cl: 50, cb: 20, cw: 300, ch: 350 }; + const cases: Array<[number, [number, number]]> = [ + [0, [10, 330]], + [1, [330, 290]], + [2, [290, 20]], + [3, [20, 10]], + ]; + for (const [rot, [ex, ey]] of cases) { + const dw = rot % 2 === 0 ? c.cw : c.ch; + const dh = rot % 2 === 0 ? c.ch : c.cw; + const t = DisplayTransform.fromCropAndRotate( + c.cl, + c.cb, + c.cw, + c.ch, + rot, + dw, + dh, + ); + // Proper rotation/reflection-free: determinant must be +1. + expect(t.a * t.d - t.b * t.c).toBeCloseTo(1, 9); + const d = t.apply(60, 350); + expect(d.x).toBeCloseTo(ex, 4); + expect(d.y).toBeCloseTo(ey, 4); + } + }); + + it("rotate 0 is a pure crop translate", () => { + const t = mk(0); + expect(t.apply(CROP.cl + 10, CROP.cb + 20)).toEqual({ x: 10, y: 20 }); + }); + + it("applyVector/invertVector round-trip and ignore translation", () => { + for (const r of ROTATIONS) { + const t = mk(r); + const v = t.applyVector(5, -3); + const back = t.invertVector(v.x, v.y); + expect(back.x).toBeCloseTo(5, 6); + expect(back.y).toBeCloseTo(-3, 6); + // identity-rotate keeps the vector as-is. + if (r === 0) expect(v).toEqual({ x: 5, y: -3 }); + } + }); + + it("fromData / toData are lossless", () => { + const t = mk(3); + const r = DisplayTransform.fromData(t.toData()); + expect(r.toData()).toEqual(t.toData()); + expect(r.apply(100, 200)).toEqual(t.apply(100, 200)); + }); +}); + +type Rect = [number, number, number, number]; + +interface StubPage { + boundingLTRB?: Rect; + crop?: Rect; + media?: Rect; + rotate?: number; +} + +function stubModule(page: StubPage): WrappedPdfiumModule { + const heap = new Float32Array(256); + let next = 4; + const put = (ptr: number, value: number): void => { + heap[ptr >> 2] = value; + }; + const mod: Record = { + pdfium: { + wasmExports: { + malloc: (n: number): number => { + const p = next; + next += n; + return p; + }, + free: (): void => undefined, + }, + getValue: (ptr: number, type: string): number => + type === "float" ? heap[ptr >> 2] : 0, + }, + FPDFPage_GetRotation: (): number => page.rotate ?? 0, + }; + if (page.boundingLTRB) { + mod.FPDF_GetPageBoundingBox = (_p: number, rect: number): number => { + page.boundingLTRB!.forEach((v, i) => put(rect + i * 4, v)); + return 1; + }; + } + const boxReader = + (box?: Rect) => + (_p: number, l: number, b: number, r: number, t: number): number => { + if (!box) return 0; + put(l, box[0]); + put(b, box[1]); + put(r, box[2]); + put(t, box[3]); + return 1; + }; + mod.FPDFPage_GetCropBox = boxReader(page.crop); + mod.FPDFPage_GetMediaBox = boxReader(page.media); + return mod as unknown as WrappedPdfiumModule; +} + +function cropOf(t: DisplayTransform): Rect { + return [t.cropLeft, t.cropBottom, t.cropWidth, t.cropHeight]; +} + +describe("DisplayTransform.fromCropAndRotate box hygiene", () => { + it("normalises reversed corner order (negative extents) instead of inverting", () => { + const t = DisplayTransform.fromCropAndRotate( + 300, + 400, + -290, + -380, + 0, + 290, + 380, + ); + expect(cropOf(t)).toEqual([10, 20, 290, 380]); + expect(t.a * t.d - t.b * t.c).toBeCloseTo(1, 9); + expect(t.apply(10, 20)).toEqual({ x: 0, y: 0 }); + expect(t.apply(300, 400)).toEqual({ x: 290, y: 380 }); + }); + + it("normalised reversed corners agree with the equivalent forward box, all rotations", () => { + for (const r of ROTATIONS) { + const dw = r % 2 === 0 ? 290 : 380; + const dh = r % 2 === 0 ? 380 : 290; + const rev = DisplayTransform.fromCropAndRotate( + 300, + 400, + -290, + -380, + r, + dw, + dh, + ); + const fwd = DisplayTransform.fromCropAndRotate( + 10, + 20, + 290, + 380, + r, + dw, + dh, + ); + expect(rev.toData()).toEqual(fwd.toData()); + } + }); + + it("falls back to identity for degenerate boxes rather than emitting NaN", () => { + const degenerate: Array<[number, number, number, number]> = [ + [0, 0, 0, 500], + [0, 0, 400, 0], + [0, 0, 0, 0], + [10, 20, Number.NaN, 380], + [10, 20, 290, Number.POSITIVE_INFINITY], + ]; + for (const [cl, cb, cw, ch] of degenerate) { + const t = DisplayTransform.fromCropAndRotate(cl, cb, cw, ch, 1, 400, 500); + expect(t.isIdentity).toBe(true); + expect(cropOf(t)).toEqual([0, 0, 400, 500]); + expect(t.rotate).toBe(0); + const d = t.apply(123, 456); + expect(Number.isNaN(d.x)).toBe(false); + expect(Number.isNaN(d.y)).toBe(false); + expect(t.a * t.d - t.b * t.c).toBe(1); + } + }); + + it("keeps identity finite when the display size itself is not", () => { + const t = DisplayTransform.identity(Number.NaN, Number.NaN); + expect(cropOf(t)).toEqual([0, 0, 0, 0]); + expect(t.displayWidth).toBe(0); + expect(t.displayHeight).toBe(0); + }); +}); + +describe("DisplayTransform.fromPage box resolution", () => { + it("matches PDFium ground truth for the effective page box", () => { + const cases: Array<{ + name: string; + page: StubPage; + display: [number, number]; + expected: Rect; + }> = [ + { + name: "MediaBox+CropBox inherited from a grandparent Pages node", + page: { boundingLTRB: [10, 400, 300, 20] }, + display: [290, 380], + expected: [10, 20, 290, 380], + }, + { + name: "CropBox larger than MediaBox is clipped", + page: { + boundingLTRB: [0, 500, 400, 0], + crop: [-50, -60, 900, 1000], + media: [0, 0, 400, 500], + }, + display: [400, 500], + expected: [0, 0, 400, 500], + }, + { + name: "reversed corner order is normalised", + page: { + boundingLTRB: [10, 400, 300, 20], + crop: [300, 400, 10, 20], + media: [612, 792, 0, 0], + }, + display: [290, 380], + expected: [10, 20, 290, 380], + }, + { + name: "no boxes anywhere falls back to US Letter", + page: { boundingLTRB: [0, 792, 612, 0] }, + display: [612, 792], + expected: [0, 0, 612, 792], + }, + { + name: "missing CropBox defaults to MediaBox", + page: { boundingLTRB: [5, 506, 405, 6], media: [5, 6, 405, 506] }, + display: [400, 500], + expected: [5, 6, 400, 500], + }, + ]; + for (const { name, page, display, expected } of cases) { + const t = DisplayTransform.fromPage( + stubModule(page), + 1, + display[0], + display[1], + ); + expect(cropOf(t), name).toEqual(expected); + } + }); + + it("keeps the bounding box in unrotated user space for a rotated page", () => { + const t = DisplayTransform.fromPage( + stubModule({ + boundingLTRB: [10, 400, 300, 20], + crop: [10, 20, 300, 400], + media: [0, 0, 612, 792], + rotate: 1, + }), + 1, + 380, + 290, + ); + expect(cropOf(t)).toEqual([10, 20, 290, 380]); + expect(t.rotate).toBe(1); + expect(t.apply(10, 20)).toEqual({ x: 0, y: 290 }); + expect(t.apply(300, 400)).toEqual({ x: 380, y: 0 }); + }); + + it("intersects CropBox with MediaBox when no bounding-box export exists", () => { + const t = DisplayTransform.fromPage( + stubModule({ crop: [-50, -60, 900, 1000], media: [0, 0, 400, 500] }), + 1, + 400, + 500, + ); + expect(cropOf(t)).toEqual([0, 0, 400, 500]); + }); + + it("normalises both boxes before intersecting them", () => { + const t = DisplayTransform.fromPage( + stubModule({ crop: [300, 400, 10, 20], media: [612, 792, 0, 0] }), + 1, + 290, + 380, + ); + expect(cropOf(t)).toEqual([10, 20, 290, 380]); + }); + + it("uses MediaBox when the page carries no CropBox", () => { + const t = DisplayTransform.fromPage( + stubModule({ media: [5, 6, 405, 506] }), + 1, + 400, + 500, + ); + expect(cropOf(t)).toEqual([5, 6, 400, 500]); + }); + + it("uses CropBox when the page carries no MediaBox", () => { + const t = DisplayTransform.fromPage( + stubModule({ crop: [5, 6, 405, 506] }), + 1, + 400, + 500, + ); + expect(cropOf(t)).toEqual([5, 6, 400, 500]); + }); + + it("falls back to MediaBox when CropBox is disjoint from it", () => { + const t = DisplayTransform.fromPage( + stubModule({ + boundingLTRB: [0, 0, 0, 0], + crop: [800, 900, 1000, 1100], + media: [10, 20, 410, 520], + }), + 1, + 0, + 0, + ); + expect(cropOf(t)).toEqual([10, 20, 400, 500]); + expect(t.a * t.d - t.b * t.c).toBe(1); + }); + + it("falls back to the page-dictionary boxes when the bounding box is degenerate", () => { + const t = DisplayTransform.fromPage( + stubModule({ + boundingLTRB: [0, 0, 0, 0], + crop: [10, 20, 300, 400], + media: [0, 0, 612, 792], + }), + 1, + 290, + 380, + ); + expect(cropOf(t)).toEqual([10, 20, 290, 380]); + }); + + it("falls back to identity when every box read fails", () => { + const t = DisplayTransform.fromPage(stubModule({}), 1, 612, 792); + expect(t.isIdentity).toBe(true); + expect(cropOf(t)).toEqual([0, 0, 612, 792]); + }); + + it("survives throwing PDFium exports", () => { + const thrower = (): number => { + throw new Error("wasm trap"); + }; + const base = stubModule({ media: [0, 0, 400, 500] }) as unknown as Record< + string, + unknown + >; + base.FPDF_GetPageBoundingBox = thrower; + base.FPDFPage_GetCropBox = thrower; + base.FPDFPage_GetRotation = thrower; + const t = DisplayTransform.fromPage( + base as unknown as WrappedPdfiumModule, + 1, + 400, + 500, + ); + expect(cropOf(t)).toEqual([0, 0, 400, 500]); + expect(t.rotate).toBe(0); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/FontBorrowing.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/FontBorrowing.test.ts new file mode 100644 index 0000000000..c05ee653eb --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/FontBorrowing.test.ts @@ -0,0 +1,246 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +/** + * Regression coverage for which font the emit path is allowed to borrow. + * + * Two reported corruptions came from here: + * - edited body text came back BOLD, because the borrow took the first glyph + * in content order and headings come first; + * - a Type 3 document (Figma/Skia export) scrambled into overlapping glyphs, + * because a face PDFium cannot author was reused anyway. + */ + +vi.mock("@app/services/apiClient", () => ({ default: { post: vi.fn() } })); +vi.mock("@app/tools/pdfTextEditor/pdfium/PdfiumSave", () => ({ + PdfiumSave: { serialize: vi.fn(() => new Uint8Array([0, 1, 2, 3])) }, +})); + +import { + findFontForChar, + fontIsReusable, + fontStyleClass, + styleClassFromName, + _clearFontForCharCacheForTests, + _clearFontNameCacheForTests, + _clearReusableFontCacheForTests, +} from "@app/tools/pdfTextEditor/charcode/BackendResolver"; +import type { ResolverContext } from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; + +const TEXT_PAGE = 555; + +interface FakeFont { + /** /BaseFont name; null models a Type 3 font, which has none. */ + name: string | null; + /** Byte length PDFium reports for the font program; 0 for Type 3. */ + dataLen: number; +} + +/** + * Fake PDFium module rendering `glyphs` in page order, each with its own font. + * `fonts` maps a font handle to what PDFium would report about it. + */ +function makeModule( + glyphs: Array<[string, number]>, + fonts: Record, +): ResolverContext["module"] { + const heap = new Map(); + let nextPtr = 1; + const strings = new Map(); + return { + FPDFText_LoadPage: vi.fn(() => TEXT_PAGE), + FPDFText_ClosePage: vi.fn(), + FPDFText_CountChars: vi.fn(() => glyphs.length), + FPDFText_GetUnicode: vi.fn( + (_tp: number, i: number) => glyphs[i][0].codePointAt(0) ?? 0, + ), + FPDFText_GetTextObject: vi.fn((_tp: number, i: number) => 1000 + i), + FPDFTextObj_GetFont: vi.fn((obj: number) => glyphs[obj - 1000][1]), + FPDFFont_GetBaseFontName: vi.fn( + (font: number, buf: number, len: number) => { + const name = fonts[font]?.name; + if (!name) return 0; + if (buf === 0 || len === 0) return name.length + 1; + strings.set(buf, name); + return name.length + 1; + }, + ), + FPDFFont_GetFontData: vi.fn( + (font: number, _buf: number, _len: number, out: number) => { + heap.set(out, fonts[font]?.dataLen ?? 0); + return true; + }, + ), + pdfium: { + wasmExports: { + malloc: vi.fn(() => nextPtr++), + free: vi.fn(), + }, + getValue: vi.fn((ptr: number) => heap.get(ptr) ?? 0), + setValue: vi.fn((ptr: number, v: number) => heap.set(ptr, v)), + UTF8ToString: vi.fn((ptr: number) => strings.get(ptr) ?? ""), + }, + } as unknown as ResolverContext["module"]; +} + +const ctxFor = (module: ResolverContext["module"]): ResolverContext => ({ + module, + pagePtr: 42, + docPtr: 1, +}); + +afterEach(() => { + _clearFontForCharCacheForTests(); + _clearFontNameCacheForTests(); + _clearReusableFontCacheForTests(); +}); + +const BOLD = 10; +const REGULAR = 20; +const TYPE3 = 30; + +const REAL_FONTS: Record = { + [BOLD]: { name: "AAAAAB+Helvetica-Bold", dataLen: 4096 }, + [REGULAR]: { name: "AAAAAC+Helvetica", dataLen: 4096 }, +}; + +describe("fontStyleClass", () => { + it("reads bold and italic off the /BaseFont name", () => { + const m = makeModule([], REAL_FONTS); + expect(fontStyleClass(m, BOLD)).toEqual({ bold: true, italic: false }); + expect(fontStyleClass(m, REGULAR)).toEqual({ bold: false, italic: false }); + }); + + it("returns null for a font with no name", () => { + const m = makeModule([], { [TYPE3]: { name: null, dataLen: 0 } }); + expect(fontStyleClass(m, TYPE3)).toBeNull(); + }); +}); + +describe("fontIsReusable", () => { + it("accepts a font that reports a font program", () => { + const m = makeModule([], REAL_FONTS); + expect(fontIsReusable(m, REGULAR)).toBe(true); + }); + + it("rejects a Type 3 font, which reports a zero-length program", () => { + // PDFium answers "true" for a Type 3 font but with length 0 - the length is + // the part that distinguishes a real face. + const m = makeModule([], { [TYPE3]: { name: "T3", dataLen: 0 } }); + expect(fontIsReusable(m, TYPE3)).toBe(false); + }); +}); + +describe("findFontForChar", () => { + it("borrows the first matching glyph when no style is requested", () => { + // 'o' appears first in the bold heading, then in the regular body. + const m = makeModule( + [ + ["o", BOLD], + ["o", REGULAR], + ], + REAL_FONTS, + ); + expect(findFontForChar("o", ctxFor(m))).toBe(BOLD); + }); + + it("skips the bold heading when the run's own font is regular", () => { + const m = makeModule( + [ + ["o", BOLD], + ["o", REGULAR], + ], + REAL_FONTS, + ); + // This is the fake-bold regression: without the style constraint the body + // run's re-emitted "o" came back in Helvetica-Bold. + expect(findFontForChar("o", ctxFor(m), REGULAR)).toBe(REGULAR); + }); + + it("skips the regular body when the run's own font is bold", () => { + const m = makeModule( + [ + ["o", REGULAR], + ["o", BOLD], + ], + REAL_FONTS, + ); + expect(findFontForChar("o", ctxFor(m), BOLD)).toBe(BOLD); + }); + + it("returns null rather than change weight when only the wrong weight has the glyph", () => { + const m = makeModule([["o", BOLD]], REAL_FONTS); + // Falling back to a substituted regular face is correct; silently going + // bold is not. + expect(findFontForChar("o", ctxFor(m), REGULAR)).toBeNull(); + }); + + it("honours an explicit style when there is no source font handle", () => { + // The undo path re-emits with `originalFontPtr: 0`. Keying the guard only + // off the handle disabled it there, and restored body text came back bold + // for every letter whose first page-order occurrence was in a heading. + const m = makeModule( + [ + ["p", BOLD], + ["p", REGULAR], + ], + REAL_FONTS, + ); + expect( + findFontForChar("p", ctxFor(m), 0, styleClassFromName("Times-Roman")), + ).toBe(REGULAR); + expect( + findFontForChar("p", ctxFor(m), 0, styleClassFromName("Times-Bold")), + ).toBe(BOLD); + }); + + it("prefers the run's OWN family over another face of the same weight", () => { + // Both are regular, so the weight guard lets either through. Taking the + // first in content order gave a word the document already sets in Times a + // near-miss face: right weight, slightly wrong shapes and advances. + const OTHER = 40; + const fonts = { + ...REAL_FONTS, + [OTHER]: { name: "AAAAAD+TimesNewRoman", dataLen: 4096 }, + }; + const m = makeModule( + [ + ["s", OTHER], + ["s", REGULAR], + ], + fonts, + ); + expect(findFontForChar("s", ctxFor(m), REGULAR)).toBe(REGULAR); + }); + + it("matches families across subset tags and style suffixes", () => { + const PLAIN = 50; + const fonts = { + ...REAL_FONTS, + [PLAIN]: { name: "Helvetica", dataLen: 4096 }, + }; + const m = makeModule([["s", PLAIN]], fonts); + // "AAAAAC+Helvetica" and a bare "Helvetica" are the same design. + expect(findFontForChar("s", ctxFor(m), REGULAR)).toBe(PLAIN); + }); + + it("still borrows another family when the run's own has no such glyph", () => { + const OTHER = 40; + const fonts = { + ...REAL_FONTS, + [OTHER]: { name: "AAAAAD+TimesNewRoman", dataLen: 4096 }, + }; + const m = makeModule([["s", OTHER]], fonts); + expect(findFontForChar("s", ctxFor(m), REGULAR)).toBe(OTHER); + }); + + it("still offers a Type 3 face - the emit path gates it on a measurable advance", () => { + // Refusing Type 3 outright would lose glyph reuse for an append into a + // Type 3 run, which renders perfectly. The emit path takes the face only + // when it can also measure the glyph's advance off the page. + const m = makeModule([["o", TYPE3]], { + [TYPE3]: { name: null, dataLen: 0 }, + }); + expect(findFontForChar("o", ctxFor(m))).toBe(TYPE3); + expect(fontIsReusable(m, TYPE3)).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/HistoryStack.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/HistoryStack.test.ts new file mode 100644 index 0000000000..5b81f38d44 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/HistoryStack.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HistoryStack } from "@app/tools/pdfTextEditor/store/HistoryStack"; +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +function makeCmd(type = "test") { + const apply = vi.fn(); + const revert = vi.fn(); + const cmd: Command = { type, apply, revert }; + return { cmd, apply, revert }; +} + +const fakeDoc = {} as unknown as EditorDocument; + +describe("HistoryStack", () => { + it("starts empty and reports neither undo nor redo", () => { + const h = new HistoryStack(); + expect(h.canUndo).toBe(false); + expect(h.canRedo).toBe(false); + expect(h.size()).toEqual({ undo: 0, redo: 0 }); + }); + + it("execute applies and pushes onto the undo stack", () => { + const h = new HistoryStack(); + const { cmd, apply } = makeCmd(); + h.execute(cmd, fakeDoc); + expect(apply).toHaveBeenCalledOnce(); + expect(h.canUndo).toBe(true); + expect(h.canRedo).toBe(false); + }); + + it("undo reverts the most recent command and moves it to redo", () => { + const h = new HistoryStack(); + const { cmd, revert } = makeCmd(); + h.execute(cmd, fakeDoc); + const popped = h.undo(fakeDoc); + expect(popped).toBe(cmd); + expect(revert).toHaveBeenCalledOnce(); + expect(h.canUndo).toBe(false); + expect(h.canRedo).toBe(true); + }); + + it("redo re-applies and shifts back to undo", () => { + const h = new HistoryStack(); + const { cmd, apply } = makeCmd(); + h.execute(cmd, fakeDoc); + h.undo(fakeDoc); + const popped = h.redo(fakeDoc); + expect(popped).toBe(cmd); + // apply was called once on execute and once on redo. + expect(apply).toHaveBeenCalledTimes(2); + expect(h.canUndo).toBe(true); + expect(h.canRedo).toBe(false); + }); + + it("a new execute after undo discards the redo stack", () => { + const h = new HistoryStack(); + const a = makeCmd("a"); + const b = makeCmd("b"); + h.execute(a.cmd, fakeDoc); + h.undo(fakeDoc); + expect(h.canRedo).toBe(true); + h.execute(b.cmd, fakeDoc); + expect(h.canRedo).toBe(false); + }); + + it("undo on an empty stack is a no-op and returns null", () => { + const h = new HistoryStack(); + expect(h.undo(fakeDoc)).toBeNull(); + }); + + it("clear empties both stacks", () => { + const h = new HistoryStack(); + h.execute(makeCmd("a").cmd, fakeDoc); + h.execute(makeCmd("b").cmd, fakeDoc); + h.clear(); + expect(h.size()).toEqual({ undo: 0, redo: 0 }); + }); + + it("enforces the configured limit by dropping the oldest entry", () => { + const h = new HistoryStack(3); + h.execute(makeCmd("a").cmd, fakeDoc); + h.execute(makeCmd("b").cmd, fakeDoc); + h.execute(makeCmd("c").cmd, fakeDoc); + h.execute(makeCmd("d").cmd, fakeDoc); + expect(h.size().undo).toBe(3); + }); +}); + +// Coalescing is what decides how much one Ctrl+Z reverts, and until now it was +// only ever exercised through the browser suite. +describe("HistoryStack coalescing", () => { + /** A command that groups with others sharing `key`. */ + function keyed(key: string | null, opts: { ignoresWindow?: boolean } = {}) { + const { cmd, apply, revert } = makeCmd("keyed"); + const full: Command = { + ...cmd, + apply, + revert, + coalesceKey: () => key, + ...(opts.ignoresWindow ? { coalesceIgnoresTimeWindow: () => true } : {}), + }; + return full; + } + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T00:00:00Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("groups same-key commands inside the 600ms window into one undo step", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + vi.advanceTimersByTime(300); + h.execute(keyed("run:1"), fakeDoc); + expect(h.size().undo).toBe(1); + }); + + it("starts a new undo step once the window has elapsed", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + vi.advanceTimersByTime(601); + h.execute(keyed("run:1"), fakeDoc); + expect(h.size().undo).toBe(2); + }); + + it("never groups commands with different keys", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + h.execute(keyed("run:2"), fakeDoc); + expect(h.size().undo).toBe(2); + }); + + it("never groups commands that opt out of coalescing", () => { + const h = new HistoryStack(); + h.execute(keyed(null), fakeDoc); + h.execute(keyed(null), fakeDoc); + expect(h.size().undo).toBe(2); + }); + + it("coalesceIgnoresTimeWindow groups however long the gap was", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + vi.advanceTimersByTime(60_000); + h.execute(keyed("run:1", { ignoresWindow: true }), fakeDoc); + expect(h.size().undo).toBe(1); + }); + + it("hands the hook the previous command, unwrapped from its group", () => { + const h = new HistoryStack(); + const first = keyed("run:1"); + const second = keyed("run:1"); + h.execute(first, fakeDoc); + h.execute(second, fakeDoc); + expect(h.size().undo).toBe(1); // first+second are now a CompositeCommand + + const seen: Array = []; + const third: Command = { + ...keyed("run:1"), + coalesceIgnoresTimeWindow: (previous: Command | null) => { + seen.push(previous); + return true; + }, + }; + vi.advanceTimersByTime(60_000); + h.execute(third, fakeDoc); + // The group's most recent child, not the CompositeCommand wrapper. + expect(seen).toEqual([second]); + }); + + it("passes null to the hook when the undo stack is empty", () => { + const h = new HistoryStack(); + const seen: Array = []; + h.execute( + { + ...keyed("run:1"), + coalesceIgnoresTimeWindow: (previous: Command | null) => { + seen.push(previous); + return false; + }, + }, + fakeDoc, + ); + expect(seen).toEqual([null]); + }); + + it("does not charge a command's own apply() time to the idle window", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + vi.advanceTimersByTime(300); + // A slow command: 500ms of PDFium/render work inside apply(). + const slow: Command = { + type: "slow", + apply: () => vi.advanceTimersByTime(500), + revert: () => {}, + coalesceKey: () => "run:1", + }; + h.execute(slow, fakeDoc); + expect(h.size().undo).toBe(1); + }); + + it("undo ends the burst so the next edit cannot rejoin the step below", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + vi.advanceTimersByTime(700); + h.execute(keyed("run:1"), fakeDoc); + expect(h.size().undo).toBe(2); + h.undo(fakeDoc); + expect(h.size().undo).toBe(1); + // Immediately after the undo, so inside the window - but the burst was + // ended, so this must not merge into the step that is still on the stack. + h.execute(keyed("run:1"), fakeDoc); + expect(h.size().undo).toBe(2); + }); + + it("breakCoalescing splits an otherwise groupable pair", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + h.breakCoalescing(); + h.execute(keyed("run:1"), fakeDoc); + expect(h.size().undo).toBe(2); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/LineGrouperSpacing.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/LineGrouperSpacing.test.ts new file mode 100644 index 0000000000..30ea3ff47b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/LineGrouperSpacing.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from "vitest"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { LineGrouper } from "@app/tools/pdfTextEditor/pdfium/LineGrouper"; + +let ptr = 5000; +function mkRun(opts: { + x: number; + width: number; + f: number; + fs: number; + text: string; +}): TextRun { + return new TextRun({ + id: `r${ptr}`, + pageIndex: 0, + bounds: { x: opts.x, y: opts.f, width: opts.width, height: opts.fs }, + matrix: { a: opts.fs, b: 0, c: 0, d: opts.fs, e: opts.x, f: opts.f }, + text: opts.text, + fontId: "pdf:1:Helvetica", + fontSize: opts.fs, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + pdfiumObjPtr: ptr++, + containerPtr: 0, + }); +} + +function lineOf( + words: Array<{ text: string; width: number; gapAfter?: number }>, + fs: number, +): TextRun[] { + const runs: TextRun[] = []; + let x = 72; + for (const w of words) { + runs.push(mkRun({ x, width: w.width, f: 500, fs, text: w.text })); + x += w.width + (w.gapAfter ?? 0); + } + return runs; +} + +function joinLine(runs: TextRun[]): string { + const page = new Page({ index: 0, pagePtr: 1, width: 600, height: 800 }); + page.setRuns(runs); + page.loaded = true; + const groups = LineGrouper.apply(page); + expect(groups.length, "runs formed a single line group").toBe(1); + return groups[0].representative.text; +} + +function runsBetween(text: string, before: string, after: string): number { + const m = new RegExp(`${before}( +)${after}`).exec(text); + return m ? m[1].length : 0; +} + +describe("LineGrouper inter-run space synthesis", () => { + it("emits one space for normal 10pt word gaps", () => { + const text = joinLine( + lineOf( + [ + { text: "Hello", width: 25, gapAfter: 3.2 }, + { text: "brave", width: 26, gapAfter: 3.2 }, + { text: "world", width: 27 }, + ], + 10, + ), + ); + expect(text).toBe("Hello brave world"); + }); + + it("keeps a justified stretched space as ONE space", () => { + const text = joinLine( + lineOf( + [ + { text: "The", width: 16, gapAfter: 7.4 }, + { text: "quick", width: 25, gapAfter: 7.4 }, + { text: "brown", width: 29, gapAfter: 7.4 }, + { text: "foxes", width: 26 }, + ], + 10, + ), + ); + expect(text).toBe("The quick brown foxes"); + }); + + it("keeps a justified stretched space as ONE when the space glyph is already in the run", () => { + const text = joinLine( + lineOf( + [ + { text: "The ", width: 16, gapAfter: 7.4 }, + { text: "quick ", width: 25, gapAfter: 7.4 }, + { text: "brown ", width: 29, gapAfter: 7.4 }, + { text: "foxes", width: 26 }, + ], + 10, + ), + ); + expect(text).toBe("The quick brown foxes"); + }); + + it("keeps a stretched space as ONE on a two-object line with no line evidence", () => { + const text = joinLine( + lineOf( + [ + { text: "widely", width: 30, gapAfter: 8 }, + { text: "spaced", width: 32 }, + ], + 10, + ), + ); + expect(text).toBe("widely spaced"); + }); + + it("keeps a genuine double space as TWO spaces", () => { + const text = joinLine( + lineOf( + [ + { text: "Item", width: 20, gapAfter: 3.4 }, + { text: "one", width: 17, gapAfter: 6.6 }, + { text: "two", width: 18, gapAfter: 3.4 }, + { text: "three", width: 24 }, + ], + 10, + ), + ); + expect(text).toBe("Item one two three"); + }); + + it("expands a tab-like gap into several spaces", () => { + const text = joinLine( + lineOf( + [ + { text: "Chapter", width: 38, gapAfter: 3.2 }, + { text: "1", width: 5, gapAfter: 11.5 }, + { text: "12", width: 11 }, + ], + 10, + ), + ); + expect(runsBetween(text, "Chapter", "1")).toBe(1); + expect(runsBetween(text, "1", "12")).toBeGreaterThanOrEqual(3); + }); + + it("scales with font size: a 24pt heading word gap stays one space", () => { + const text = joinLine( + lineOf( + [ + { text: "Big", width: 44, gapAfter: 9.5 }, + { text: "bold", width: 55, gapAfter: 9.5 }, + { text: "title", width: 48 }, + ], + 24, + ), + ); + expect(text).toBe("Big bold title"); + }); + + it("does not synthesise a space for a hairline kerning gap", () => { + const text = joinLine( + lineOf( + [ + { text: "Wa", width: 16, gapAfter: 0.6 }, + { text: "ter", width: 14 }, + ], + 10, + ), + ); + expect(text).toBe("Water"); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ParagraphEdit.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ParagraphEdit.test.ts new file mode 100644 index 0000000000..c1653f761f --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ParagraphEdit.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect } from "vitest"; +import { + planParagraphEdit, + planPartialEdit, +} from "@app/tools/pdfTextEditor/commands/partialEdit"; +import { + TextRun, + type ParagraphLineSlot, +} from "@app/tools/pdfTextEditor/model/TextRun"; + +/** Regression coverage for the mushroom-life.pdf "line collapse" bug. */ + +let nextPtr = 100; +function slot( + text: string, + startChar: number, + baselineY: number, +): ParagraphLineSlot { + const ptr = nextPtr++; + return { + startChar, + endChar: startChar + text.length, + baselineY, + matrixE: 0, + containerPtr: 0, + fontId: "pdf:1:LMRoman12", + fontSize: 12, + fontSubset: false, + mergedFromPtrs: [ptr], + mergedFromTexts: [text], + mergedFromBounds: [{ x: 0, right: text.length * 6 }], + mergedFromCharStarts: [0], + }; +} + +// Build a paragraph run whose `text` is the visual lines joined by the given +// separators (one per gap, "\n" or " "). +function makeParagraph(lines: string[], separators: string[]): TextRun { + let text = lines[0]; + const slots: ParagraphLineSlot[] = [slot(lines[0], 0, 800)]; + let cursor = lines[0].length; + for (let i = 1; i < lines.length; i++) { + text += separators[i - 1] + lines[i]; + cursor += 1; // separator + slots.push(slot(lines[i], cursor, 800 - i * 14)); + cursor += lines[i].length; + } + const run = new TextRun({ + id: "p0-t0", + pageIndex: 0, + bounds: { x: 0, y: 0, width: 100, height: 100 }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 800 }, + text, + fontId: "pdf:1:LMRoman12", + fontSize: 12, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + pdfiumObjPtr: 0, + }); + run.paragraphLineSlots = slots; + run.paragraphLineHeight = 14; + return run; +} + +// Build a single-sub-run TextRun whose own `mergedFrom*` arrays carry `text` as +// one object - the shape `planPartialEdit` diffs against. +function makeSingleSubRun(text: string): TextRun { + const run = new TextRun({ + id: "p0-t0", + pageIndex: 0, + bounds: { x: 0, y: 0, width: text.length * 6, height: 14 }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 800 }, + text, + fontId: "pdf:1:LMRoman12", + fontSize: 12, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + pdfiumObjPtr: 0, + }); + run.mergedFromPtrs = [200]; + run.mergedFromTexts = [text]; + run.mergedFromBounds = [{ x: 0, right: text.length * 6 }]; + run.mergedFromCharStarts = [0]; + return run; +} + +describe("planPartialEdit surrogate-pair guard (astral chars)", () => { + it("stays surgical for an append after an emoji the edit never touches", () => { + // "🎉" is two UTF-16 code units, but the append is nowhere near it. + // Bailing here dropped the run to the overlay re-emit, which loses chars. + const run = makeSingleSubRun("🎉ab"); + expect(planPartialEdit(run, "🎉ab", "🎉abc")).not.toBeNull(); + }); + + it("returns a non-null plan for the same edit when prevText has NO surrogate", () => { + const run = makeSingleSubRun("Xab"); + expect(planPartialEdit(run, "Xab", "Xabc")).not.toBeNull(); + }); + + it("bails when the diff would cut a pair (sibling emoji share a high half)", () => { + // U+1F600 and U+1F601 are both "\uD83D...". The code-unit LCS matches the + // shared high surrogate and drops the low, which would emit a lone half. + const run = makeSingleSubRun("a\u{1F600}b"); + expect(planPartialEdit(run, "a\u{1F600}b", "a\u{1F601}b")).toBeNull(); + }); + + it("stays surgical when a whole astral char is deleted", () => { + const run = makeSingleSubRun("a\u{1F600}b"); + expect(planPartialEdit(run, "a\u{1F600}b", "ab")).not.toBeNull(); + }); + + it("stays surgical for a plane-1 script (U+10C80 Old Hungarian)", () => { + const run = makeSingleSubRun("x\u{10C80}y"); + expect(planPartialEdit(run, "x\u{10C80}y", "x\u{10C80}yz")).not.toBeNull(); + }); + + it("bails when prevText already holds a LONE surrogate", () => { + const run = makeSingleSubRun("a\uD83Db"); + expect(planPartialEdit(run, "a\uD83Db", "a\uD83Dbc")).toBeNull(); + }); +}); + +describe("planPartialEdit interior-insert guard (single word object)", () => { + it("bails when an inserted char splits a multi-char object's kept chars", () => { + // "world" is ONE object; inserting "a" mid-word ("world"->"worald") leaves + // the survivors at non-contiguous new-text positions (0,1,2,4,5). + const run = makeSingleSubRun("world"); + expect(planPartialEdit(run, "world", "worald")).toBeNull(); + }); + + it("bails on a mid-word char replace (delete+insert interior)", () => { + const run = makeSingleSubRun("world"); + expect(planPartialEdit(run, "world", "worXd")).toBeNull(); + }); + + it("keeps the surgical path for a boundary delete (survivors contiguous)", () => { + // Deleting from the END keeps survivors contiguous, so no scramble risk. + const run = makeSingleSubRun("world"); + expect(planPartialEdit(run, "world", "worl")).not.toBeNull(); + }); + + it("keeps the surgical path for a prefix insert (before the object)", () => { + // A char typed BEFORE the word anchors ahead of it, survivors stay + // contiguous - the surgical path is safe and preserved. + const run = makeSingleSubRun("world"); + expect(planPartialEdit(run, "world", "aworld")).not.toBeNull(); + }); +}); + +describe("planParagraphEdit slot-range line mapping", () => { + it("does NOT bail on a soft-wrapped paragraph (the collapse bug)", () => { + // 4 visual lines, but only ONE hard break: "aaa bbb\nccc ddd". + // split("\n") => 2 segments, slots => 4. The old guard bailed here. + const run = makeParagraph(["aaa", "bbb", "ccc", "ddd"], [" ", "\n", " "]); + const prev = run.text; + expect(prev).toBe("aaa bbb\nccc ddd"); + const next = "Zaaa bbb\nccc ddd"; // insert "Z" at the very start + + const plan = planParagraphEdit(run, prev, next); + expect(plan).not.toBeNull(); + // Per-visual-line next text, slot-aligned (NOT \n-split). + expect(plan?.nextLines).toEqual(["Zaaa", "bbb", "ccc", "ddd"]); + // Only the hit slot (line 0) is in the per-slot edit list. + expect(plan?.perSlot.map((p) => p.slotIdx)).toEqual([0]); + }); + + it("maps an edit confined to a later soft-wrapped line to the right slot", () => { + const run = makeParagraph(["aaa", "bbb", "ccc", "ddd"], [" ", "\n", " "]); + const prev = run.text; // "aaa bbb\nccc ddd" + // Insert "X" at the start of the last visual line ("ddd" -> "Xddd"). + const next = "aaa bbb\nccc Xddd"; + const plan = planParagraphEdit(run, prev, next); + expect(plan).not.toBeNull(); + expect(plan?.nextLines).toEqual(["aaa", "bbb", "ccc", "Xddd"]); + expect(plan?.perSlot.map((p) => p.slotIdx)).toEqual([3]); + }); + + it("bails when the edit changes the hard-break count (structural)", () => { + const run = makeParagraph(["aaa", "bbb", "ccc", "ddd"], [" ", "\n", " "]); + const prev = run.text; + // Type Enter inside the first line -> a NEW hard break. + const next = "aa\na bbb\nccc ddd"; + expect(planParagraphEdit(run, prev, next)).toBeNull(); + }); + + it("bails when the edit spans a soft-wrap separator (two slots)", () => { + const run = makeParagraph(["aaa", "bbb", "ccc", "ddd"], [" ", "\n", " "]); + const prev = run.text; // "aaa bbb\nccc ddd" + // Delete the soft-wrap space between "ccc" and "ddd" (merges two slots). + const next = "aaa bbb\ncccddd"; + expect(planParagraphEdit(run, prev, next)).toBeNull(); + }); + + it("bails when slot ranges don't tile run.text (desynced model)", () => { + const run = makeParagraph(["aaa", "bbb"], ["\n"]); + // Corrupt run.text so the slot ranges no longer tile it. + run.text = "aaa bbb EXTRA"; + expect(planParagraphEdit(run, run.text, "Zaaa bbb EXTRA")).toBeNull(); + }); + + it("forces a fresh word-split re-emit when a mid-line edit would SetText whitespace (the „ bug)", () => { + // A whole line as ONE sub-run carrying spaces (LaTeX one-object-per-line). + const run = makeParagraph(["aaa bbb ccc", "ddd eee"], ["\n"]); + const prev = run.text; // "aaa bbb ccc\nddd eee" + const next = "aaa Xbb ccc\nddd eee"; // replace one char mid-line-0 + const plan = planParagraphEdit(run, prev, next); + expect(plan).not.toBeNull(); + const entry = plan?.perSlot.find((p) => p.slotIdx === 0); + expect(entry).toBeDefined(); + // null plan => the apply step fresh-emits this line (word-split), avoiding „. + expect(entry?.plan).toBeNull(); + expect(entry?.nextLine).toBe("aaa Xbb ccc"); + }); + + it("keeps the in-place modify fast path for a boundary edit on a single-word sub-run", () => { + // Deleting a char at a word's END keeps the surviving chars CONTIGUOUS in + // the new text, so the surgical single-object modify path is safe. + const run = makeParagraph(["hello", "world"], ["\n"]); + const prev = run.text; // "hello\nworld" + const next = "hello\nworl"; // delete trailing "d" + const plan = planParagraphEdit(run, prev, next); + expect(plan).not.toBeNull(); + const entry = plan?.perSlot.find((p) => p.slotIdx === 1); + // A non-null plan => surgical in-place edit kept (survivors contiguous). + expect(entry?.plan).not.toBeNull(); + }); + + it("re-emits a mid-word char replace instead of scrambling it (interior-insert guard)", () => { + // Replacing a char in the MIDDLE of a single word object ("world"->"worXd") + // deletes 'l' and inserts 'X' between the surviving 'r' and 'd'. + const run = makeParagraph(["hello", "world"], ["\n"]); + const prev = run.text; // "hello\nworld" + const next = "hello\nworXd"; + const plan = planParagraphEdit(run, prev, next); + expect(plan).not.toBeNull(); + const entry = plan?.perSlot.find((p) => p.slotIdx === 1); + expect(entry).toBeDefined(); + // null slot plan => the apply step fresh-emits this line (correct order). + expect(entry?.plan).toBeNull(); + expect(entry?.nextLine).toBe("worXd"); + }); + + it("handles an all-hard-break paragraph (initial-load shape) too", () => { + // Every visual line a hard break: this is the shape ParagraphGrouper builds + // at load. split == slots here, so it always worked. + const run = makeParagraph(["one", "two", "three"], ["\n", "\n"]); + const prev = run.text; + expect(prev).toBe("one\ntwo\nthree"); + const next = "one\ntwoX\nthree"; + const plan = planParagraphEdit(run, prev, next); + expect(plan).not.toBeNull(); + expect(plan?.nextLines).toEqual(["one", "twoX", "three"]); + expect(plan?.perSlot.map((p) => p.slotIdx)).toEqual([1]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/PdfiumPageRenderer.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/PdfiumPageRenderer.test.ts new file mode 100644 index 0000000000..5bf31df486 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/PdfiumPageRenderer.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { PdfiumPageRenderer } from "@app/tools/pdfTextEditor/pdfium/PdfiumPageRenderer"; + +// A4 in PDF points. +const A4_W = 595; +const A4_H = 842; + +describe("PdfiumPageRenderer.deviceScale", () => { + it("multiplies the zoom scale by the display ratio", () => { + expect(PdfiumPageRenderer.deviceScale(A4_W, A4_H, 1.5, 2)).toBeCloseTo(3); + }); + + it("treats a 1x display as a plain zoom scale", () => { + expect(PdfiumPageRenderer.deviceScale(A4_W, A4_H, 1.5, 1)).toBeCloseTo(1.5); + }); + + it("never renders BELOW the zoom scale on a sub-1x ratio", () => { + // Browser zoomed out below 100%: upscaling would soften, so hold at 1x. + expect(PdfiumPageRenderer.deviceScale(A4_W, A4_H, 1.5, 0.8)).toBeCloseTo( + 1.5, + ); + }); + + it("caps the ratio at 3 - beyond that is memory, not sharpness", () => { + expect(PdfiumPageRenderer.deviceScale(A4_W, A4_H, 1, 4)).toBeCloseTo(3); + }); + + it("clamps a poster page to the pixel budget", () => { + // 36x48in poster: 2592x3456pt. Unclamped 4x zoom on a 2x display would be + // a 573MB bitmap; the budget holds one page under ~128MB of RGBA. + const scale = PdfiumPageRenderer.deviceScale(2592, 3456, 4, 2); + const { width, height } = PdfiumPageRenderer.rasterSize(2592, 3456, scale); + expect(width * height).toBeLessThanOrEqual(32_000_000 * 1.01); + expect(scale).toBeLessThan(8); + expect(scale).toBeGreaterThan(1); + }); + + it("keeps ordinary pages essentially unclamped at max zoom on 2x", () => { + // A4 at 8x sits right on the pixel budget, so the cap shaves ~0.01. + expect(PdfiumPageRenderer.deviceScale(A4_W, A4_H, 4, 2)).toBeCloseTo(8, 1); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ReplaceImageCommand.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ReplaceImageCommand.test.ts new file mode 100644 index 0000000000..ff432689f8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ReplaceImageCommand.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect } from "vitest"; +import { ReplaceImageCommand } from "@app/tools/pdfTextEditor/commands/ReplaceImageCommand"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; +import { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Affine, PageRect } from "@app/tools/pdfTextEditor/types"; + +const OLD_PTR = 42; +/** 90-degree rotated placement: a naive (w,0,0,h,x,y) rebuild would flip it. */ +const ROTATED: Affine = { a: 0, b: 120, c: -80, d: 0, e: 300, f: 40 }; +const BOX: PageRect = { x: 220, y: 40, width: 80, height: 120 }; + +interface FakeModule { + objs: number[]; + destroyed: number[]; + /** [objPtr, a, b, c, d, e, f] per FPDFImageObj_SetMatrix call. */ + matrixCalls: number[][]; + /** Same shape, but recorded from the FS_MATRIX struct fallback. */ + structMatrixCalls: number[][]; + newImageObjs: number; + bitmapsCreated: number; + jpegLoads: number; + generateCalls: number; + module: EditorDocument["module"]; +} + +/** Stub PDFium: page objects are a pointer array (index 0 = bottom). */ +function fakePdfium( + objs: number[], + opts: { + imageMatrixSetter?: boolean; + insertAtIndex?: boolean; + jpeg?: boolean; + } = {}, +): FakeModule { + const heap = new ArrayBuffer(64 * 1024); + const view = new DataView(heap); + const state: FakeModule = { + objs, + destroyed: [], + matrixCalls: [], + structMatrixCalls: [], + newImageObjs: 0, + bitmapsCreated: 0, + jpegLoads: 0, + generateCalls: 0, + module: null as unknown as EditorDocument["module"], + }; + let nextPtr = 1000; + let brk = 64; + let bitmapWidth = 0; + + const module: Record = { + FPDFPage_CountObjects: () => objs.length, + FPDFPage_GetObject: (_p: number, i: number) => objs[i] ?? 0, + FPDFPage_RemoveObject: (_p: number, ptr: number) => { + const i = objs.indexOf(ptr); + if (i < 0) return false; + objs.splice(i, 1); + return true; + }, + FPDFPage_InsertObject: (_p: number, ptr: number) => { + objs.push(ptr); + }, + FPDFPageObj_Destroy: (ptr: number) => { + state.destroyed.push(ptr); + }, + FPDFPageObj_NewImageObj: () => { + state.newImageObjs += 1; + nextPtr += 1; + return nextPtr; + }, + FPDFBitmap_Create: (w: number) => { + state.bitmapsCreated += 1; + bitmapWidth = w; + return 500; + }, + FPDFBitmap_GetBuffer: () => 4096, + FPDFBitmap_GetStride: () => bitmapWidth * 4, + FPDFBitmap_Destroy: () => undefined, + FPDFImageObj_SetBitmap: () => true, + FPDFPageObj_SetMatrix: (obj: number, ptr: number) => { + const vals: number[] = [obj]; + for (let i = 0; i < 6; i++) vals.push(view.getFloat32(ptr + i * 4, true)); + state.structMatrixCalls.push(vals); + return true; + }, + FPDFPage_GenerateContent: () => { + state.generateCalls += 1; + }, + pdfium: { + setValue: (ptr: number, value: number, type: string) => { + if (type === "float") view.setFloat32(ptr, value, true); + else view.setInt32(ptr, value, true); + }, + wasmExports: { + malloc: (size: number) => { + const p = brk; + brk += size; + return p; + }, + free: () => undefined, + memory: { buffer: heap }, + }, + HEAPU8: new Uint8Array(heap), + addFunction: () => 7, + removeFunction: () => undefined, + }, + }; + if (opts.imageMatrixSetter !== false) { + module.FPDFImageObj_SetMatrix = ( + obj: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => { + state.matrixCalls.push([obj, a, b, c, d, e, f]); + return true; + }; + } + if (opts.insertAtIndex !== false) { + module.FPDFPage_InsertObjectAtIndex = ( + _p: number, + ptr: number, + index: number, + ) => { + objs.splice(index, 0, ptr); + return true; + }; + } + if (opts.jpeg) { + module.FPDFImageObj_LoadJpegFileInline = () => { + state.jpegLoads += 1; + return true; + }; + } + state.module = module as unknown as EditorDocument["module"]; + return state; +} + +function pageWithImage(): Page { + const page = new Page({ index: 0, pagePtr: 1, width: 600, height: 800 }); + page.setImages([ + new ImageObject({ + id: "img1", + pageIndex: 0, + pdfiumObjPtr: OLD_PTR, + bounds: { ...BOX }, + matrix: { ...ROTATED }, + }), + ]); + return page; +} + +function fakeDoc(fake: FakeModule, page: Page): EditorDocument { + return { + module: fake.module, + docPtr: 9, + page: () => page, + } as unknown as EditorDocument; +} + +/** Replacement pixels with a deliberately different aspect ratio (4x1). */ +const REPLACEMENT = { + rgba: new Uint8Array(4 * 1 * 4).fill(200), + width: 4, + height: 1, +}; + +function makeCommand(jpegBytes?: Uint8Array): ReplaceImageCommand { + return new ReplaceImageCommand({ + pageIndex: 0, + imageId: "img1", + image: REPLACEMENT, + jpegBytes, + }); +} + +describe("ReplaceImageCommand", () => { + it("keeps the existing placement matrix exactly, whatever the new pixel ratio", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR]); + makeCommand().apply(fakeDoc(fake, page)); + + const img = page.images[0]; + expect(img.pdfiumObjPtr).not.toBe(OLD_PTR); + // The written matrix is the captured one, NOT a rebuilt (w,0,0,h,x,y). + expect(fake.matrixCalls).toEqual([ + [img.pdfiumObjPtr, 0, 120, -80, 0, 300, 40], + ]); + expect(img.matrix).toEqual(ROTATED); + expect(img.bounds).toEqual(BOX); + }); + + it("puts the replacement back in the old object's z-order slot", () => { + const page = pageWithImage(); + const fake = fakePdfium([7, OLD_PTR, 9]); + makeCommand().apply(fakeDoc(fake, page)); + + expect(fake.objs).toEqual([7, page.images[0].pdfiumObjPtr, 9]); + }); + + it("detaches the old object WITHOUT destroying it, so undo is not a use-after-free", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR]); + makeCommand().apply(fakeDoc(fake, page)); + + expect(fake.objs).not.toContain(OLD_PTR); + expect(fake.destroyed).toEqual([]); + }); + + it("marks the page dirty and needing regeneration instead of generating content", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR]); + const rev0 = page.revision; + makeCommand().apply(fakeDoc(fake, page)); + + expect(page.revision).toBeGreaterThan(rev0); + expect(page.needsGenerateContent).toBe(true); + expect(page.images[0].dirty).toBe(true); + expect(fake.generateCalls).toBe(0); + }); + + it("revert restores the original object, matrix and bounds", () => { + const page = pageWithImage(); + const fake = fakePdfium([7, OLD_PTR, 9]); + const doc = fakeDoc(fake, page); + const cmd = makeCommand(); + cmd.apply(doc); + const replacement = page.images[0].pdfiumObjPtr; + + cmd.revert(doc); + + expect(fake.objs).toEqual([7, OLD_PTR, 9]); + expect(page.images[0].pdfiumObjPtr).toBe(OLD_PTR); + expect(page.images[0].matrix).toEqual(ROTATED); + expect(page.images[0].bounds).toEqual(BOX); + // The replacement survives for redo, so it must not have been destroyed. + expect(fake.destroyed).not.toContain(replacement); + expect(page.needsGenerateContent).toBe(true); + }); + + it("redo re-attaches the same replacement instead of embedding twice", () => { + const page = pageWithImage(); + const fake = fakePdfium([7, OLD_PTR, 9]); + const doc = fakeDoc(fake, page); + const cmd = makeCommand(); + cmd.apply(doc); + const replacement = page.images[0].pdfiumObjPtr; + cmd.revert(doc); + cmd.apply(doc); + + expect(fake.newImageObjs).toBe(1); + expect(fake.objs).toEqual([7, replacement, 9]); + expect(page.images[0].pdfiumObjPtr).toBe(replacement); + expect(page.images[0].matrix).toEqual(ROTATED); + }); + + it("falls back to the FS_MATRIX setter when FPDFImageObj_SetMatrix is missing", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR], { imageMatrixSetter: false }); + makeCommand().apply(fakeDoc(fake, page)); + + const written = fake.structMatrixCalls.at(-1); + expect(written?.slice(1)).toEqual([0, 120, -80, 0, 300, 40]); + expect(page.images[0].matrix).toEqual(ROTATED); + }); + + it("embeds supplied JPEG bytes as-is rather than re-encoding the bitmap", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR], { jpeg: true }); + makeCommand(new Uint8Array([0xff, 0xd8, 0xff, 0xd9])).apply( + fakeDoc(fake, page), + ); + + expect(fake.jpegLoads).toBe(1); + expect(fake.bitmapsCreated).toBe(0); + expect(fake.matrixCalls.at(-1)?.slice(1)).toEqual([ + 0, 120, -80, 0, 300, 40, + ]); + }); + + it("is a no-op for an unknown image id", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR]); + const cmd = new ReplaceImageCommand({ + pageIndex: 0, + imageId: "missing", + image: REPLACEMENT, + }); + cmd.apply(fakeDoc(fake, page)); + cmd.revert(fakeDoc(fake, page)); + + expect(fake.objs).toEqual([OLD_PTR]); + expect(fake.newImageObjs).toBe(0); + expect(page.revision).toBe(0); + }); + + it("leaves the page untouched when the embed fails", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR]); + ( + fake.module as unknown as Record + ).FPDFPageObj_NewImageObj = () => 0; + makeCommand().apply(fakeDoc(fake, page)); + + expect(fake.objs).toEqual([OLD_PTR]); + expect(page.images[0].pdfiumObjPtr).toBe(OLD_PTR); + expect(page.needsGenerateContent).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/affine.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/affine.test.ts new file mode 100644 index 0000000000..6fff639295 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/affine.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { + composeAffine, + invertAffine, + imageMatrixBounds, + remapImageMatrix, + transformRectAABB, +} from "@app/tools/pdfTextEditor/model/affine"; +import { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import type { Affine, PageRect } from "@app/tools/pdfTextEditor/types"; + +const IDENTITY: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + +function expectAffineClose(got: Affine, want: Affine): void { + for (const k of ["a", "b", "c", "d", "e", "f"] as const) { + expect(got[k]).toBeCloseTo(want[k], 4); + } +} + +describe("affine helpers", () => { + it("invertAffine inverts a rotation+translation, identity on singular", () => { + const t: Affine = { a: 0, b: -1, c: 1, d: 0, e: 5, f: 7 }; + const round = composeAffine(t, invertAffine(t)); + expectAffineClose(round, IDENTITY); + // Degenerate (zero linear part) -> identity rather than NaN. + expectAffineClose( + invertAffine({ a: 0, b: 0, c: 0, d: 0, e: 3, f: 4 }), + IDENTITY, + ); + }); + + it("imageMatrixBounds is the AABB of the unit square under the matrix", () => { + // 90deg-rotated 200x100 image -> 100 wide x 200 tall AABB. + const m: Affine = { a: 0, b: 200, c: -100, d: 0, e: 562, f: 100 }; + const b = imageMatrixBounds(m); + expect(b).toEqual({ x: 462, y: 100, width: 100, height: 200 }); + }); +}); + +describe("remapImageMatrix - unrotated page stays byte-identical", () => { + const display = IDENTITY; // CropBox==MediaBox, /Rotate 0 + + it("moving an axis-aligned image only translates it", () => { + const prev: Affine = { a: 100, b: 0, c: 0, d: 50, e: 10, f: 20 }; + const prevBounds: PageRect = { x: 10, y: 20, width: 100, height: 50 }; + const nextBounds: PageRect = { x: 60, y: 80, width: 100, height: 50 }; + expectAffineClose(remapImageMatrix(prev, prevBounds, nextBounds, display), { + a: 100, + b: 0, + c: 0, + d: 50, + e: 60, + f: 80, + }); + }); + + it("resizing an axis-aligned image rebuilds (w,0,0,h,x,y)", () => { + const prev: Affine = { a: 100, b: 0, c: 0, d: 50, e: 10, f: 20 }; + const prevBounds: PageRect = { x: 10, y: 20, width: 100, height: 50 }; + const nextBounds: PageRect = { x: 10, y: 20, width: 200, height: 100 }; + expectAffineClose(remapImageMatrix(prev, prevBounds, nextBounds, display), { + a: 200, + b: 0, + c: 0, + d: 100, + e: 10, + f: 20, + }); + }); +}); + +describe("remapImageMatrix - /Rotate 90 landscape page preserves orientation", () => { + // Portrait MediaBox 612x792 displayed landscape via /Rotate 90. + const display = DisplayTransform.fromCropAndRotate( + 0, + 0, + 612, + 792, + 1, + 792, + 612, + ); + // An image that displays upright as 200 wide x 100 tall has this raw matrix + // (rotated 90deg in raw space) and a 100x200 raw AABB. + const prev: Affine = { a: 0, b: 200, c: -100, d: 0, e: 562, f: 100 }; + const prevBounds: PageRect = { x: 462, y: 100, width: 100, height: 200 }; + + it("a no-op move returns the original matrix unchanged (no flip)", () => { + const next = remapImageMatrix(prev, prevBounds, prevBounds, display); + expectAffineClose(next, prev); + }); + + it("a move keeps the image's linear part (orientation + aspect) intact", () => { + // Drag the displayed image by (+30, +40) px in display space. That is a + // raw-space translation of A^-1 * (30,40) = (-40, 30). + const nextBounds: PageRect = { x: 422, y: 130, width: 100, height: 200 }; + const next = remapImageMatrix(prev, prevBounds, nextBounds, display); + // Linear part is byte-stable -> the image is NOT re-oriented by a move. + expect(next.a).toBeCloseTo(prev.a, 4); + expect(next.b).toBeCloseTo(prev.b, 4); + expect(next.c).toBeCloseTo(prev.c, 4); + expect(next.d).toBeCloseTo(prev.d, 4); + expect(next.e).toBeCloseTo(522, 4); + expect(next.f).toBeCloseTo(130, 4); + + // And the image still DISPLAYS as 200 wide x 100 tall (landscape upright), + // not the swapped 100x200 the old counter-rotate path produced. + const dispBox = transformRectAABB(display, imageMatrixBounds(next)); + expect(dispBox.width).toBeCloseTo(200, 3); + expect(dispBox.height).toBeCloseTo(100, 3); + }); + + it("a uniform resize scales display footprint without swapping w/h", () => { + // Halve the displayed size: 200x100 -> 100x50, anchored at same display + // lower-left. The displayed AABB stays landscape (wider than tall). + const half = remapImageMatrix( + prev, + prevBounds, + { x: 512, y: 100, width: 50, height: 100 }, + display, + ); + const dispBox = transformRectAABB(display, imageMatrixBounds(half)); + expect(dispBox.width).toBeCloseTo(100, 3); + expect(dispBox.height).toBeCloseTo(50, 3); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/canvasBackground.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/canvasBackground.test.ts new file mode 100644 index 0000000000..90b420160b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/canvasBackground.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + sampleRunBackground, + toOpaqueCss, +} from "@app/tools/pdfTextEditor/util/canvasBackground"; + +type Pixel = [number, number, number]; + +function stubCanvas( + width: number, + height: number, + pixelAt: (x: number, y: number) => Pixel, +): HTMLCanvasElement { + const ctx = { + getImageData: (sx: number, sy: number, sw: number, sh: number) => { + const data = new Uint8ClampedArray(sw * sh * 4); + for (let y = 0; y < sh; y += 1) { + for (let x = 0; x < sw; x += 1) { + const [r, g, b] = pixelAt(sx + x, sy + y); + const off = (y * sw + x) * 4; + data[off] = r; + data[off + 1] = g; + data[off + 2] = b; + data[off + 3] = 255; + } + } + return { data }; + }, + }; + return { + width, + height, + getContext: () => ctx, + } as unknown as HTMLCanvasElement; +} + +const RECT = { x: 10, y: 10, width: 30, height: 20 }; + +describe("sampleRunBackground", () => { + it("returns pure white for a white page", () => { + const canvas = stubCanvas(100, 100, () => [255, 255, 255]); + expect(sampleRunBackground(canvas, RECT)).toEqual({ + r: 255, + g: 255, + b: 255, + }); + }); + + it("serialises that white as an opaque rgb() string", () => { + const canvas = stubCanvas(100, 100, () => [255, 255, 255]); + expect(toOpaqueCss(sampleRunBackground(canvas, RECT)!)).toBe( + "rgb(255, 255, 255)", + ); + }); + + it("returns the exact colour of a flat coloured page", () => { + const canvas = stubCanvas(100, 100, () => [183, 28, 28]); + expect(sampleRunBackground(canvas, RECT)).toEqual({ r: 183, g: 28, b: 28 }); + }); + + it("averages the real pixels of the winning bucket, rounding to integers", () => { + const canvas = stubCanvas(100, 100, (_x, y) => + y % 2 === 0 ? [250, 250, 250] : [255, 255, 255], + ); + expect(sampleRunBackground(canvas, RECT)).toEqual({ + r: 253, + g: 253, + b: 253, + }); + }); + + it("ignores a minority colour in the sampled strips", () => { + const canvas = stubCanvas(100, 100, (x) => + x < 14 ? [0, 0, 0] : [240, 200, 100], + ); + expect(sampleRunBackground(canvas, RECT)).toEqual({ + r: 240, + g: 200, + b: 100, + }); + }); + + it("returns null for a degenerate rect", () => { + const canvas = stubCanvas(100, 100, () => [255, 255, 255]); + expect(sampleRunBackground(canvas, { ...RECT, width: 0 })).toBeNull(); + expect(sampleRunBackground(canvas, { ...RECT, height: 0 })).toBeNull(); + }); + + it("returns null when the canvas cannot be read", () => { + const noCtx = { width: 100, height: 100, getContext: () => null }; + expect( + sampleRunBackground(noCtx as unknown as HTMLCanvasElement, RECT), + ).toBeNull(); + const tainted = { + width: 100, + height: 100, + getContext: () => ({ + getImageData: () => { + throw new Error("tainted"); + }, + }), + }; + expect( + sampleRunBackground(tainted as unknown as HTMLCanvasElement, RECT), + ).toBeNull(); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/cloneParagraphLineSlot.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/cloneParagraphLineSlot.test.ts new file mode 100644 index 0000000000..77d7595a2e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/cloneParagraphLineSlot.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { + cloneParagraphLineSlot, + type ParagraphLineSlot, +} from "@app/tools/pdfTextEditor/model/TextRun"; + +function mkSlot(): ParagraphLineSlot { + return { + startChar: 0, + endChar: 5, + baselineY: 100, + matrixE: 10, + containerPtr: 0, + fontId: "pdf:1:Helvetica", + fontSize: 12, + fontSubset: false, + mergedFromPtrs: [11, 22], + mergedFromTexts: ["He", "llo"], + mergedFromBounds: [ + { x: 0, right: 5 }, + { x: 5, right: 10 }, + ], + mergedFromCharStarts: [0, 2], + }; +} + +describe("cloneParagraphLineSlot", () => { + it("produces an equal but independent copy", () => { + const src = mkSlot(); + const copy = cloneParagraphLineSlot(src); + expect(copy).toEqual(src); + // Nested arrays/objects must be fresh references, not shared. + expect(copy.mergedFromPtrs).not.toBe(src.mergedFromPtrs); + expect(copy.mergedFromTexts).not.toBe(src.mergedFromTexts); + expect(copy.mergedFromBounds).not.toBe(src.mergedFromBounds); + expect(copy.mergedFromBounds[0]).not.toBe(src.mergedFromBounds[0]); + expect(copy.mergedFromCharStarts).not.toBe(src.mergedFromCharStarts); + }); + + it("mutating the copy never touches the source (snapshot-safety)", () => { + const src = mkSlot(); + const snapshot = cloneParagraphLineSlot(src); + // Simulate a later in-place edit of the live slot. + src.mergedFromPtrs.push(33); + src.mergedFromTexts[0] = "XX"; + src.mergedFromBounds[0].right = 999; + src.mergedFromCharStarts[1] = 7; + expect(snapshot.mergedFromPtrs).toEqual([11, 22]); + expect(snapshot.mergedFromTexts).toEqual(["He", "llo"]); + expect(snapshot.mergedFromBounds[0].right).toBe(5); + expect(snapshot.mergedFromCharStarts).toEqual([0, 2]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/deviceFontEmbed.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/deviceFontEmbed.test.ts new file mode 100644 index 0000000000..e93948807f --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/deviceFontEmbed.test.ts @@ -0,0 +1,453 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + deviceFontEmitCount, + emitDeviceFontTextObject, + ensureDeviceFontReady, + isDeviceFontEmbedded, + isDeviceFontReady, + loadDeviceFontInto, + resetDeviceFontEmbedCache, +} from "@app/tools/pdfTextEditor/util/deviceFontEmbed"; +import { + loadLocalFontBytes, + pickLocalFontFace, + resetLocalFontsCache, +} from "@app/tools/pdfTextEditor/util/localFonts"; +import type { LocalFont } from "@app/tools/pdfTextEditor/util/localFonts"; +import type { FontRef } from "@app/tools/pdfTextEditor/model/FontRef"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; + +type QueryStub = () => Promise; + +/** One FontData-shaped face; `bytes` null means `.blob()` is absent. */ +function face( + family: string, + style: string, + bytes: Uint8Array | null, + blobImpl?: () => Promise, +): Record { + const entry: Record = { + family, + style, + fullName: `${family} ${style}`, + postscriptName: `${family.replace(/\s+/g, "")}-${style.replace(/\s+/g, "")}`, + }; + if (blobImpl) entry.blob = blobImpl; + else if (bytes) { + entry.blob = async () => ({ + arrayBuffer: async () => + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ), + }); + } + return entry; +} + +function setQuery(stub: QueryStub | null): void { + const w = window as unknown as { queryLocalFonts?: QueryStub }; + if (stub) w.queryLocalFonts = stub; + else delete w.queryLocalFonts; +} + +function plainFont(family: string, style: string): LocalFont { + return { + family, + style, + fullName: `${family} ${style}`, + postscriptName: `${family.replace(/\s+/g, "")}-${style.replace(/\s+/g, "")}`, + }; +} + +/** Not a real font file: parseTrueTypeCmap gives up, so coverage fails open. */ +const FAKE_FONT_BYTES = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + +interface FakeModuleOptions { + loadFont?: ( + doc: number, + data: number, + size: number, + type: number, + cid: boolean, + ) => number; + /** Right edge the emitted object measures at (drives the width check). */ + rightEdge?: number; + omitCreateTextObj?: boolean; +} + +interface FakeHarness { + doc: EditorDocument; + page: Page; + calls: { + loadFont: number; + createTextObj: number; + inserted: number[]; + removed: number[]; + destroyed: number[]; + freed: number[]; + malloced: number[]; + }; + ownedFonts: Map; +} + +function fakeHarness(options: FakeModuleOptions = {}): FakeHarness { + const calls = { + loadFont: 0, + createTextObj: 0, + inserted: [] as number[], + removed: [] as number[], + destroyed: [] as number[], + freed: [] as number[], + malloced: [] as number[], + }; + const heap = new Uint8Array(4096); + let nextPtr = 16; + const module = { + pdfium: { + HEAPU8: heap, + stringToUTF16: () => undefined, + getValue: () => options.rightEdge ?? 100, + wasmExports: { + malloc: (n: number) => { + const ptr = nextPtr; + nextPtr += Math.max(4, n); + calls.malloced.push(ptr); + return ptr; + }, + free: (p: number) => { + calls.freed.push(p); + }, + }, + }, + FPDFText_LoadFont: ( + doc: number, + data: number, + size: number, + type: number, + cid: boolean, + ) => { + calls.loadFont += 1; + return options.loadFont + ? options.loadFont(doc, data, size, type, cid) + : 900; + }, + FPDFFont_Close: () => undefined, + FPDFPageObj_CreateTextObj: () => { + calls.createTextObj += 1; + return 500 + calls.createTextObj; + }, + FPDFText_SetText: () => true, + FPDFPageObj_SetFillColor: () => true, + FPDFPageObj_Transform: () => true, + FPDFPage_InsertObject: (_page: number, ptr: number) => { + calls.inserted.push(ptr); + }, + FPDFPage_RemoveObject: (_page: number, ptr: number) => { + calls.removed.push(ptr); + return true; + }, + FPDFPageObj_Destroy: (ptr: number) => { + calls.destroyed.push(ptr); + }, + FPDFPageObj_GetBounds: () => true, + }; + if (options.omitCreateTextObj) { + delete (module as { FPDFPageObj_CreateTextObj?: unknown }) + .FPDFPageObj_CreateTextObj; + } + const ownedFonts = new Map(); + const doc = { + module, + docPtr: 7, + registerOwnedFont: (font: FontRef) => { + ownedFonts.set(font.id, font); + }, + ownedFont: (id: string) => ownedFonts.get(id), + } as unknown as EditorDocument; + const page = new Page({ index: 0, pagePtr: 3, width: 200, height: 200 }); + return { doc, page, calls, ownedFonts }; +} + +const FILL = { r: 0, g: 0, b: 0, a: 255 }; + +function emit(harness: FakeHarness, family: string, text = "Hi"): number { + return emitDeviceFontTextObject( + harness.doc, + harness.page, + family, + text, + 12, + FILL, + 10, + 20, + ); +} + +beforeEach(() => { + resetLocalFontsCache(); + resetDeviceFontEmbedCache(); + setQuery(null); +}); + +afterEach(() => { + resetLocalFontsCache(); + resetDeviceFontEmbedCache(); + setQuery(null); +}); + +describe("pickLocalFontFace", () => { + const faces = [ + plainFont("Segoe UI", "Bold"), + plainFont("Segoe UI", "Italic"), + plainFont("Segoe UI", "Bold Italic"), + plainFont("Segoe UI", "Regular"), + plainFont("Segoe UI", "Light"), + plainFont("Arial", "Regular"), + ]; + + it("prefers the upright regular cut for a bare family name", () => { + expect(pickLocalFontFace(faces, "Segoe UI")?.style).toBe("Regular"); + }); + + it("respects bold and italic carried in the requested name", () => { + expect(pickLocalFontFace(faces, "Segoe UI Bold")?.style).toBe("Bold"); + expect(pickLocalFontFace(faces, "Segoe UI Italic")?.style).toBe("Italic"); + expect(pickLocalFontFace(faces, "Segoe UI Bold Italic")?.style).toBe( + "Bold Italic", + ); + }); + + it("matches case- and separator-insensitively", () => { + expect(pickLocalFontFace(faces, "segoe-ui")?.family).toBe("Segoe UI"); + }); + + it("keeps a family whose own name contains a style word", () => { + const withBlack = [ + plainFont("Arial Black", "Regular"), + plainFont("Arial", "Bold"), + ]; + expect(pickLocalFontFace(withBlack, "Arial Black")?.family).toBe( + "Arial Black", + ); + }); + + it("returns null when nothing matches", () => { + expect(pickLocalFontFace(faces, "Comic Sans MS")).toBeNull(); + expect(pickLocalFontFace([], "Segoe UI")).toBeNull(); + }); +}); + +describe("loadLocalFontBytes", () => { + it("returns null when the API is unsupported", async () => { + await expect(loadLocalFontBytes("Segoe UI")).resolves.toBeNull(); + expect(isDeviceFontReady("Segoe UI")).toBe(false); + }); + + it("returns null when the permission prompt is denied", async () => { + const denied = new Error("denied"); + denied.name = "NotAllowedError"; + setQuery(vi.fn().mockRejectedValue(denied)); + await expect(ensureDeviceFontReady("Segoe UI")).resolves.toBe(false); + }); + + it("returns null when the face exposes no blob()", async () => { + setQuery( + vi.fn().mockResolvedValue([face("Segoe UI", "Regular", null)]), + ); + await expect(loadLocalFontBytes("Segoe UI")).resolves.toBeNull(); + }); + + it("returns null when the blob read rejects", async () => { + setQuery( + vi + .fn() + .mockResolvedValue([ + face("Segoe UI", "Regular", null, () => + Promise.reject(new Error("blob failed")), + ), + ]), + ); + await expect(loadLocalFontBytes("Segoe UI")).resolves.toBeNull(); + }); + + it("returns null when the blob has no arrayBuffer()", async () => { + setQuery( + vi + .fn() + .mockResolvedValue([ + face("Segoe UI", "Regular", null, async () => ({})), + ]), + ); + await expect(loadLocalFontBytes("Segoe UI")).resolves.toBeNull(); + }); + + it("reads the bytes once per family and caches them for the session", async () => { + const blob = vi.fn(async () => ({ + arrayBuffer: async () => FAKE_FONT_BYTES.buffer.slice(0), + })); + const query = vi + .fn() + .mockResolvedValue([face("Segoe UI", "Regular", null, blob)]); + setQuery(query); + + const [first, second] = await Promise.all([ + loadLocalFontBytes("Segoe UI"), + loadLocalFontBytes("Segoe UI"), + ]); + const third = await loadLocalFontBytes("Segoe UI"); + + expect(first).toBeInstanceOf(Uint8Array); + expect(second).toBe(first); + expect(third).toBe(first); + expect(query).toHaveBeenCalledTimes(1); + expect(blob).toHaveBeenCalledTimes(1); + expect(isDeviceFontReady("segoe ui")).toBe(true); + }); + + it("does not cache a failure, so a later read can still succeed", async () => { + setQuery(vi.fn().mockResolvedValue([])); + await expect(loadLocalFontBytes("Segoe UI")).resolves.toBeNull(); + + resetLocalFontsCache(); + setQuery( + vi + .fn() + .mockResolvedValue([face("Segoe UI", "Regular", FAKE_FONT_BYTES)]), + ); + await expect(loadLocalFontBytes("Segoe UI")).resolves.toBeInstanceOf( + Uint8Array, + ); + }); +}); + +describe("loadDeviceFontInto", () => { + async function warm(family = "Segoe UI"): Promise { + setQuery( + vi + .fn() + .mockResolvedValue([face(family, "Regular", FAKE_FONT_BYTES)]), + ); + await ensureDeviceFontReady(family); + } + + it("returns 0 while the byte cache is cold", () => { + const harness = fakeHarness(); + expect(loadDeviceFontInto(harness.doc, "Segoe UI")).toBe(0); + expect(harness.calls.loadFont).toBe(0); + }); + + it("embeds once per document and reuses the handle", async () => { + await warm(); + const harness = fakeHarness(); + + expect(loadDeviceFontInto(harness.doc, "Segoe UI")).toBe(900); + expect(loadDeviceFontInto(harness.doc, "Segoe UI")).toBe(900); + expect(harness.calls.loadFont).toBe(1); + expect(isDeviceFontEmbedded(harness.doc, "Segoe UI")).toBe(true); + }); + + it("embeds separately per document", async () => { + await warm(); + const a = fakeHarness(); + const b = fakeHarness(); + + loadDeviceFontInto(a.doc, "Segoe UI"); + loadDeviceFontInto(b.doc, "Segoe UI"); + + expect(a.calls.loadFont).toBe(1); + expect(b.calls.loadFont).toBe(1); + }); + + it("frees the buffer and never retries when PDFium refuses the font", async () => { + await warm(); + const harness = fakeHarness({ loadFont: () => 0 }); + + expect(loadDeviceFontInto(harness.doc, "Segoe UI")).toBe(0); + expect(loadDeviceFontInto(harness.doc, "Segoe UI")).toBe(0); + expect(harness.calls.loadFont).toBe(1); + expect(harness.calls.freed).toEqual(harness.calls.malloced); + expect(isDeviceFontEmbedded(harness.doc, "Segoe UI")).toBe(false); + }); + + it("frees the buffer when the binding throws", async () => { + await warm(); + const harness = fakeHarness({ + loadFont: () => { + throw new Error("wasm trap"); + }, + }); + + expect(loadDeviceFontInto(harness.doc, "Segoe UI")).toBe(0); + expect(harness.calls.freed).toEqual(harness.calls.malloced); + }); + + it("frees the font handle and its buffer through the owned FontRef", async () => { + await warm(); + const harness = fakeHarness(); + loadDeviceFontInto(harness.doc, "Segoe UI"); + const buffer = harness.calls.malloced[0]; + harness.calls.freed.length = 0; + + for (const font of harness.ownedFonts.values()) font.dispose(); + + expect(harness.calls.freed).toContain(buffer); + }); +}); + +describe("emitDeviceFontTextObject", () => { + async function warm(family = "Segoe UI"): Promise { + setQuery( + vi + .fn() + .mockResolvedValue([face(family, "Regular", FAKE_FONT_BYTES)]), + ); + await ensureDeviceFontReady(family); + } + + it("returns 0 without touching PDFium when the bytes are not cached", () => { + const harness = fakeHarness(); + expect(emit(harness, "Segoe UI")).toBe(0); + expect(harness.calls.createTextObj).toBe(0); + expect(deviceFontEmitCount(harness.doc, "Segoe UI")).toBe(0); + }); + + it("emits an inserted text object in the embedded face", async () => { + await warm(); + const harness = fakeHarness(); + + const ptr = emit(harness, "Segoe UI"); + + expect(ptr).toBeGreaterThan(0); + expect(harness.calls.inserted).toEqual([ptr]); + expect(harness.calls.removed).toEqual([]); + expect(deviceFontEmitCount(harness.doc, "Segoe UI")).toBe(1); + }); + + it("rejects an emit that rendered no width and cleans it up", async () => { + await warm(); + // Right edge equal to x: the face produced .notdef, not glyphs. + const harness = fakeHarness({ rightEdge: 10 }); + + const ptr = emit(harness, "Segoe UI"); + + expect(ptr).toBe(0); + expect(harness.calls.removed).toHaveLength(1); + expect(harness.calls.destroyed).toEqual(harness.calls.removed); + expect(deviceFontEmitCount(harness.doc, "Segoe UI")).toBe(0); + }); + + it("returns 0 when the CreateTextObj binding is missing", async () => { + await warm(); + const harness = fakeHarness({ omitCreateTextObj: true }); + expect(emit(harness, "Segoe UI")).toBe(0); + }); + + it("returns 0 for an unknown family and for empty text", async () => { + await warm(); + const harness = fakeHarness(); + expect(emit(harness, "Comic Sans MS")).toBe(0); + expect(emit(harness, "Segoe UI", "")).toBe(0); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/documentRisks.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/documentRisks.test.ts new file mode 100644 index 0000000000..c1a38b3062 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/documentRisks.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { + detectSaveRisks, + hasSaveRisks, + describeSaveRisks, +} from "@app/tools/pdfTextEditor/util/documentRisks"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +function mkDoc(opts: { + signatures?: number; + formType?: number; + throwOnSig?: boolean; + secHandlerRev?: number; + throwOnEncrypt?: boolean; +}): EditorDocument { + return { + docPtr: 1, + loadedPages: () => [{ pagePtr: 10 }], + module: { + FPDF_GetSignatureCount: () => { + if (opts.throwOnSig) throw new Error("no API"); + return opts.signatures ?? 0; + }, + FPDF_GetFormType: () => opts.formType ?? 0, + FPDF_GetSecurityHandlerRevision: () => { + if (opts.throwOnEncrypt) throw new Error("no API"); + return opts.secHandlerRev ?? -1; + }, + }, + } as unknown as EditorDocument; +} + +describe("detectSaveRisks", () => { + it("reports no risk for a plain document", () => { + const r = detectSaveRisks(mkDoc({})); + expect(r).toEqual({ + signatures: 0, + xfaForm: false, + encrypted: false, + droppedChars: [], + }); + expect(hasSaveRisks(r)).toBe(false); + }); + + it("flags digital signatures", () => { + const r = detectSaveRisks(mkDoc({ signatures: 2 })); + expect(r.signatures).toBe(2); + expect(hasSaveRisks(r)).toBe(true); + expect(describeSaveRisks(r)).toEqual([ + "This document carries 2 digital signatures. Your changes are appended as a new revision, so the signed version stays verifiable, but the document will report as modified since it was signed.", + ]); + }); + + it("flags XFA forms (formType 2/3) but not plain AcroForm (1)", () => { + expect(detectSaveRisks(mkDoc({ formType: 1 })).xfaForm).toBe(false); + expect(detectSaveRisks(mkDoc({ formType: 2 })).xfaForm).toBe(true); + expect(detectSaveRisks(mkDoc({ formType: 3 })).xfaForm).toBe(true); + }); + + it("singular wording for one signature", () => { + expect( + describeSaveRisks({ + signatures: 1, + xfaForm: false, + encrypted: false, + droppedChars: [], + }), + ).toEqual([ + "This document carries a digital signature. Your changes are appended as a new revision, so the signed version stays verifiable, but the document will report as modified since it was signed.", + ]); + }); + + it("flags characters dropped because no font could render them", () => { + const r = { + signatures: 0, + xfaForm: false, + encrypted: false, + droppedChars: ["中", "文"], + }; + expect(hasSaveRisks(r)).toBe(true); + expect(describeSaveRisks(r)).toEqual([ + "Some characters could not be embedded in any available font and were dropped: 中 文", + ]); + }); + + it("truncates a long dropped-char list with a +N more suffix", () => { + const dropped = Array.from({ length: 15 }, (_, i) => + String.fromCharCode(0x4e00 + i), + ); + const line = describeSaveRisks({ + signatures: 0, + xfaForm: false, + encrypted: false, + droppedChars: dropped, + })[0]; + expect(line).toContain("(+3 more)"); + }); + + it("clamps negative signature counts and survives a missing API", () => { + expect(detectSaveRisks(mkDoc({ signatures: -1 })).signatures).toBe(0); + expect(detectSaveRisks(mkDoc({ throwOnSig: true })).signatures).toBe(0); + }); + + it("combines both risks", () => { + const r = detectSaveRisks(mkDoc({ signatures: 1, formType: 2 })); + expect(describeSaveRisks(r)).toEqual([ + "This document carries a digital signature. Your changes are appended as a new revision, so the signed version stays verifiable, but the document will report as modified since it was signed.", + "Interactive XFA form data may be lost.", + ]); + }); + + it("flags an encrypted document and survives a missing API", () => { + expect(detectSaveRisks(mkDoc({ secHandlerRev: -1 })).encrypted).toBe(false); + const r = detectSaveRisks(mkDoc({ secHandlerRev: 3 })); + expect(r.encrypted).toBe(true); + expect(hasSaveRisks(r)).toBe(true); + expect(describeSaveRisks(r)).toContain( + "This PDF is encrypted; the saved copy will NOT be encrypted (password and access restrictions are removed).", + ); + expect(detectSaveRisks(mkDoc({ throwOnEncrypt: true })).encrypted).toBe( + false, + ); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/editorDirtyState.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/editorDirtyState.test.ts new file mode 100644 index 0000000000..27e1947abc --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/editorDirtyState.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +function makeCmd(type = "test"): Command { + return { type, apply: vi.fn(), revert: vi.fn() } as unknown as Command; +} + +function makeKeyedCmd(key: string): Command { + return { + type: "keyed", + apply: vi.fn(), + revert: vi.fn(), + coalesceKey: () => key, + } as unknown as Command; +} + +function makeDoc(): EditorDocument { + return { + pageCount: 0, + loadedPages: () => [], + dispose: () => {}, + } as unknown as EditorDocument; +} + +async function makeStore(): Promise { + const store = new EditorStore(); + await store.setDocument(makeDoc()); + return store; +} + +describe("EditorStore dirty tracking", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("a freshly loaded document is clean", async () => { + const store = await makeStore(); + expect(store.getState().dirty).toBe(false); + }); + + it("an edit dirties the document and saving clears it", async () => { + const store = await makeStore(); + store.dispatch(makeCmd("a")); + expect(store.getState().dirty).toBe(true); + store.markSaved(); + expect(store.getState().dirty).toBe(false); + }); + + it("undoing past the saved point reports dirty", async () => { + const store = await makeStore(); + store.dispatch(makeCmd("a")); + store.markSaved(); + store.undo(); + expect(store.getState().dirty).toBe(true); + }); + + it("a new edit after save then undo reports dirty", async () => { + const store = await makeStore(); + store.dispatch(makeCmd("a")); + store.markSaved(); + store.undo(); + store.dispatch(makeCmd("b")); + expect(store.getState().dirty).toBe(true); + }); + + it("undoing back to the saved point reports clean", async () => { + const store = await makeStore(); + store.dispatch(makeCmd("a")); + store.markSaved(); + store.dispatch(makeCmd("b")); + expect(store.getState().dirty).toBe(true); + store.undo(); + expect(store.getState().dirty).toBe(false); + }); + + it("redoing away from the saved point reports dirty", async () => { + const store = await makeStore(); + store.dispatch(makeCmd("a")); + store.markSaved(); + store.dispatch(makeCmd("b")); + store.undo(); + store.redo(); + expect(store.getState().dirty).toBe(true); + }); + + it("a coalescable edit after saving cannot rejoin the saved step", async () => { + const store = await makeStore(); + store.dispatch(makeKeyedCmd("run:1")); + store.dispatch(makeKeyedCmd("run:1")); + expect(store.history.size().undo).toBe(1); + store.markSaved(); + store.dispatch(makeKeyedCmd("run:1")); + expect(store.history.size().undo).toBe(2); + expect(store.getState().dirty).toBe(true); + }); + + it("undoing a post-save coalesced burst returns to the saved step", async () => { + const store = await makeStore(); + store.dispatch(makeKeyedCmd("run:1")); + store.markSaved(); + store.dispatch(makeKeyedCmd("run:1")); + store.dispatch(makeKeyedCmd("run:1")); + expect(store.getState().dirty).toBe(true); + store.undo(); + expect(store.getState().dirty).toBe(false); + }); + + it("resetAll returns to clean only when the base was the saved state", async () => { + const store = await makeStore(); + store.dispatch(makeCmd("a")); + store.resetAll(); + expect(store.getState().dirty).toBe(false); + + store.dispatch(makeCmd("b")); + store.markSaved(); + store.resetAll(); + expect(store.getState().dirty).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/embeddedFace.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/embeddedFace.test.ts new file mode 100644 index 0000000000..49d5593317 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/embeddedFace.test.ts @@ -0,0 +1,365 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + embeddedFaceFamily, + isEmbeddedFaceReady, + onEmbeddedFaceLoaded, + registerEmbeddedFace, + resetEmbeddedFaces, +} from "@app/tools/pdfTextEditor/util/embeddedFace"; + +type PdfiumModule = Parameters[0]; + +const MAX_FACE_BYTES = 8 * 1024 * 1024; + +interface FaceRecord { + family: string; + size: number; + resolve: () => void; + reject: () => void; +} + +let created: FaceRecord[] = []; +let constructorThrows = false; +let addedFamilies: string[] = []; +let fontsAdd: ReturnType; +let fontsDelete: ReturnType; + +class FakeFontFace { + family: string; + rec: FaceRecord; + constructor(family: string, source: BufferSource) { + if (constructorThrows) throw new TypeError("malformed buffer"); + this.family = family; + this.rec = { + family, + size: (source as Uint8Array).byteLength, + resolve: () => {}, + reject: () => {}, + }; + created.push(this.rec); + } + load(): Promise { + return new Promise((res, rej) => { + this.rec.resolve = () => res(this); + this.rec.reject = () => rej(new Error("unsupported format")); + }); + } +} + +function faceFor(family: string): FaceRecord | undefined { + return created.find((f) => f.family === family); +} + +function makeModule( + data: Map, + heapBytes = 9 * 1024 * 1024, +): PdfiumModule { + const memory = { buffer: new ArrayBuffer(heapBytes) }; + let next = 8; // pointer 0 means "absent" to the code under test + let live = 0; + const view = () => new DataView(memory.buffer); + const fake = { + pdfium: { + wasmExports: { + memory, + malloc(n: number): number { + const ptr = next; + next += (n + 7) & ~7; + if (next > heapBytes) throw new Error("fake heap exhausted"); + live++; + return ptr; + }, + free(): void { + if (--live === 0) next = 8; + }, + }, + getValue(ptr: number): number { + return view().getInt32(ptr, true); + }, + }, + FPDFFont_GetFontData( + font: number, + buf: number, + len: number, + out: number, + ): boolean { + const bytes = data.get(font); + if (!bytes) return false; + if (buf && len >= bytes.length) { + new Uint8Array(memory.buffer).set(bytes, buf); + } + view().setInt32(out, bytes.length, true); + return true; + }, + }; + return fake as unknown as PdfiumModule; +} + +function fontBytes(sig: string | number[], size = 64): Uint8Array { + const bytes = new Uint8Array(size); + const head = + typeof sig === "string" ? [...sig].map((c) => c.charCodeAt(0)) : sig; + bytes.set(head.slice(0, size), 0); + return bytes; +} + +const TRUETYPE = [0x00, 0x01, 0x00, 0x00]; + +async function flush(): Promise { + for (let i = 0; i < 4; i++) await Promise.resolve(); +} + +beforeEach(() => { + created = []; + addedFamilies = []; + constructorThrows = false; + fontsAdd = vi.fn((face: FakeFontFace) => addedFamilies.push(face.family)); + fontsDelete = vi.fn(); + Object.defineProperty(document, "fonts", { + value: { add: fontsAdd, delete: fontsDelete }, + configurable: true, + writable: true, + }); + (globalThis as { FontFace?: unknown }).FontFace = FakeFontFace; + resetEmbeddedFaces(); +}); + +afterEach(() => { + resetEmbeddedFaces(); + delete (globalThis as { FontFace?: unknown }).FontFace; + Reflect.deleteProperty(document, "fonts"); +}); + +describe("registerEmbeddedFace format sniff", () => { + it("accepts every signature a browser can load", () => { + const data = new Map([ + [11, fontBytes(TRUETYPE)], + [12, fontBytes("true")], + [13, fontBytes("OTTO")], + [14, fontBytes("wOFF")], + [15, fontBytes("wOF2")], + ]); + const m = makeModule(data); + for (const ptr of data.keys()) registerEmbeddedFace(m, ptr); + expect(created.map((f) => f.family)).toEqual([ + embeddedFaceFamily(11), + embeddedFaceFamily(12), + embeddedFaceFamily(13), + embeddedFaceFamily(14), + embeddedFaceFamily(15), + ]); + }); + + it("skips formats FontFace refuses, before building a face", () => { + const data = new Map([ + [21, fontBytes("ttcf")], // TrueType collection + [22, fontBytes([0x01, 0x00, 0x04, 0x04])], // bare CFF + [23, fontBytes("%!PS")], // Type1 + [24, fontBytes(TRUETYPE, 3)], // too short to sniff + ]); + const m = makeModule(data); + for (const ptr of data.keys()) registerEmbeddedFace(m, ptr); + expect(created).toHaveLength(0); + }); + + it("ignores a null pointer and a font PDFium has no data for", () => { + const m = makeModule(new Map([[31, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 0); + registerEmbeddedFace(m, 32); + expect(created).toHaveLength(0); + }); + + it("tries a pointer once per document", () => { + const m = makeModule(new Map([[41, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 41); + registerEmbeddedFace(m, 41); + expect(created).toHaveLength(1); + }); + + it("does nothing when FontFace is unavailable", () => { + delete (globalThis as { FontFace?: unknown }).FontFace; + const m = makeModule(new Map([[51, fontBytes(TRUETYPE)]])); + expect(() => registerEmbeddedFace(m, 51)).not.toThrow(); + expect(created).toHaveLength(0); + }); +}); + +describe("embedded face byte budget", () => { + const big = fontBytes(TRUETYPE, MAX_FACE_BYTES); + + function moduleOf(ptrs: number[], bytes: Uint8Array): PdfiumModule { + return makeModule(new Map(ptrs.map((p) => [p, bytes]))); + } + + it("rejects a face whose reported size is beyond the per-face cap", () => { + const over = fontBytes(TRUETYPE, MAX_FACE_BYTES + 1); + registerEmbeddedFace(moduleOf([61], over), 61); + expect(created).toHaveLength(0); + }); + + it("frees the budget of a load that rejects", async () => { + const ptrs = [71, 72, 73, 74, 75, 76]; + const m = moduleOf([...ptrs, 77], big); + for (const ptr of ptrs) registerEmbeddedFace(m, ptr); + expect(created).toHaveLength(6); + for (const rec of created) rec.reject(); + await flush(); + + registerEmbeddedFace(m, 77); + expect(created).toHaveLength(7); + faceFor(embeddedFaceFamily(77))?.resolve(); + await flush(); + expect(isEmbeddedFaceReady(77)).toBe(true); + }); + + it("frees the budget when the FontFace constructor throws", () => { + constructorThrows = true; + const ptrs = [81, 82, 83, 84, 85, 86]; + const m = moduleOf([...ptrs, 87], big); + for (const ptr of ptrs) registerEmbeddedFace(m, ptr); + expect(created).toHaveLength(0); + + constructorThrows = false; + registerEmbeddedFace(m, 87); + expect(created).toHaveLength(1); + }); + + it("still skips a face once the budget is genuinely held", () => { + const ptrs = [91, 92, 93, 94, 95, 96]; + const m = moduleOf([...ptrs, 97], big); + for (const ptr of ptrs) registerEmbeddedFace(m, ptr); + registerEmbeddedFace(m, 97); + expect(created).toHaveLength(6); + }); + + it("frees the whole budget on reset", () => { + const ptrs = [101, 102, 103, 104, 105, 106]; + const m = moduleOf([...ptrs, 107], big); + for (const ptr of ptrs) registerEmbeddedFace(m, ptr); + resetEmbeddedFaces(); + registerEmbeddedFace(m, 107); + expect(created).toHaveLength(7); + }); +}); + +describe("resetEmbeddedFaces vs an in-flight load", () => { + it("drops a face that resolves after its document is gone", async () => { + const m = makeModule(new Map([[111, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 111); + const pending = faceFor(embeddedFaceFamily(111)); + + resetEmbeddedFaces(); + pending?.resolve(); + await flush(); + + expect(fontsAdd).not.toHaveBeenCalled(); + expect(isEmbeddedFaceReady(111)).toBe(false); + }); + + it("removes the faces it added and clears readiness", async () => { + const m = makeModule(new Map([[121, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 121); + faceFor(embeddedFaceFamily(121))?.resolve(); + await flush(); + expect(isEmbeddedFaceReady(121)).toBe(true); + + resetEmbeddedFaces(); + expect(fontsDelete).toHaveBeenCalledTimes(1); + expect(isEmbeddedFaceReady(121)).toBe(false); + }); + + it("re-registers a reused pointer for the new document", async () => { + const m = makeModule(new Map([[131, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 131); + resetEmbeddedFaces(); + + registerEmbeddedFace(m, 131); + expect(created).toHaveLength(2); + created[1].resolve(); + await flush(); + expect(isEmbeddedFaceReady(131)).toBe(true); + expect(addedFamilies).toEqual([embeddedFaceFamily(131)]); + }); +}); + +describe("embedded face load signal", () => { + it("reports readiness only once the face is in document.fonts", async () => { + const m = makeModule(new Map([[141, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 141); + expect(isEmbeddedFaceReady(141)).toBe(false); + + faceFor(embeddedFaceFamily(141))?.resolve(); + await flush(); + expect(isEmbeddedFaceReady(141)).toBe(true); + expect(fontsAdd).toHaveBeenCalledTimes(1); + }); + + it("stays unready when the load rejects", async () => { + const m = makeModule(new Map([[151, fontBytes(TRUETYPE)]])); + const listener = vi.fn(); + onEmbeddedFaceLoaded(listener); + registerEmbeddedFace(m, 151); + faceFor(embeddedFaceFamily(151))?.reject(); + await flush(); + expect(isEmbeddedFaceReady(151)).toBe(false); + expect(listener).not.toHaveBeenCalled(); + }); + + it("notifies subscribers once per successful load", async () => { + const listener = vi.fn(); + onEmbeddedFaceLoaded(listener); + const m = makeModule( + new Map([ + [161, fontBytes(TRUETYPE)], + [162, fontBytes("OTTO")], + ]), + ); + registerEmbeddedFace(m, 161); + registerEmbeddedFace(m, 162); + expect(listener).not.toHaveBeenCalled(); + + faceFor(embeddedFaceFamily(161))?.resolve(); + await flush(); + expect(listener).toHaveBeenCalledTimes(1); + faceFor(embeddedFaceFamily(162))?.resolve(); + await flush(); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("stops notifying after unsubscribe", async () => { + const listener = vi.fn(); + const off = onEmbeddedFaceLoaded(listener); + off(); + const m = makeModule(new Map([[171, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 171); + faceFor(embeddedFaceFamily(171))?.resolve(); + await flush(); + expect(listener).not.toHaveBeenCalled(); + }); + + it("keeps notifying a throwing subscriber's neighbours", async () => { + const bad = vi.fn(() => { + throw new Error("subscriber blew up"); + }); + const good = vi.fn(); + onEmbeddedFaceLoaded(bad); + onEmbeddedFaceLoaded(good); + const m = makeModule(new Map([[181, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 181); + faceFor(embeddedFaceFamily(181))?.resolve(); + await flush(); + expect(good).toHaveBeenCalledTimes(1); + }); + + it("keeps subscriptions across a document swap", async () => { + const listener = vi.fn(); + onEmbeddedFaceLoaded(listener); + resetEmbeddedFaces(); + + const m = makeModule(new Map([[191, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 191); + faceFor(embeddedFaceFamily(191))?.resolve(); + await flush(); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/exactLayout.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/exactLayout.test.ts new file mode 100644 index 0000000000..cf8f6a9d2d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/exactLayout.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import { + buildExactLines, + type CharPositions, +} from "@app/tools/pdfTextEditor/util/exactLayout"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; + +/** Positions for `text` where every glyph advances by `advance` points. */ +function uniform(text: string, advance = 10): CharPositions { + const starts: number[] = []; + const ends: number[] = []; + let x = 0; + for (const ch of text) { + if (ch === "\n") { + starts.push(Number.NaN); + ends.push(Number.NaN); + x = 0; + continue; + } + starts.push(x); + ends.push(x + advance); + x += advance; + } + return { starts, ends }; +} + +describe("buildExactLines", () => { + it("splits a line into word and space boxes at the captured advances", () => { + const text = "ab cd"; + const lines = buildExactLines(text, uniform(text)); + expect(lines).toHaveLength(1); + expect(lines?.[0].left).toBe(0); + expect(lines?.[0].tokens).toEqual([ + { text: "ab", width: 20, space: false }, + { text: " ", width: 10, space: true }, + { text: "cd", width: 20, space: false }, + ]); + }); + + it("tiles boxes so each token starts at its own captured origin", () => { + const text = "one two three"; + const positions = uniform(text); + const lines = buildExactLines(text, positions); + let x = lines?.[0].left ?? 0; + let at = 0; + for (const token of lines?.[0].tokens ?? []) { + expect(x).toBeCloseTo(positions.starts[at], 6); + x += token.width; + at += token.text.length; + } + }); + + it("preserves an uneven justification gap rather than averaging it", () => { + // "a" then a wide gap then "b": the gap is the whole point of the capture. + const positions: CharPositions = { + starts: [0, 10, 60], + ends: [10, 60, 70], + }; + const lines = buildExactLines("a b", positions); + expect(lines?.[0].tokens.map((t) => t.width)).toEqual([10, 50, 10]); + }); + + it("gives each line of a paragraph its own left origin", () => { + const positions: CharPositions = { + starts: [0, 10, Number.NaN, 40, 50], + ends: [10, 20, Number.NaN, 50, 60], + }; + const lines = buildExactLines("ab\ncd", positions); + expect(lines).toHaveLength(2); + expect(lines?.[0].left).toBe(0); + expect(lines?.[1].left).toBe(40); + }); + + it("drops the engine-trimmed trailing spaces into a zero-width token", () => { + const positions: CharPositions = { + starts: [0, 10, Number.NaN], + ends: [10, 20, Number.NaN], + }; + const lines = buildExactLines("ab ", positions); + expect(lines?.[0].tokens).toEqual([ + { text: "ab", width: 20, space: false }, + { text: " ", width: 0, space: true }, + ]); + }); + + it("keeps every character of the text, so innerText still round-trips", () => { + const text = "hello there friend\nsecond line"; + const lines = buildExactLines(text, uniform(text)); + const rebuilt = (lines ?? []) + .map((line) => line.tokens.map((t) => t.text).join("")) + .join("\n"); + expect(rebuilt).toBe(text); + }); + + it("derives a synthesised space's width from the gap the engine left", () => { + // The grouper inserts this space between two separately-drawn words, so + // it backs no glyph and has no captured position of its own. + const positions: CharPositions = { + starts: [0, 10, Number.NaN, 45, 55], + ends: [10, 20, Number.NaN, 55, 65], + }; + const lines = buildExactLines("ab cd", positions); + expect(lines?.[0].tokens).toEqual([ + { text: "ab", width: 20, space: false }, + { text: " ", width: 25, space: true }, + { text: "cd", width: 20, space: false }, + ]); + }); + + it("still bails when a synthesised space has no word to measure against", () => { + const positions: CharPositions = { + starts: [0, 10, Number.NaN], + ends: [10, 20, Number.NaN], + }; + // Trailing spaces are trimmed, so put the unknown space mid-line with + // nothing usable after it. + expect( + buildExactLines("ab x", { + starts: [0, 10, Number.NaN, Number.NaN], + ends: [10, 20, Number.NaN, Number.NaN], + }), + ).toBeNull(); + expect(buildExactLines("ab ", positions)).not.toBeNull(); + }); + + it("returns null when a position is missing inside a word", () => { + const positions: CharPositions = { + starts: [0, Number.NaN, Number.NaN], + ends: [10, Number.NaN, Number.NaN], + }; + expect(buildExactLines("abc", positions)).toBeNull(); + }); + + it("returns null when the capture does not match the text length", () => { + expect( + buildExactLines("abc", { starts: [0, 10], ends: [10, 20] }), + ).toBeNull(); + }); + + it("returns null for empty text", () => { + expect(buildExactLines("", { starts: [], ends: [] })).toBeNull(); + }); + + it("returns null when positions run backwards", () => { + const positions: CharPositions = { starts: [50, 10], ends: [60, 20] }; + expect(buildExactLines("ab", positions)).toBeNull(); + }); +}); + +describe("capture validity", () => { + const base = { + id: "r1", + pageIndex: 0, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, + text: "ab", + fontId: "pdf:1:Helvetica", + fontSize: 12, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + }; + + function measured(): TextRun { + const run = new TextRun({ ...base, pdfiumObjPtr: 1 }); + run.charStartsX = [0, 10]; + run.charEndsX = [10, 20]; + run.charPositionsKey = run.positionsKey(); + return run; + } + + it("publishes the capture while the run is unchanged", () => { + expect(measured().snapshot().charStartsX).toEqual([0, 10]); + }); + + it("drops the capture when the text changes", () => { + const run = measured(); + run.text = "abc"; + expect(run.snapshot().charStartsX).toBeUndefined(); + }); + + it("drops the capture when the size changes, which rescales every glyph", () => { + const run = measured(); + run.fontSize = 24; + expect(run.snapshot().charStartsX).toBeUndefined(); + }); + + it("drops the capture when the family changes", () => { + const run = measured(); + run.fontId = "base14:Times-Roman"; + expect(run.snapshot().charStartsX).toBeUndefined(); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/externalImageEdit.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/externalImageEdit.test.ts new file mode 100644 index 0000000000..c513458477 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/externalImageEdit.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { deflateSync, inflateSync } from "node:zlib"; +import { + encodeRgbaAsPng, + isExternalImageEditSupported, + startExternalImageEdit, +} from "@app/tools/pdfTextEditor/util/externalImageEdit"; + +const POLL_MS = 500; + +const PIXELS = { + rgba: new Uint8Array([ + 1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255, + ]), + width: 2, + height: 2, +}; + +interface FakeFile { + lastModified: number; + arrayBuffer(): Promise; +} + +function fakeHandle() { + const state = { + written: null as Uint8Array | null, + lastModified: 1000, + bytes: new Uint8Array([9, 9, 9]), + getFileCalls: 0, + hold: false, + release: null as null | (() => void), + failWith: null as unknown, + }; + const handle = { + name: "picture.png", + createWritable: async () => ({ + write: async (data: Uint8Array) => { + state.written = data; + }, + close: async () => undefined, + }), + getFile: async (): Promise => { + state.getFileCalls += 1; + if (state.hold) { + await new Promise((resolve) => { + state.release = resolve; + }); + } + if (state.failWith) throw state.failWith; + const bytes = state.bytes; + return { + lastModified: state.lastModified, + arrayBuffer: async () => + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer, + }; + }, + }; + return { state, handle }; +} + +function stubPicker(handle: unknown) { + const picker = vi.fn(async (_options?: { suggestedName?: string }) => handle); + vi.stubGlobal("showSaveFilePicker", picker); + return picker; +} + +function pngChunkBody(png: Uint8Array, type: string): Uint8Array | null { + const view = new DataView(png.buffer, png.byteOffset, png.byteLength); + let at = 8; + while (at + 8 <= png.length) { + const length = view.getUint32(at); + const name = String.fromCharCode(...png.subarray(at + 4, at + 8)); + if (name === type) return png.subarray(at + 8, at + 8 + length); + at += 12 + length; + } + return null; +} + +/** Expected PNG raw stream: one zero filter byte in front of every RGBA row. */ +function filteredScanlines(): Uint8Array { + return new Uint8Array([ + 0, 1, 2, 3, 255, 4, 5, 6, 255, 0, 7, 8, 9, 255, 10, 11, 12, 255, + ]); +} + +class FakeCompressionStream { + readable: { + getReader(): { read(): Promise<{ done: boolean; value?: Uint8Array }> }; + }; + writable: { + getWriter(): { + write(chunk: Uint8Array): Promise; + close(): Promise; + }; + }; + + constructor(_format: string) { + const parts: Uint8Array[] = []; + let resolveClosed = (): void => {}; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + let sent = false; + this.writable = { + getWriter: () => ({ + write: async (chunk: Uint8Array) => { + parts.push(chunk); + }, + close: async () => { + resolveClosed(); + }, + }), + }; + this.readable = { + getReader: () => ({ + read: async (): Promise<{ done: boolean; value?: Uint8Array }> => { + await closed; + if (sent) return { done: true }; + sent = true; + return { done: false, value: deflateSync(Buffer.concat(parts)) }; + }, + }), + }; + } +} + +describe("encodeRgbaAsPng", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("writes a valid RGBA PNG using stored blocks when CompressionStream is absent", async () => { + vi.stubGlobal("CompressionStream", undefined); + const png = await encodeRgbaAsPng(PIXELS); + + expect(Array.from(png.subarray(0, 8))).toEqual([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + const ihdr = pngChunkBody(png, "IHDR"); + expect(ihdr && Array.from(ihdr)).toEqual([ + 0, 0, 0, 2, 0, 0, 0, 2, 8, 6, 0, 0, 0, + ]); + const idat = pngChunkBody(png, "IDAT"); + expect(idat).not.toBeNull(); + expect( + Array.from(inflateSync(Buffer.from(idat ?? new Uint8Array()))), + ).toEqual(Array.from(filteredScanlines())); + }); + + it("compresses through CompressionStream when the browser has one", async () => { + vi.stubGlobal("CompressionStream", FakeCompressionStream); + const png = await encodeRgbaAsPng(PIXELS); + + const idat = pngChunkBody(png, "IDAT"); + expect( + Array.from(inflateSync(Buffer.from(idat ?? new Uint8Array()))), + ).toEqual(Array.from(filteredScanlines())); + }); +}); + +describe("startExternalImageEdit", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("reports unsupported instead of throwing where showSaveFilePicker is missing", async () => { + vi.stubGlobal("showSaveFilePicker", undefined); + + expect(isExternalImageEditSupported()).toBe(false); + await expect( + startExternalImageEdit({ pixels: PIXELS, onChange: vi.fn() }), + ).resolves.toEqual({ status: "unsupported" }); + }); + + it("treats a cancelled picker as a normal outcome, not an error", async () => { + const abort = Object.assign(new Error("user cancelled"), { + name: "AbortError", + }); + vi.stubGlobal( + "showSaveFilePicker", + vi.fn(() => Promise.reject(abort)), + ); + + const outcome = await startExternalImageEdit({ + pixels: PIXELS, + onChange: vi.fn(), + }); + + expect(outcome).toEqual({ status: "cancelled" }); + }); + + it("writes the pixels out as a PNG under the suggested name", async () => { + const { state, handle } = fakeHandle(); + const picker = stubPicker(handle); + + const outcome = await startExternalImageEdit({ + pixels: PIXELS, + suggestedName: "logo.png", + onChange: vi.fn(), + }); + + expect(picker.mock.calls[0][0]).toMatchObject({ + suggestedName: "logo.png", + }); + expect(Array.from(state.written?.subarray(0, 4) ?? [])).toEqual([ + 0x89, 0x50, 0x4e, 0x47, + ]); + expect(outcome.status).toBe("watching"); + if (outcome.status === "watching") { + expect(outcome.watch.fileName).toBe("picture.png"); + outcome.watch.stop(); + } + }); + + it("reports the edited bytes exactly once per external save", async () => { + const { state, handle } = fakeHandle(); + stubPicker(handle); + const onChange = vi.fn(); + const outcome = await startExternalImageEdit({ + pixels: PIXELS, + pollIntervalMs: POLL_MS, + onChange, + }); + expect(outcome.status).toBe("watching"); + + await vi.advanceTimersByTimeAsync(POLL_MS * 2); + expect(onChange).not.toHaveBeenCalled(); + + state.lastModified = 2000; + state.bytes = new Uint8Array([1, 1]); + await vi.advanceTimersByTimeAsync(POLL_MS); + expect(onChange).toHaveBeenCalledTimes(1); + expect(Array.from(onChange.mock.calls[0][0] as Uint8Array)).toEqual([1, 1]); + + // Same mtime on later polls must not re-fire for the same edit. + await vi.advanceTimersByTimeAsync(POLL_MS * 3); + expect(onChange).toHaveBeenCalledTimes(1); + + state.lastModified = 3000; + state.bytes = new Uint8Array([2, 2, 2]); + await vi.advanceTimersByTimeAsync(POLL_MS); + expect(onChange).toHaveBeenCalledTimes(2); + expect(Array.from(onChange.mock.calls[1][0] as Uint8Array)).toEqual([ + 2, 2, 2, + ]); + + if (outcome.status === "watching") outcome.watch.stop(); + }); + + it("never overlaps polls when a read is slower than the interval", async () => { + const { state, handle } = fakeHandle(); + stubPicker(handle); + const onChange = vi.fn(); + const outcome = await startExternalImageEdit({ + pixels: PIXELS, + pollIntervalMs: POLL_MS, + onChange, + }); + + state.getFileCalls = 0; + state.hold = true; + state.lastModified = 2000; + await vi.advanceTimersByTimeAsync(POLL_MS * 4); + expect(state.getFileCalls).toBe(1); + + state.hold = false; + state.release?.(); + await vi.advanceTimersByTimeAsync(0); + expect(onChange).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(POLL_MS); + expect(state.getFileCalls).toBe(2); + + if (outcome.status === "watching") outcome.watch.stop(); + }); + + it("stops polling on a read error and reports it", async () => { + const { state, handle } = fakeHandle(); + stubPicker(handle); + const onError = vi.fn(); + await startExternalImageEdit({ + pixels: PIXELS, + pollIntervalMs: POLL_MS, + onChange: vi.fn(), + onError, + }); + + state.getFileCalls = 0; + state.failWith = new Error("file gone"); + await vi.advanceTimersByTimeAsync(POLL_MS); + expect(onError).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(POLL_MS * 5); + expect(state.getFileCalls).toBe(1); + }); + + it("stop() halts polling and is safe to call twice", async () => { + const { state, handle } = fakeHandle(); + stubPicker(handle); + const onChange = vi.fn(); + const outcome = await startExternalImageEdit({ + pixels: PIXELS, + pollIntervalMs: POLL_MS, + onChange, + }); + expect(outcome.status).toBe("watching"); + if (outcome.status !== "watching") return; + + state.getFileCalls = 0; + outcome.watch.stop(); + expect(() => outcome.watch.stop()).not.toThrow(); + + state.lastModified = 5000; + await vi.advanceTimersByTimeAsync(POLL_MS * 5); + expect(state.getFileCalls).toBe(0); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fitText.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fitText.test.ts new file mode 100644 index 0000000000..359c8b0662 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fitText.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { fitTextToWidth, NO_FIT } from "@app/tools/pdfTextEditor/util/fitText"; + +describe("fitTextToWidth", () => { + it("leaves text alone when it already matches", () => { + expect(fitTextToWidth("hello", 100, 100, 16)).toEqual(NO_FIT); + }); + + it("ignores sub-pixel differences", () => { + expect(fitTextToWidth("hello", 100.4, 100, 16)).toEqual(NO_FIT); + }); + + it("tightens with negative tracking when the text is too wide", () => { + // 10px over 10 chars = 1px per gap, well inside the tracking budget. + const fit = fitTextToWidth("abcdefghij", 110, 100, 16); + expect(fit.scaleX).toBe(1); + expect(fit.letterSpacing).toBeCloseTo(-1, 5); + }); + + it("loosens with positive tracking when the text is too narrow", () => { + const fit = fitTextToWidth("abcdefghij", 90, 100, 16); + expect(fit.scaleX).toBe(1); + expect(fit.letterSpacing).toBeCloseTo(1, 5); + }); + + it("scales instead of tracking when the correction is too large to hide", () => { + // 40px over 10 chars = 4px per gap on a 16px font = 0.25em, over budget. + const fit = fitTextToWidth("abcdefghij", 140, 100, 16); + expect(fit.letterSpacing).toBe(0); + expect(fit.scaleX).toBeCloseTo(100 / 140, 5); + }); + + it("scales a single character, which has no gaps to tighten", () => { + const fit = fitTextToWidth("W", 30, 20, 16); + expect(fit.letterSpacing).toBe(0); + expect(fit.scaleX).toBeCloseTo(20 / 30, 5); + }); + + it("gives up rather than squashing when the inputs disagree wildly", () => { + // A paragraph measured on one line against a single line's width. + expect(fitTextToWidth("a lot of text", 5000, 100, 14)).toEqual(NO_FIT); + expect(fitTextToWidth("x", 10, 100, 14)).toEqual(NO_FIT); + }); + + it("is inert for empty or degenerate input", () => { + expect(fitTextToWidth("", 100, 50, 16)).toEqual(NO_FIT); + expect(fitTextToWidth("hi", 0, 50, 16)).toEqual(NO_FIT); + expect(fitTextToWidth("hi", 50, 0, 16)).toEqual(NO_FIT); + expect(fitTextToWidth("hi", NaN, 50, 16)).toEqual(NO_FIT); + }); + + it("counts a surrogate pair as one character", () => { + // Two emoji = 2 characters, so 10px of overflow is 5px per gap. + const fit = fitTextToWidth("😀😀", 110, 100, 64); + expect(fit.letterSpacing).toBeCloseTo(-5, 5); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontCapability.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontCapability.test.ts new file mode 100644 index 0000000000..2b32f38db5 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontCapability.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { + canToggleItalic, + fallbackFamilyFor, + fallbackFontIdFor, + italicCapability, + resetDocumentFontMatchCache, + warmDocumentDeviceFonts, +} from "@app/tools/pdfTextEditor/util/fontCapability"; +import { + listLocalFonts, + loadLocalFontBytes, + resetLocalFontsCache, +} from "@app/tools/pdfTextEditor/util/localFonts"; +import type { LocalFont } from "@app/tools/pdfTextEditor/util/localFonts"; + +// The editor used to answer "make this italic" for ANY font by swapping the run +// wholesale to Helvetica-Oblique. For a document set in Calibri that is not +// italic, it is losing the typeface - and it happened silently, because the +// toolbar had no way to say the change was impossible. +// +// The same blind spot cost subset-embedded runs their face on every edit: +// helveticaVariantFor threw the family name away, so the device-font emit path +// (which needs the real family) could never fire, even with the face installed. + +const CALIBRI: LocalFont[] = [ + { + family: "Calibri", + fullName: "Calibri", + style: "Regular", + postscriptName: "Calibri", + }, + { + family: "Calibri", + fullName: "Calibri Italic", + style: "Italic", + postscriptName: "Calibri-Italic", + }, +]; + +/** An installed family with no italic cut at all. */ +const STENCIL: LocalFont[] = [ + { + family: "Stencil", + fullName: "Stencil", + style: "Regular", + postscriptName: "Stencil", + }, +]; + +function stubQueryLocalFonts(fonts: LocalFont[] | null): void { + const w = window as unknown as { queryLocalFonts?: unknown }; + if (fonts === null) { + delete w.queryLocalFonts; + return; + } + w.queryLocalFonts = vi.fn(async () => + fonts.map((font) => ({ + ...font, + blob: async () => ({ + arrayBuffer: async () => new Uint8Array([1]).buffer, + }), + })), + ); +} + +beforeEach(() => { + resetLocalFontsCache(); + resetDocumentFontMatchCache(); + stubQueryLocalFonts(null); +}); + +describe("italicCapability", () => { + it("flips a base-14 family in place", () => { + expect(italicCapability("base14:Helvetica", true, null)).toEqual({ + family: "Helvetica-Oblique", + source: "base14", + }); + expect(italicCapability("base14:Times-BoldItalic", false, null)).toEqual({ + family: "Times-Bold", + source: "base14", + }); + }); + + it("refuses an embedded family with no device fonts loaded", () => { + expect(italicCapability("pdf:4242:Calibri", true, null).family).toBeNull(); + }); + + it("refuses a subset family whose installed face has no italic cut", () => { + expect( + italicCapability("pdf:4242:Stencil", true, STENCIL).family, + ).toBeNull(); + }); + + it("uses the installed italic cut of the run's own family", () => { + expect(italicCapability("pdf:4242:Calibri", true, CALIBRI)).toEqual({ + family: "Calibri Italic", + source: "device", + }); + }); + + it("never substitutes a different typeface", () => { + // The whole point: Calibri does not become Helvetica just to look slanted. + const cap = italicCapability("pdf:4242:Calibri", true, STENCIL); + expect(cap.family).toBeNull(); + expect(cap.source).toBeNull(); + }); +}); + +describe("canToggleItalic", () => { + it("is false for an empty selection", () => { + expect(canToggleItalic([], CALIBRI)).toBe(false); + }); + + it("needs EVERY run to be capable", () => { + expect( + canToggleItalic(["base14:Helvetica", "base14:Times-Roman"], null), + ).toBe(true); + expect( + canToggleItalic(["base14:Helvetica", "pdf:1:Stencil"], STENCIL), + ).toBe(false); + }); +}); + +describe("fallbackFamilyFor", () => { + it("falls back to Helvetica when the family is not installed", () => { + expect(fallbackFamilyFor("pdf:4242:Calibri")).toBe("Helvetica"); + expect(fallbackFontIdFor("Helvetica")).toBe("base14:Helvetica"); + }); + + it("keeps a subset family whose real face is loaded", async () => { + stubQueryLocalFonts(CALIBRI); + await listLocalFonts(); + await warmDocumentDeviceFonts(["pdf:4242:Calibri"]); + + // Completing the subset now costs the document nothing: the edit re-emits + // in Calibri's real bytes rather than Helvetica. + expect(fallbackFamilyFor("pdf:4242:Calibri")).toBe("Calibri"); + expect(fallbackFontIdFor("Calibri")).toBe("device:Calibri"); + }); + + it("does not forget the face on the NEXT edit", async () => { + stubQueryLocalFonts(CALIBRI); + await listLocalFonts(); + await warmDocumentDeviceFonts(["pdf:4242:Calibri"]); + + // The id an edit leaves behind must still resolve to the same real family. + const nextId = fallbackFontIdFor(fallbackFamilyFor("pdf:4242:Calibri")); + expect(fallbackFamilyFor(nextId)).toBe("Calibri"); + }); +}); + +describe("warmDocumentDeviceFonts", () => { + it("matches only the document's own families, exactly", async () => { + stubQueryLocalFonts(CALIBRI); + await listLocalFonts(); + const matched = await warmDocumentDeviceFonts([ + "base14:Helvetica", + "pdf:1:Calibri", + "pdf:2:SomeFontNobodyHas", + ]); + expect(matched).toEqual(["Calibri"]); + expect(await loadLocalFontBytes("SomeFontNobodyHas")).toBeNull(); + }); + + it("is a no-op before the user loads their device fonts", async () => { + expect(await warmDocumentDeviceFonts(["pdf:1:Calibri"])).toEqual([]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontFamily.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontFamily.test.ts new file mode 100644 index 0000000000..798ab1cb00 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontFamily.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { + flipBold, + flipItalic, + nearestStandardFont, +} from "@app/tools/pdfTextEditor/util/fontFamily"; + +// The base-14 combined styles have EXACT PostScript spellings (Times uses +// Roman/Italic/BoldItalic; Helvetica/Courier use Oblique/BoldOblique). +describe("fontFamily base-14 style flips", () => { + it("bold-on preserves italic with the canonical combined name", () => { + expect(flipBold("Times-Italic", true)).toBe("Times-BoldItalic"); + expect(flipBold("Helvetica-Oblique", true)).toBe("Helvetica-BoldOblique"); + expect(flipBold("Courier-Oblique", true)).toBe("Courier-BoldOblique"); + }); + + it("italic-on preserves bold with the canonical combined name", () => { + expect(flipItalic("Times-Bold", true)).toBe("Times-BoldItalic"); + expect(flipItalic("Helvetica-Bold", true)).toBe("Helvetica-BoldOblique"); + // Courier italic was previously unrepresentable (returned null). + expect(flipItalic("Courier", true)).toBe("Courier-Oblique"); + expect(flipItalic("Courier-Bold", true)).toBe("Courier-BoldOblique"); + }); + + it("turning a style off returns the correct base / single-style name", () => { + expect(flipBold("Times-BoldItalic", false)).toBe("Times-Italic"); + expect(flipItalic("Times-BoldItalic", false)).toBe("Times-Bold"); + expect(flipBold("Helvetica-BoldOblique", false)).toBe("Helvetica-Oblique"); + expect(flipItalic("Helvetica-BoldOblique", false)).toBe("Helvetica-Bold"); + expect(flipBold("Helvetica-Bold", false)).toBe("Helvetica"); + expect(flipItalic("Times-Italic", false)).toBe("Times-Roman"); + }); + + it("returns null for non-base-14 families", () => { + expect(flipBold("LMRoman12", true)).toBeNull(); + expect(flipItalic("ABCDEF+CustomFont", true)).toBeNull(); + }); +}); + +/** An unknown family must be substituted, not dropped along with the text. */ +describe("nearestStandardFont", () => { + it("passes a standard font through untouched", () => { + expect(nearestStandardFont("Helvetica")).toBe("Helvetica"); + expect(nearestStandardFont("Times-BoldItalic")).toBe("Times-BoldItalic"); + expect(nearestStandardFont("Courier-Oblique")).toBe("Courier-Oblique"); + }); + + it("maps a device sans-serif family onto Helvetica", () => { + expect(nearestStandardFont("Segoe UI")).toBe("Helvetica"); + expect(nearestStandardFont("Arial")).toBe("Helvetica"); + }); + + it("recognises serif and monospace families by name", () => { + expect(nearestStandardFont("Georgia")).toBe("Times-Roman"); + expect(nearestStandardFont("Garamond")).toBe("Times-Roman"); + expect(nearestStandardFont("Consolas")).toBe("Courier"); + expect(nearestStandardFont("JetBrains Mono")).toBe("Courier"); + }); + + it("carries weight and slant across the substitution", () => { + expect(nearestStandardFont("Segoe UI Bold")).toBe("Helvetica-Bold"); + expect(nearestStandardFont("Georgia Bold Italic")).toBe("Times-BoldItalic"); + expect(nearestStandardFont("Consolas Italic")).toBe("Courier-Oblique"); + expect(nearestStandardFont("Inter SemiBold")).toBe("Helvetica-Bold"); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/guides.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/guides.test.ts new file mode 100644 index 0000000000..f649a377c0 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/guides.test.ts @@ -0,0 +1,419 @@ +import { describe, it, expect } from "vitest"; +import { + GuideStore, + MIN_LABEL_SPACING_PX, + MIN_TICK_SPACING_PX, + guideToLine, + lineToGuide, + rulerTicks, + snapToGuides, +} from "@app/tools/pdfTextEditor/util/guides"; +import type { Guide } from "@app/tools/pdfTextEditor/util/guides"; +import { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; + +// Pure geometry, pinned hard: ticks must stay readable at every zoom and +// snapping must be deterministic - a flickering snap target is worse than none. + +const ZOOMS = [ + 0.05, 0.1, 0.17, 0.25, 0.33, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3, 4, 6, 8, 12, + 16, 24, 32, 48, 64, +]; +const LENGTHS = [595.276, 841.89, 612, 792, 200, 1000.5, 2000]; + +function mkGuide(id: string, position: number): Guide { + return { id, axis: "x", position }; +} + +/** True when `step` is a 1/2/5 x 10^n ladder value. */ +function isLadderStep(step: number): boolean { + const exponent = Math.floor(Math.log10(step) + 1e-9); + const mantissa = step / Math.pow(10, exponent); + return [1, 2, 5].some((m) => Math.abs(mantissa - m) < 1e-6); +} + +function isMultiple(value: number, step: number): boolean { + const ratio = value / step; + return Math.abs(ratio - Math.round(ratio)) < 1e-6; +} + +/** Tightest on-screen gap between neighbouring marks, Infinity when under two. */ +function minGapPx(marks: Array<{ position: number }>, scale: number): number { + let min = Number.POSITIVE_INFINITY; + for (let i = 1; i < marks.length; i += 1) { + min = Math.min(min, (marks[i].position - marks[i - 1].position) * scale); + } + return min; +} + +describe("rulerTicks", () => { + it("returns nothing for degenerate lengths or scales", () => { + for (const [length, scale] of [ + [0, 1], + [-10, 1], + [595, 0], + [595, -1], + [Number.NaN, 1], + [595, Number.NaN], + [Number.POSITIVE_INFINITY, 1], + [595, Number.POSITIVE_INFINITY], + ]) { + expect(rulerTicks(length, scale)).toEqual({ + minorStep: 0, + majorStep: 0, + ticks: [], + }); + } + }); + + it("never crowds ticks or labels below the readable pixel thresholds", () => { + for (const scale of ZOOMS) { + for (const length of LENGTHS) { + const { minorStep, majorStep, ticks } = rulerTicks(length, scale); + const where = `length=${length} scale=${scale}`; + expect(minorStep * scale, where).toBeGreaterThanOrEqual( + MIN_TICK_SPACING_PX, + ); + expect(majorStep * scale, where).toBeGreaterThanOrEqual( + MIN_LABEL_SPACING_PX, + ); + // Measured on screen, not inferred from the step. + expect(minGapPx(ticks, scale), where).toBeGreaterThanOrEqual( + MIN_TICK_SPACING_PX - 1e-9, + ); + expect( + minGapPx( + ticks.filter((t) => t.label !== null), + scale, + ), + where, + ).toBeGreaterThanOrEqual(MIN_LABEL_SPACING_PX - 1e-9); + } + } + }); + + it("keeps both steps on the 1/2/5 ladder with major a multiple of minor", () => { + for (const scale of ZOOMS) { + for (const length of LENGTHS) { + const { minorStep, majorStep } = rulerTicks(length, scale); + const where = `length=${length} scale=${scale}`; + expect(isLadderStep(minorStep), `${where} minor=${minorStep}`).toBe( + true, + ); + expect(isLadderStep(majorStep), `${where} major=${majorStep}`).toBe( + true, + ); + const ratio = majorStep / minorStep; + expect(Math.abs(ratio - Math.round(ratio)), where).toBeLessThan(1e-6); + expect(ratio, where).toBeGreaterThan(1); + } + } + }); + + it("labelled ticks are a strict subset sitting on round major positions", () => { + for (const scale of ZOOMS) { + for (const length of LENGTHS) { + const { majorStep, ticks } = rulerTicks(length, scale); + const where = `length=${length} scale=${scale}`; + const labelled = ticks.filter((t) => t.label !== null); + expect(labelled.length, where).toBeGreaterThan(0); + expect(labelled.length, where).toBeLessThan(ticks.length); + const offRound = labelled.filter( + (t) => !isMultiple(t.position, majorStep), + ); + expect( + offRound.map((t) => t.position), + where, + ).toEqual([]); + // The label reads the position it sits on, not an index. + const misread = labelled.filter( + (t) => Math.abs(Number(t.label) - t.position) > 1e-6, + ); + expect( + misread.map((t) => t.label), + where, + ).toEqual([]); + // `major` and `label` never disagree. + const disagree = ticks.filter((t) => t.major !== (t.label !== null)); + expect( + disagree.map((t) => t.position), + where, + ).toEqual([]); + } + } + }); + + it("covers the page from 0 to within one step of its length, strictly increasing", () => { + for (const scale of ZOOMS) { + for (const length of LENGTHS) { + const { minorStep, ticks } = rulerTicks(length, scale); + const where = `length=${length} scale=${scale}`; + expect(ticks[0].position, where).toBe(0); + expect(ticks[0].major, where).toBe(true); + const last = ticks[ticks.length - 1]; + expect(last.position, where).toBeLessThanOrEqual(length + 1e-9); + expect(length - last.position, where).toBeLessThan(minorStep); + expect(minGapPx(ticks, 1), where).toBeGreaterThan(0); + } + } + }); + + it("pins the interval at the zoom levels the editor actually uses", () => { + const cases: Array<[number, number, number]> = [ + [0.25, 50, 200], + [0.5, 20, 100], + [1, 10, 50], + [1.5, 5, 50], + [2, 5, 50], + [4, 2, 20], + ]; + for (const [scale, minorStep, majorStep] of cases) { + const ticks = rulerTicks(595.276, scale); + expect([scale, ticks.minorStep, ticks.majorStep]).toEqual([ + scale, + minorStep, + majorStep, + ]); + } + }); + + it("adds decimals to labels only when the major step is sub-point", () => { + expect(rulerTicks(600, 1).ticks[0].label).toBe("0"); + const fine = rulerTicks(20, 200); + expect(fine.majorStep).toBeLessThan(1); + const labels = fine.ticks + .filter((t) => t.label !== null) + .slice(0, 3) + .map((t) => t.label); + expect(labels.every((l) => (l ?? "").includes("."))).toBe(true); + }); + + it("stays bounded on a huge page at extreme zoom", () => { + const { ticks, minorStep } = rulerTicks(20000, 100); + expect(ticks.length).toBeLessThanOrEqual(4001); + // Widening the step, not truncating: the last tick still reaches the end. + expect(20000 - ticks[ticks.length - 1].position).toBeLessThan(minorStep); + }); +}); + +describe("snapToGuides", () => { + it("returns the value untouched when there are no guides", () => { + expect(snapToGuides(120.5, [], 5)).toEqual({ value: 120.5, guide: null }); + }); + + it("snaps inside the tolerance and leaves the value alone outside it", () => { + const guides = [mkGuide("a", 100)]; + expect(snapToGuides(103, guides, 5)).toEqual({ + value: 100, + guide: guides[0], + }); + expect(snapToGuides(97, guides, 5)).toEqual({ + value: 100, + guide: guides[0], + }); + expect(snapToGuides(106, guides, 5)).toEqual({ value: 106, guide: null }); + expect(snapToGuides(94, guides, 5)).toEqual({ value: 94, guide: null }); + }); + + it("treats the tolerance as inclusive", () => { + const guides = [mkGuide("a", 100)]; + expect(snapToGuides(105, guides, 5).guide).toBe(guides[0]); + expect(snapToGuides(105.000001, guides, 5).guide).toBeNull(); + }); + + it("picks the nearest guide, not the first in range", () => { + const guides = [mkGuide("a", 100), mkGuide("b", 108), mkGuide("c", 130)]; + expect(snapToGuides(107, guides, 10).guide?.id).toBe("b"); + expect(snapToGuides(102, guides, 10).guide?.id).toBe("a"); + }); + + it("breaks an exact tie on the lower id whatever the array order", () => { + const low = mkGuide("guide-000001", 90); + const high = mkGuide("guide-000002", 110); + expect(snapToGuides(100, [low, high], 20).guide?.id).toBe("guide-000001"); + expect(snapToGuides(100, [high, low], 20).guide?.id).toBe("guide-000001"); + }); + + it("with a zero tolerance only an exact hit snaps", () => { + const guides = [mkGuide("a", 100)]; + expect(snapToGuides(100, guides, 0).guide).toBe(guides[0]); + expect(snapToGuides(100.0001, guides, 0).guide).toBeNull(); + }); + + it("refuses to snap on a negative or non-finite tolerance", () => { + const guides = [mkGuide("a", 100)]; + expect(snapToGuides(100, guides, -1)).toEqual({ value: 100, guide: null }); + expect(snapToGuides(100, guides, Number.NaN).guide).toBeNull(); + }); + + it("ignores non-finite guide positions and values", () => { + const guides = [mkGuide("a", Number.NaN), mkGuide("b", 100)]; + expect(snapToGuides(101, guides, 5).guide?.id).toBe("b"); + const nan = snapToGuides(Number.NaN, guides, 5); + expect(Number.isNaN(nan.value)).toBe(true); + expect(nan.guide).toBeNull(); + }); +}); + +describe("guideToLine / lineToGuide", () => { + const CROP = { cl: 36, cb: 72, cw: 540, ch: 720 }; + + function mk(rotate: number): DisplayTransform { + const { cl, cb, cw, ch } = CROP; + const dw = rotate % 2 === 0 ? cw : ch; + const dh = rotate % 2 === 0 ? ch : cw; + return DisplayTransform.fromCropAndRotate(cl, cb, cw, ch, rotate, dw, dh); + } + + it("maps axes straight through on an identity page", () => { + const t = DisplayTransform.identity(600, 800); + expect(guideToLine({ axis: "x", position: 120 }, t)).toEqual({ + orientation: "vertical", + position: 120, + }); + expect(guideToLine({ axis: "y", position: 300 }, t)).toEqual({ + orientation: "horizontal", + position: 300, + }); + expect(lineToGuide({ orientation: "vertical", position: 120 }, t)).toEqual({ + axis: "x", + position: 120, + }); + }); + + it("shifts by the CropBox origin", () => { + const t = mk(0); + expect(guideToLine({ axis: "x", position: CROP.cl }, t).position).toBe(0); + expect(lineToGuide({ orientation: "horizontal", position: 0 }, t)).toEqual({ + axis: "y", + position: CROP.cb, + }); + }); + + it("swaps the drawn orientation on quarter-turned pages", () => { + for (const rotate of [1, 3]) { + const t = mk(rotate); + expect(guideToLine({ axis: "x", position: 100 }, t).orientation).toBe( + "horizontal", + ); + expect(guideToLine({ axis: "y", position: 100 }, t).orientation).toBe( + "vertical", + ); + } + for (const rotate of [0, 2]) { + const t = mk(rotate); + expect(guideToLine({ axis: "x", position: 100 }, t).orientation).toBe( + "vertical", + ); + } + }); + + it("round-trips for every rotation", () => { + for (const rotate of [0, 1, 2, 3]) { + const t = mk(rotate); + for (const seed of [ + { axis: "x" as const, position: 100 }, + { axis: "y" as const, position: 400.25 }, + ]) { + const back = lineToGuide(guideToLine(seed, t), t); + expect(back.axis, `rotate=${rotate}`).toBe(seed.axis); + expect(back.position, `rotate=${rotate}`).toBeCloseTo(seed.position, 6); + } + } + }); +}); + +describe("GuideStore", () => { + it("adds guides per page with ids that sort in creation order", () => { + const store = new GuideStore(); + const ids: string[] = []; + for (let i = 0; i < 12; i += 1) { + const guide = store.add(0, "x", i * 10); + expect(guide).not.toBeNull(); + if (guide) ids.push(guide.id); + } + expect(ids).toEqual([...ids].sort()); + expect(store.get(0)).toHaveLength(12); + expect(store.get(1)).toEqual([]); + }); + + it("rejects a non-finite position", () => { + const store = new GuideStore(); + expect(store.add(0, "x", Number.NaN)).toBeNull(); + expect(store.get(0)).toEqual([]); + }); + + it("replaces the array instead of mutating it on every change", () => { + const store = new GuideStore(); + const guide = store.add(0, "y", 50); + const before = store.get(0); + store.move(0, guide?.id ?? "", 80); + const after = store.get(0); + expect(after).not.toBe(before); + expect(before[0].position).toBe(50); + expect(after[0].position).toBe(80); + }); + + it("notifies on add / move / remove / clear but not on no-ops", () => { + const store = new GuideStore(); + const seen: Array<[number, number]> = []; + store.subscribe((pageIndex, guides) => + seen.push([pageIndex, guides.length]), + ); + const guide = store.add(2, "x", 10); + const id = guide?.id ?? ""; + store.move(2, id, 10); // same position + store.move(2, "nope", 40); // unknown id + store.remove(2, "nope"); // unknown id + store.clear(3); // page with no guides + store.move(2, id, 40); + store.remove(2, id); + store.clear(2); // already empty + expect(seen).toEqual([ + [2, 1], + [2, 1], + [2, 0], + ]); + }); + + it("clears one page or every page", () => { + const store = new GuideStore(); + store.add(0, "x", 10); + store.add(1, "y", 20); + store.clear(0); + expect(store.get(0)).toEqual([]); + expect(store.get(1)).toHaveLength(1); + store.add(0, "x", 30); + const pages: number[] = []; + store.subscribe((pageIndex) => pages.push(pageIndex)); + store.clear(); + expect(pages.sort()).toEqual([0, 1]); + expect(store.get(0)).toEqual([]); + expect(store.get(1)).toEqual([]); + }); + + it("unsubscribes cleanly", () => { + const store = new GuideStore(); + let calls = 0; + const off = store.subscribe(() => { + calls += 1; + }); + store.add(0, "x", 10); + off(); + store.add(0, "x", 20); + expect(calls).toBe(1); + }); + + it("keeps notifying when one listener throws or unsubscribes another", () => { + const store = new GuideStore(); + const calls: string[] = []; + store.subscribe(() => { + calls.push("first"); + off(); + throw new Error("boom"); + }); + const off = store.subscribe(() => calls.push("second")); + store.subscribe(() => calls.push("third")); + expect(() => store.add(0, "x", 10)).not.toThrow(); + expect(calls).toEqual(["first", "second", "third"]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/helveticaVariant.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/helveticaVariant.test.ts new file mode 100644 index 0000000000..a109f5d30d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/helveticaVariant.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { helveticaVariantFor } from "@app/tools/pdfTextEditor/util/helveticaVariant"; + +// The base-14 fallback used to map EVERY source font to a Helvetica variant. +describe("helveticaVariantFor", () => { + it("keeps sans-serif sources on Helvetica with canonical styles", () => { + expect(helveticaVariantFor("ABCDEF+Arial")).toBe("Helvetica"); + expect(helveticaVariantFor("Arial-BoldMT")).toBe("Helvetica-Bold"); + expect(helveticaVariantFor("Verdana-Italic")).toBe("Helvetica-Oblique"); + expect(helveticaVariantFor("Helvetica-BoldOblique")).toBe( + "Helvetica-BoldOblique", + ); + }); + + it("maps serif sources (incl. LaTeX Computer Modern) to Times", () => { + expect(helveticaVariantFor("ABCDEF+LMRoman12-Regular")).toBe("Times-Roman"); + expect(helveticaVariantFor("Times New Roman")).toBe("Times-Roman"); + expect(helveticaVariantFor("CMR10")).toBe("Times-Roman"); + expect(helveticaVariantFor("Georgia-BoldItalic")).toBe("Times-BoldItalic"); + expect(helveticaVariantFor("Garamond-Italic")).toBe("Times-Italic"); + expect(helveticaVariantFor("MinionPro-Bold")).toBe("Times-Bold"); + }); + + it("maps monospace sources to Courier", () => { + expect(helveticaVariantFor("Consolas")).toBe("Courier"); + expect(helveticaVariantFor("ABCDEF+CourierNew")).toBe("Courier"); + expect(helveticaVariantFor("DejaVuSansMono-Bold")).toBe("Courier-Bold"); + expect(helveticaVariantFor("MonoFont-Oblique")).toBe("Courier-Oblique"); + expect(helveticaVariantFor("SomethingMono-BoldItalic")).toBe( + "Courier-BoldOblique", + ); + }); + + it("monospace classification wins over an incidental serif keyword", () => { + // "Courier" is monospace even though it could read as a serif face. + expect(helveticaVariantFor("CourierBold")).toBe("Courier-Bold"); + }); +}); + +describe("device fonts survive an edit", () => { + it("keeps the embedded family instead of mapping it to base-14", () => { + expect(helveticaVariantFor("device:Segoe UI")).toBe("Segoe UI"); + expect(helveticaVariantFor("device:Georgia Bold")).toBe("Georgia Bold"); + }); + + it("still maps a non-device id by its style class", () => { + expect(helveticaVariantFor("pdf:12:ArialBold")).toBe("Helvetica-Bold"); + expect(helveticaVariantFor("base14:Times-Roman")).toBe("Times-Roman"); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/historyFailure.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/historyFailure.test.ts new file mode 100644 index 0000000000..20cb12ec5e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/historyFailure.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + HistoryStack, + HistoryStepError, +} from "@app/tools/pdfTextEditor/store/HistoryStack"; +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +const doc = {} as EditorDocument; + +function cmd(opts: { failRevert?: boolean; failApply?: boolean }): Command { + return { + type: "test", + apply: () => { + if (opts.failApply) throw new Error("apply blew up"); + }, + revert: () => { + if (opts.failRevert) throw new Error("revert blew up"); + }, + } as unknown as Command; +} + +describe("HistoryStack failure handling", () => { + it("surfaces a failed revert instead of leaking the raw error", () => { + const h = new HistoryStack(); + h.execute(cmd({ failRevert: true }), doc); + expect(() => h.undo(doc)).toThrow(HistoryStepError); + }); + + it("does not put a failed command back on the redo stack", () => { + const h = new HistoryStack(); + h.execute(cmd({ failRevert: true }), doc); + try { + h.undo(doc); + } catch { + /* expected */ + } + // Neither stack may claim the command: the document state is unknown. + expect(h.size()).toEqual({ undo: 0, redo: 0 }); + }); + + it("surfaces a failed redo the same way", () => { + const h = new HistoryStack(); + const c = cmd({}); + h.execute(c, doc); + h.undo(doc); + // Make the redo throw only now, after the command is on the redo stack. + (c as unknown as { apply: () => void }).apply = () => { + throw new Error("apply blew up"); + }; + expect(() => h.redo(doc)).toThrow(HistoryStepError); + expect(h.size()).toEqual({ undo: 0, redo: 0 }); + }); + + it("still reverts normally when nothing throws", () => { + const h = new HistoryStack(); + h.execute(cmd({}), doc); + expect(h.undo(doc)).not.toBeNull(); + expect(h.size()).toEqual({ undo: 0, redo: 1 }); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/lineLayout.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/lineLayout.test.ts new file mode 100644 index 0000000000..b3d1c3f4c2 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/lineLayout.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + fitTokenAdvance, + NO_TOKEN_FIT, + stackLineBoxes, +} from "@app/tools/pdfTextEditor/util/lineLayout"; + +function renderedAdvance( + charCount: number, + naturalPx: number, + fit: { letterSpacingPx: number; marginRightPx: number }, +): number { + return naturalPx + charCount * fit.letterSpacingPx + fit.marginRightPx; +} + +describe("fitTokenAdvance", () => { + it("leaves a token alone when it already measures right", () => { + expect(fitTokenAdvance(5, 80, 80, 16)).toEqual(NO_TOKEN_FIT); + }); + + it("ignores differences too small to see", () => { + expect(fitTokenAdvance(5, 80, 80.005, 16)).toEqual(NO_TOKEN_FIT); + }); + + it("tightens a token the browser laid out too wide", () => { + const fit = fitTokenAdvance(3, 26.813, 23.422, 19); + expect(fit.letterSpacingPx).toBeLessThan(0); + expect(renderedAdvance(3, 26.813, fit)).toBeCloseTo(23.422, 6); + }); + + it("widens a token the browser laid out too narrow", () => { + const fit = fitTokenAdvance(10, 70, 76.7, 19); + expect(fit.letterSpacingPx).toBeGreaterThan(0); + expect(renderedAdvance(10, 70, fit)).toBeCloseTo(76.7, 6); + }); + + it("spreads the correction between glyphs, not after the last one", () => { + const fit = fitTokenAdvance(3, 30, 24, 20); + expect(fit.letterSpacingPx).toBeCloseTo(-3, 6); + expect(fit.marginRightPx).toBeCloseTo(3, 6); + }); + + it("puts the whole correction in the margin for a single glyph", () => { + const fit = fitTokenAdvance(1, 10, 14, 16); + expect(fit.letterSpacingPx).toBe(0); + expect(fit.marginRightPx).toBeCloseTo(4, 6); + expect(renderedAdvance(1, 10, fit)).toBeCloseTo(14, 6); + }); + + it("caps tracking but still lands on the exact advance", () => { + const fit = fitTokenAdvance(4, 20, 200, 16); + expect(fit.letterSpacingPx).toBeCloseTo(0.25 * 16, 6); + expect(renderedAdvance(4, 20, fit)).toBeCloseTo(200, 6); + }); + + it("refuses nonsense inputs rather than emitting NaN", () => { + expect(fitTokenAdvance(0, 10, 20, 16)).toEqual(NO_TOKEN_FIT); + expect(fitTokenAdvance(3, Number.NaN, 20, 16)).toEqual(NO_TOKEN_FIT); + expect(fitTokenAdvance(3, 10, Number.POSITIVE_INFINITY, 16)).toEqual( + NO_TOKEN_FIT, + ); + expect(fitTokenAdvance(3, -1, 20, 16)).toEqual(NO_TOKEN_FIT); + }); +}); + +describe("stackLineBoxes", () => { + it("puts the first baseline where the caller asked", () => { + const stack = stackLineBoxes([100, 120, 140], 16, 12); + expect(stack?.topPx).toBe(88); + expect(stack?.marginTopsPx[0]).toBe(0); + }); + + it("keeps uneven leading instead of averaging it", () => { + const stack = stackLineBoxes([100, 120, 143], 16, 12); + expect(stack?.marginTopsPx).toEqual([0, 4, 7]); + }); + + it("stacks back onto the exact baselines it was given", () => { + const baselines = [100, 120, 143, 161.5]; + const stack = stackLineBoxes(baselines, 16, 12); + let y = stack!.topPx; + baselines.forEach((baseline, i) => { + y += stack!.marginTopsPx[i]; + expect(y + 12).toBeCloseTo(baseline, 6); + y += 16; + }); + }); + + it("allows a negative gap when lines overlap", () => { + const stack = stackLineBoxes([100, 110], 16, 12); + expect(stack?.marginTopsPx[1]).toBe(-6); + }); + + it("rejects input it cannot place", () => { + expect(stackLineBoxes([], 16, 12)).toBeNull(); + expect(stackLineBoxes([100, Number.NaN], 16, 12)).toBeNull(); + expect(stackLineBoxes([100], 0, 12)).toBeNull(); + expect(stackLineBoxes([100], 16, Number.NaN)).toBeNull(); + }); + + it("collapses to no gaps when the leading really is even", () => { + expect(stackLineBoxes([100, 116, 132], 16, 12)?.marginTopsPx).toEqual([ + 0, 0, 0, + ]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/localFonts.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/localFonts.test.ts new file mode 100644 index 0000000000..cbbe18f550 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/localFonts.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + groupByFamily, + isLocalFontAccessSupported, + listLocalFonts, + resetLocalFontsCache, +} from "@app/tools/pdfTextEditor/util/localFonts"; +import type { LocalFont } from "@app/tools/pdfTextEditor/util/localFonts"; + +type QueryStub = () => Promise; + +function setQuery(stub: QueryStub | null): void { + const w = window as unknown as { queryLocalFonts?: QueryStub }; + if (stub) w.queryLocalFonts = stub; + else delete w.queryLocalFonts; +} + +function face( + family: string, + style: string, + postscriptName: string, +): Record { + return { family, style, postscriptName, fullName: `${family} ${style}` }; +} + +function mkFont(family: string, style: string): LocalFont { + return { + family, + style, + fullName: `${family} ${style}`, + postscriptName: `${family}-${style}`, + }; +} + +beforeEach(() => { + resetLocalFontsCache(); + setQuery(null); +}); + +afterEach(() => { + resetLocalFontsCache(); + setQuery(null); +}); + +describe("isLocalFontAccessSupported", () => { + it("is false when the API is missing", () => { + expect(isLocalFontAccessSupported()).toBe(false); + }); + + it("is true when the API exists, without calling it", () => { + const query = vi.fn().mockResolvedValue([]); + setQuery(query); + expect(isLocalFontAccessSupported()).toBe(true); + expect(query).not.toHaveBeenCalled(); + }); +}); + +describe("listLocalFonts", () => { + it("returns null in a browser without the API", async () => { + await expect(listLocalFonts()).resolves.toBeNull(); + }); + + it("maps the faces the API returns", async () => { + setQuery( + vi + .fn() + .mockResolvedValue([ + face("Inter", "Regular", "Inter-Regular"), + face("Inter", "Bold", "Inter-Bold"), + ]), + ); + const fonts = await listLocalFonts(); + expect(fonts).toEqual([ + { + family: "Inter", + style: "Regular", + postscriptName: "Inter-Regular", + fullName: "Inter Regular", + }, + { + family: "Inter", + style: "Bold", + postscriptName: "Inter-Bold", + fullName: "Inter Bold", + }, + ]); + }); + + it("drops entries without a usable family", async () => { + setQuery( + vi + .fn() + .mockResolvedValue([ + face("Inter", "Regular", "Inter-Regular"), + { family: 42, style: "Regular" }, + { style: "Bold" }, + null, + ]), + ); + const fonts = await listLocalFonts(); + expect(fonts).toHaveLength(1); + expect(fonts?.[0]?.family).toBe("Inter"); + }); + + it("returns null when permission is denied", async () => { + for (const name of ["SecurityError", "NotAllowedError"]) { + resetLocalFontsCache(); + const error = new Error("denied"); + error.name = name; + setQuery(vi.fn().mockRejectedValue(error)); + await expect(listLocalFonts()).resolves.toBeNull(); + } + }); + + it("returns null when the API throws unexpectedly", async () => { + setQuery( + vi.fn().mockImplementation(() => { + throw new TypeError("boom"); + }), + ); + await expect(listLocalFonts()).resolves.toBeNull(); + }); + + it("returns null when the API resolves to a non-array", async () => { + setQuery( + vi.fn().mockResolvedValue(undefined as unknown as unknown[]), + ); + await expect(listLocalFonts()).resolves.toBeNull(); + }); + + it("queries once per session so the prompt fires at most once", async () => { + const query = vi + .fn() + .mockResolvedValue([face("Inter", "Regular", "Inter-Regular")]); + setQuery(query); + + const [first, second] = await Promise.all([ + listLocalFonts(), + listLocalFonts(), + ]); + await listLocalFonts(); + + expect(query).toHaveBeenCalledTimes(1); + expect(first).toBe(second); + + resetLocalFontsCache(); + await listLocalFonts(); + expect(query).toHaveBeenCalledTimes(2); + }); + + it("memoises a denial instead of re-prompting", async () => { + const error = new Error("denied"); + error.name = "NotAllowedError"; + const query = vi.fn().mockRejectedValue(error); + setQuery(query); + + await expect(listLocalFonts()).resolves.toBeNull(); + await expect(listLocalFonts()).resolves.toBeNull(); + expect(query).toHaveBeenCalledTimes(1); + }); +}); + +describe("groupByFamily", () => { + it("collapses faces into families sorted case-insensitively", () => { + const grouped = groupByFamily([ + mkFont("inter", "Regular"), + mkFont("Arial", "Bold"), + mkFont("Zapfino", "Regular"), + mkFont("bahnschrift", "Light"), + ]); + expect(grouped.map((f) => f.family)).toEqual([ + "Arial", + "bahnschrift", + "inter", + "Zapfino", + ]); + }); + + it("merges faces of one family and sorts its styles", () => { + const grouped = groupByFamily([ + mkFont("Inter", "Regular"), + mkFont("Inter", "Bold"), + mkFont("inter", "Italic"), + ]); + expect(grouped).toHaveLength(1); + expect(grouped[0]?.family).toBe("Inter"); + expect(grouped[0]?.styles).toEqual(["Bold", "Italic", "Regular"]); + }); + + it("de-duplicates styles case-insensitively and skips empty ones", () => { + const grouped = groupByFamily([ + mkFont("Inter", "Bold"), + mkFont("Inter", "bold"), + mkFont("Inter", ""), + ]); + expect(grouped[0]?.styles).toEqual(["Bold"]); + }); + + it("returns an empty list for no faces", () => { + expect(groupByFamily([])).toEqual([]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/overlayPainter.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/overlayPainter.test.ts new file mode 100644 index 0000000000..390bc21d5f --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/overlayPainter.test.ts @@ -0,0 +1,380 @@ +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + isLinePainted, + type PaintLine, + paintLines, + paintPlainText, + plainCaretOffset, + readOverlayText, + refitEditedTokens, + restoreCaretOffset, +} from "@app/tools/pdfTextEditor/util/overlayPainter"; + +const OPTS = { font: "normal 400 16px sans-serif", fontSizePx: 16 }; + +/** Line box geometry the advance tests do not care about. */ +const BOX = { heightPx: 20, marginTopPx: 0, marginLeftPx: 0 }; + +function line(text: string, marginTopPx = 0): PaintLine { + const tokens = text + .split(/( +)/) + .filter((t) => t.length > 0) + .map((t) => ({ text: t, advancePx: 10 * t.length })); + return { tokens, heightPx: 20, marginTopPx, marginLeftPx: 0 }; +} + +function host(): HTMLDivElement { + const el = document.createElement("div"); + el.contentEditable = "true"; + document.body.appendChild(el); + return el; +} + +beforeAll(() => { + HTMLCanvasElement.prototype.getContext = (() => ({ + font: "", + letterSpacing: "0px", + measureText: (text: string) => ({ + width: text.length * 8, + fontBoundingBoxAscent: 12, + fontBoundingBoxDescent: 4, + }), + })) as unknown as HTMLCanvasElement["getContext"]; +}); + +beforeEach(() => { + document.body.replaceChildren(); +}); + +describe("paintLines", () => { + it("emits one block per line", () => { + const el = host(); + paintLines(el, [line("Hello world"), line("second line")], OPTS); + expect(el.children).toHaveLength(2); + expect(isLinePainted(el)).toBe(true); + }); + + it("reads back the same text innerText would give", () => { + const el = host(); + paintLines(el, [line("Hello world"), line("second line")], OPTS); + expect(el.textContent).toBe("Hello worldsecond line"); + expect(el.children[0].textContent).toBe("Hello world"); + expect(el.children[1].textContent).toBe("second line"); + }); + + it("gives an empty line a break so it still counts as a line", () => { + const el = host(); + paintLines(el, [line("a"), line(""), line("b")], OPTS); + expect(el.children).toHaveLength(3); + expect(el.children[1].querySelector("br")).not.toBeNull(); + }); + + it("pins each line's own height and gap", () => { + const el = host(); + paintLines(el, [line("a"), line("b", 7.25)], OPTS); + const second = el.children[1] as HTMLElement; + // A FIXED height, never minHeight. A painted block is one line of the PDF - + // one text object at one pen origin - and the page cannot wrap it. Letting + // the block grow put a long line on two rows in the overlay and one on the + // page, pushing every block below it a full line-height out of register. + expect(second.style.height).toBe("20px"); + expect(second.style.minHeight).toBe(""); + expect(second.style.lineHeight).toBe("20px"); + expect(second.style.marginTop).toBe("7.25px"); + }); + + it("never lets a painted line wrap", () => { + const el = host(); + paintLines(el, [line("a long line of text"), line("b")], OPTS); + for (const block of el.children) { + // "inherit" let the container's pre-wrap reach the blocks; the PDF has + // no such thing as a soft break, so neither may these. + expect((block as HTMLElement).style.whiteSpace).toBe("pre"); + } + }); + + it("uses inline tokens, never inline-block", () => { + const el = host(); + paintLines(el, [line("Hello world")], OPTS); + const spans = el.querySelectorAll("span"); + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.style.display).toBe(""); + } + }); + + it("replaces a previous painting rather than appending to it", () => { + const el = host(); + paintLines(el, [line("first")], OPTS); + paintLines(el, [line("second"), line("third")], OPTS); + expect(el.children).toHaveLength(2); + expect(el.textContent).toBe("secondthird"); + }); +}); + +describe("caret offsets", () => { + it("counts one character per line boundary", () => { + const el = host(); + paintLines(el, [line("Hello world"), line("second line")], OPTS); + const secondLine = el.children[1]; + const textNode = secondLine.firstChild!.firstChild!; + const range = document.createRange(); + range.setStart(textNode, 4); + range.collapse(true); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + expect(plainCaretOffset(el)).toBe(16); + }); + + it("round-trips every offset across a repaint", () => { + const el = host(); + const lines = [line("Hello world"), line("second line")]; + paintLines(el, lines, OPTS); + const total = "Hello world\nsecond line".length; + for (let offset = 0; offset <= total; offset += 1) { + restoreCaretOffset(el, offset); + expect(plainCaretOffset(el)).toBe(offset); + } + }); + + it("round-trips through an empty line", () => { + const el = host(); + paintLines(el, [line("a"), line(""), line("b")], OPTS); + for (const offset of [0, 1, 2, 3]) { + restoreCaretOffset(el, offset); + expect(plainCaretOffset(el)).toBe(offset); + } + }); + + it("works on plain text too, for runs the exact path cannot place", () => { + const el = host(); + paintPlainText(el, "just one line"); + expect(isLinePainted(el)).toBe(false); + el.textContent = "just one line"; + restoreCaretOffset(el, 5); + expect(plainCaretOffset(el)).toBe(5); + }); + + it("clamps past the end instead of throwing", () => { + const el = host(); + paintLines(el, [line("abc")], OPTS); + restoreCaretOffset(el, 999); + expect(plainCaretOffset(el)).toBe(3); + }); + + it("returns null when the caret is somewhere else entirely", () => { + const el = host(); + paintLines(el, [line("abc")], OPTS); + const outside = document.createElement("div"); + outside.textContent = "elsewhere"; + document.body.appendChild(outside); + const range = document.createRange(); + range.setStart(outside.firstChild!, 2); + range.collapse(true); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + expect(plainCaretOffset(el)).toBeNull(); + }); +}); + +describe("readOverlayText", () => { + it("round-trips what paintLines wrote", () => { + const el = host(); + paintLines(el, [line("Hello world"), line("second line")], OPTS); + expect(readOverlayText(el)).toBe("Hello world\nsecond line"); + }); + + // The browser leaves a filler
in a block the user emptied. innerText + // reports that as "\n", which used to add a phantom line and push every + // line below it one leading down the page. + it("reads a block the browser emptied as ONE blank line", () => { + const el = host(); + paintLines(el, [line("4 Park Plaza"), line("Suite 1930")], OPTS); + const first = el.children[0] as HTMLElement; + first.replaceChildren(document.createElement("br")); + expect(readOverlayText(el)).toBe("\nSuite 1930"); + }); + + it("keeps the line count when every block is emptied", () => { + const el = host(); + paintLines(el, [line("a"), line("b"), line("c")], OPTS); + for (const block of Array.from(el.children)) { + block.replaceChildren(document.createElement("br")); + } + expect(readOverlayText(el)).toBe("\n\n"); + }); + + it("keeps a blank first line in the plain
DOM", () => { + const el = host(); + el.append(document.createElement("br"), document.createTextNode("abc")); + expect(readOverlayText(el)).toBe("\nabc"); + }); + + it("drops the browser's trailing filler
", () => { + const el = host(); + el.append(document.createTextNode("abc"), document.createElement("br")); + expect(readOverlayText(el)).toBe("abc"); + }); + + it("reads a lone filler
as empty, not as a line break", () => { + const el = host(); + el.appendChild(document.createElement("br")); + expect(readOverlayText(el)).toBe(""); + }); + + it("normalises non-breaking spaces the browser inserts", () => { + const el = host(); + el.appendChild(document.createTextNode("a\u00A0b")); + expect(readOverlayText(el)).toBe("a b"); + }); +}); + +describe("readOverlayText - browser-emptied blocks", () => { + // Chrome does not always leave a bare
behind. Pressing Enter at the end + // of a line leaves the new block holding an EMPTY CLONE of the token span + // with the filler
inside it - a break the walk must NOT read as a line + // of its own, or one Enter reads back as two. + it("reads a block emptied down to a token span as ONE blank line", () => { + const el = host(); + paintLines(el, [line("Second line"), line("left margin")], OPTS); + const emptied = el.children[0] as HTMLElement; + const leftover = document.createElement("span"); + leftover.setAttribute("data-pdf-editor-token", ""); + leftover.dataset.src = "line"; + leftover.appendChild(document.createElement("br")); + emptied.replaceChildren(leftover); + expect(readOverlayText(el)).toBe("\nleft margin"); + }); + + it("still splits a block that really does hold two lines", () => { + const el = host(); + paintLines(el, [line("one two")], OPTS); + const block = el.children[0] as HTMLElement; + // Firefox spells a manual break as a
INSIDE the token span it split, + // so the reader has to descend to see it. + const span = document.createElement("span"); + span.setAttribute("data-pdf-editor-token", ""); + span.replaceChildren( + document.createTextNode("one"), + document.createElement("br"), + document.createTextNode("two"), + ); + block.replaceChildren(span); + expect(readOverlayText(el)).toBe("one\ntwo"); + }); +}); + +describe("plainCaretOffset - carets that are not in a text node", () => { + // Enter parks the caret inside the empty span Chrome left behind. A tree walk + // over text nodes alone reports nothing for that position, and the repaint + // that follows then dropped the caret to the top of the run. + it("finds a caret parked inside an empty token span", () => { + const el = host(); + paintLines(el, [line("Second line"), line(""), line("left margin")], OPTS); + const blank = el.children[1] as HTMLElement; + const leftover = document.createElement("span"); + leftover.setAttribute("data-pdf-editor-token", ""); + blank.replaceChildren(leftover); + + const range = document.createRange(); + range.setStart(leftover, 0); + range.collapse(true); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + + expect(plainCaretOffset(el)).toBe("Second line\n".length); + }); + + it("finds a caret parked on the container between two blocks", () => { + const el = host(); + paintLines(el, [line("abc"), line("de")], OPTS); + const range = document.createRange(); + range.setStart(el, 1); + range.collapse(true); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + expect(plainCaretOffset(el)).toBe(4); + }); + + it("reads a container caret past the last block as the end of it", () => { + const el = host(); + paintLines(el, [line("abc"), line("de")], OPTS); + const range = document.createRange(); + range.setStart(el, 2); + range.collapse(true); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + expect(plainCaretOffset(el)).toBe("abc\nde".length); + }); +}); + +describe("refitEditedTokens", () => { + // The stub canvas above advances every face at 8px/char, so a token painted + // at a different advance is standing in for a PDF whose own face is wider or + // narrower than the one the browser has. + function tokenOf(el: HTMLElement): HTMLElement { + return el.querySelector("[data-pdf-editor-token]")!; + } + + function fittedWidth(span: HTMLElement, chars: number): number { + const ls = parseFloat(span.style.letterSpacing || "0"); + const mr = parseFloat(span.style.marginRight || "0"); + return chars * 8 + chars * ls + mr; + } + + it("re-prices a token the user typed into against the PDF's own advances", () => { + const el = host(); + paintLines(el, [{ tokens: [{ text: "ab", advancePx: 24 }], ...BOX }], OPTS); + const span = tokenOf(el); + span.textContent = "abcd"; + // "a" and "b" measured 12px each in the PDF; "c"/"d" are new, so they take + // the token's own browser-to-PDF ratio (24/16). + refitEditedTokens(el, { + ...OPTS, + advanceEm: new Map([ + ["a", 12 / OPTS.fontSizePx], + ["b", 12 / OPTS.fontSizePx], + ]), + }); + expect(fittedWidth(span, 4)).toBeCloseTo(48, 4); + }); + + it("falls back to the token's own ratio with no advance table", () => { + const el = host(); + paintLines(el, [{ tokens: [{ text: "ab", advancePx: 24 }], ...BOX }], OPTS); + const span = tokenOf(el); + span.textContent = "abcd"; + refitEditedTokens(el, OPTS); + expect(fittedWidth(span, 4)).toBeCloseTo(48, 4); + }); + + it("restores the exact fit when the edit is backspaced away", () => { + const el = host(); + paintLines(el, [{ tokens: [{ text: "ab", advancePx: 24 }], ...BOX }], OPTS); + const span = tokenOf(el); + const painted = `${span.style.letterSpacing}|${span.style.marginRight}`; + span.textContent = "abcd"; + refitEditedTokens(el, OPTS); + span.textContent = "ab"; + refitEditedTokens(el, OPTS); + expect(`${span.style.letterSpacing}|${span.style.marginRight}`).toBe( + painted, + ); + }); + + it("leaves untouched tokens exactly as painted", () => { + const el = host(); + paintLines(el, [{ tokens: [{ text: "ab", advancePx: 24 }], ...BOX }], OPTS); + const span = tokenOf(el); + const before = `${span.style.letterSpacing}|${span.style.marginRight}`; + refitEditedTokens(el, OPTS); + expect(`${span.style.letterSpacing}|${span.style.marginRight}`).toBe( + before, + ); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pageFonts.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pageFonts.test.ts new file mode 100644 index 0000000000..f94e6e5691 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pageFonts.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +import { + analyzePageFonts, + missingAlnumFromCmap, +} from "@app/tools/pdfTextEditor/util/pageFonts"; +import type { PageSnapshot } from "@app/tools/pdfTextEditor/types"; + +function mkRun(id: string, fontId: string, fontSubset = false) { + return { + id, + pageIndex: 0, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, + text: "x", + fontId, + fontSize: 12, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset, + }; +} +function mkPage(pageIndex: number, runs: ReturnType[]) { + return { + pageIndex, + width: 100, + height: 100, + revision: 0, + dirty: false, + runs, + images: [], + } as unknown as PageSnapshot; +} + +describe("analyzePageFonts", () => { + it("classifies base-14 / standard families as standard", () => { + const fonts = analyzePageFonts([ + mkPage(0, [ + mkRun("a", "base14:Helvetica"), + mkRun("b", "pdf:11:Times-Roman"), + mkRun("c", "pdf:12:Courier"), + ]), + ]); + expect(fonts.every((f) => f.status === "standard")).toBe(true); + }); + + it("classifies a fully embedded non-subset font as embedded", () => { + const fonts = analyzePageFonts([ + mkPage(0, [mkRun("a", "pdf:2212776:LMRoman12", false)]), + ]); + expect(fonts).toHaveLength(1); + expect(fonts[0].status).toBe("embedded"); + expect(fonts[0].name).toBe("LMRoman12"); + }); + + it("flags a non-standard subset font as subset and strips the tag", () => { + const fonts = analyzePageFonts([ + mkPage(0, [mkRun("a", "pdf:9:ABCDEF+LMRoman10", true)]), + ]); + expect(fonts).toHaveLength(1); + expect(fonts[0].status).toBe("subset"); + expect(fonts[0].name).toBe("LMRoman10"); + }); + + it("treats a subset of a standard family as standard (base-14 fallback is safe)", () => { + const fonts = analyzePageFonts([ + mkPage(0, [mkRun("a", "pdf:3:ABCDEF+Helvetica", true)]), + ]); + expect(fonts).toHaveLength(1); + expect(fonts[0].status).toBe("standard"); + }); + + it("classifies base-14 bold/italic variants as standard", () => { + const fonts = analyzePageFonts([ + mkPage(0, [ + mkRun("a", "pdf:1:Helvetica-BoldOblique"), + mkRun("b", "pdf:2:Times-BoldItalic"), + mkRun("c", "pdf:3:Courier-Oblique"), + mkRun("d", "pdf:4:ArialMT"), + ]), + ]); + expect(fonts.every((f) => f.status === "standard")).toBe(true); + }); + + it("does NOT mislabel a custom font that merely contains a base-14 substring", () => { + // "Arial Black" / "Helvetica Neue" are distinct fonts, and a custom font + // with "arial" mid-name is not base-14 - all must fall through to embedded. + const fonts = analyzePageFonts([ + mkPage(0, [ + mkRun("a", "pdf:5:ArialBlack", false), + mkRun("b", "pdf:6:HelveticaNeue", false), + mkRun("c", "pdf:7:MyArialClone", false), + ]), + ]); + expect(fonts.every((f) => f.status !== "standard")).toBe(true); + }); + + it("de-duplicates the same font across pages and records page numbers", () => { + const fonts = analyzePageFonts([ + mkPage(0, [mkRun("a", "pdf:5:LMRoman12", false)]), + mkPage(2, [mkRun("b", "pdf:5:LMRoman12", false)]), + ]); + expect(fonts).toHaveLength(1); + expect(fonts[0].pages).toEqual([1, 3]); + }); + + it("returns nothing when there are no runs", () => { + expect(analyzePageFonts([mkPage(0, [])])).toEqual([]); + }); + + it("reports standard fonts as full a-zA-Z0-9 coverage (no cmap read needed)", () => { + const fonts = analyzePageFonts([ + mkPage(0, [mkRun("a", "base14:Helvetica")]), + ]); + expect(fonts[0].coverage).toEqual({ known: true, missing: [] }); + }); + + it("reports coverage unknown for an embedded font with no primed cmap", () => { + const fonts = analyzePageFonts([ + mkPage(0, [mkRun("a", "pdf:777777:LMRoman12", false)]), + ]); + expect(fonts[0].coverage.known).toBe(false); + }); +}); + +describe("missingAlnumFromCmap", () => { + function cmapWith(...codepoints: number[]): Map { + const m = new Map(); + for (const cp of codepoints) m.set(cp, cp + 1); // glyphId is arbitrary + return m; + } + const ALL = (() => { + const cps: number[] = []; + for (let c = 0x30; c <= 0x39; c++) cps.push(c); + for (let c = 0x41; c <= 0x5a; c++) cps.push(c); + for (let c = 0x61; c <= 0x7a; c++) cps.push(c); + return cps; + })(); + + it("returns [] when every a-zA-Z0-9 glyph is present", () => { + expect(missingAlnumFromCmap(cmapWith(...ALL))).toEqual([]); + }); + + it("lists exactly the absent alphanumerics", () => { + const present = ALL.filter((c) => c !== 0x71 && c !== 0x57 && c !== 0x37); + expect(missingAlnumFromCmap(cmapWith(...present)).sort()).toEqual( + ["7", "W", "q"].sort(), + ); + }); + + it("reports all 62 missing for an empty cmap", () => { + expect(missingAlnumFromCmap(new Map()).length).toBe(62); + }); + + it("ignores non-alphanumeric glyphs in the cmap", () => { + expect(missingAlnumFromCmap(cmapWith(0x21, 0x2e, 0x2c)).length).toBe(62); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfFixtures.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfFixtures.ts new file mode 100644 index 0000000000..62515d36d6 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfFixtures.ts @@ -0,0 +1,140 @@ +// Hand-assembled PDFs for the raw-PDF tests: small enough to reason about +// byte by byte, where a library fixture would hide the structural variation. +import { fromLatin1 } from "@app/tools/pdfTextEditor/pdfdoc/bytes"; + +export interface FixtureObject { + num: number; + body: string; +} + +/** Build a stream object body with a correct `/Length`. */ +export function streamBody(dictInner: string, data: string): string { + const inner = dictInner.trim(); + const sep = inner.length ? `${inner} ` : ""; + return `<< ${sep}/Length ${data.length} >>\nstream\n${data}\nendstream`; +} + +/** Assemble objects into a PDF with a classic cross-reference table. */ +export function buildClassicPdf( + objects: FixtureObject[], + rootNum: number, +): Uint8Array { + const sorted = [...objects].sort((a, b) => a.num - b.num); + let out = "%PDF-1.7\n"; + const offsets = new Map(); + for (const obj of sorted) { + offsets.set(obj.num, out.length); + out += `${obj.num} 0 obj\n${obj.body}\nendobj\n`; + } + const xrefAt = out.length; + const size = sorted[sorted.length - 1].num + 1; + out += `xref\n0 ${size}\n0000000000 65535 f \n`; + for (let num = 1; num < size; num += 1) { + const off = offsets.get(num); + out += + off === undefined + ? "0000000000 65535 f \n" + : `${String(off).padStart(10, "0")} 00000 n \n`; + } + out += `trailer\n<< /Size ${size} /Root ${rootNum} 0 R /ID [ ] >>\n`; + out += `startxref\n${xrefAt}\n%%EOF`; + return fromLatin1(out); +} + +/** Assemble objects into a PDF whose newest xref section is a stream. */ +export function buildXrefStreamPdf( + objects: FixtureObject[], + rootNum: number, + /** objNum -> containing ObjStm number, emitted as a type-2 xref row. */ + compressed?: Map, +): Uint8Array { + const sorted = [...objects].sort((a, b) => a.num - b.num); + let out = "%PDF-1.7\n"; + const offsets = new Map(); + for (const obj of sorted) { + offsets.set(obj.num, out.length); + out += `${obj.num} 0 obj\n${obj.body}\nendobj\n`; + } + const xrefNum = sorted[sorted.length - 1].num + 1; + const size = xrefNum + 1; + const xrefAt = out.length; + offsets.set(xrefNum, xrefAt); + let rows = ""; + for (let num = 0; num < size; num += 1) { + const container = compressed?.get(num); + if (container !== undefined) { + // Type 2: field 2 is the container number, field 3 the index within it. + rows += String.fromCharCode( + 2, + (container >>> 24) & 0xff, + (container >>> 16) & 0xff, + (container >>> 8) & 0xff, + container & 0xff, + 0, + 0, + ); + continue; + } + const off = offsets.get(num) ?? 0; + const type = num === 0 ? 0 : offsets.has(num) ? 1 : 0; + rows += String.fromCharCode( + type, + (off >>> 24) & 0xff, + (off >>> 16) & 0xff, + (off >>> 8) & 0xff, + off & 0xff, + 0, + num === 0 ? 0xff : 0, + ); + } + const dict = + `<< /Type /XRef /W [1 4 2] /Size ${size} /Root ${rootNum} 0 R ` + + `/ID [ ] /Length ${rows.length} >>`; + out += `${xrefNum} 0 obj\n${dict}\nstream\n${rows}\nendstream\nendobj\n`; + out += `startxref\n${xrefAt}\n%%EOF`; + return fromLatin1(out); +} + +/** A one-page document whose `/Contents` is a single stream. */ +export function singleContentPdf( + content = "BT /F1 12 Tf (hi) Tj ET", +): Uint8Array { + return buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { + num: 3, + body: + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + }, + { num: 4, body: streamBody("", content) }, + { + num: 5, + body: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + }, + ], + 1, + ); +} + +// One page whose `/Contents` is an array; `tight` omits the separator, the +// shape a naive splice fuses into one token. +export function splitContentPdf(parts: string[], tight = false): Uint8Array { + const refs = parts.map((_, i) => `${4 + i} 0 R`).join(" "); + const objects: FixtureObject[] = [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { + num: 3, + body: + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + `/Resources << >> /Contents${tight ? "" : " "}[${refs}] >>`, + }, + ]; + parts.forEach((p, i) => + objects.push({ num: 4 + i, body: streamBody("", p) }), + ); + return buildClassicPdf(objects, 1); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfPasses.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfPasses.test.ts new file mode 100644 index 0000000000..419d852bac --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfPasses.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from "vitest"; +import { toLatin1 } from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import { parseOps, tokenize } from "@app/tools/pdfTextEditor/pdfdoc/contentOps"; +import { RawPdf } from "@app/tools/pdfTextEditor/pdfdoc/raw"; +import { consolidateContents } from "@app/tools/pdfTextEditor/pdfdoc/passes/consolidateContents"; +import { + extractShadingDraws, + preserveShadings, +} from "@app/tools/pdfTextEditor/pdfdoc/passes/preserveShadings"; +import { prepareForEditing } from "@app/tools/pdfTextEditor/pdfdoc/prepareForEditing"; +import { + buildClassicPdf, + singleContentPdf, + splitContentPdf, + streamBody, +} from "@app/tools/pdfTextEditor/__tests__/pdfFixtures"; + +describe("content-stream tokeniser", () => { + it("treats a parenthesised string as one token even with escapes", () => { + const tokens = tokenize("BT (a \\) b (c) d) Tj ET"); + expect(tokens.map((t) => t.text)).toEqual([ + "BT", + "(a \\) b (c) d)", + "Tj", + "ET", + ]); + }); + + it("groups operands with their operator", () => { + const ops = parseOps("1 0 0 1 20 30 cm /Sh0 sh"); + expect(ops).toHaveLength(2); + expect(ops[0].op).toBe("cm"); + expect(ops[0].operands).toEqual(["1", "0", "0", "1", "20", "30"]); + expect(ops[1].op).toBe("sh"); + expect(ops[1].operands).toEqual(["/Sh0"]); + }); + + it("does not lex the binary payload of an inline image", () => { + const ops = parseOps("BI /W 2 ID \u0001q\u0000Q EI Q"); + expect(ops.map((o) => o.op)).toEqual(["BI", "EI", "Q"]); + }); + + it("does not treat true/false/R as operators", () => { + const ops = parseOps("/GS0 gs true /X Do"); + expect(ops.map((o) => o.op)).toEqual(["gs", "Do"]); + }); +}); + +describe("consolidateContents", () => { + it("merges a multi-part /Contents array into a single stream", async () => { + const original = splitContentPdf(["q 1 0 0", "1 0 0 cm", "Q"]); + const result = await consolidateContents(original); + expect(result?.pages).toEqual([0]); + + const pdf = await RawPdf.parse(result?.bytes as Uint8Array); + const pageNum = pdf?.pageNumberAt(0) ?? 0; + const body = pdf?.objectBody(pageNum) ?? ""; + expect(pdf?.contentRefs(body)).toHaveLength(1); + + const content = toLatin1( + (await pdf?.pageContent(pageNum)) ?? new Uint8Array(), + ); + expect(content.replace(/\s+/g, " ").trim()).toBe("q 1 0 0 1 0 0 cm Q"); + }); + + it("separates the parts so two of them cannot fuse into one token", async () => { + const result = await consolidateContents( + splitContentPdf(["1 0 0 1 0 0", "cm"]), + ); + const pdf = await RawPdf.parse(result?.bytes as Uint8Array); + const content = toLatin1( + (await pdf?.pageContent(pdf?.pageNumberAt(0) ?? 0)) ?? new Uint8Array(), + ); + expect(parseOps(content).map((o) => o.op)).toEqual(["cm"]); + }); + + it("keeps the rewritten reference lexable when /Contents has no separator", async () => { + const original = splitContentPdf(["q", "Q"], true); + expect(toLatin1(original)).toContain("/Contents["); + const result = await consolidateContents(original); + const pdf = await RawPdf.parse(result?.bytes as Uint8Array); + const pageNum = pdf?.pageNumberAt(0) ?? 0; + const body = pdf?.objectBody(pageNum) ?? ""; + // Splicing without a gap yields the single name token "/Contents5", + // which costs the page every one of its objects. + expect(body).not.toMatch(/\/Contents\d/); + expect(pdf?.contentRefs(body)).toHaveLength(1); + const content = toLatin1( + (await pdf?.pageContent(pageNum)) ?? new Uint8Array(), + ); + expect(content.replace(/\s+/g, " ").trim()).toBe("q Q"); + }); + + it("leaves a single-stream document alone", async () => { + expect(await consolidateContents(singleContentPdf())).toBeNull(); + }); + + it("leaves the original bytes intact", async () => { + const original = splitContentPdf(["q", "Q"]); + const result = await consolidateContents(original); + const out = result?.bytes as Uint8Array; + expect(out.slice(0, original.length)).toEqual(original); + }); +}); + +describe("prepareForEditing", () => { + it("merges split content streams on the way in", async () => { + const out = await prepareForEditing(splitContentPdf(["q", "Q"])); + const pdf = await RawPdf.parse(out); + const body = pdf?.objectBody(pdf?.pageNumberAt(0) ?? 0) ?? ""; + expect(pdf?.contentRefs(body)).toHaveLength(1); + }); + + it("returns the same buffer when there is nothing to repair", async () => { + const original = singleContentPdf(); + expect(await prepareForEditing(original)).toBe(original); + }); + + it("returns the input untouched rather than throwing on a broken file", async () => { + const broken = new Uint8Array([ + 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0xff, 0xfe, + ]); + expect(await prepareForEditing(broken)).toBe(broken); + }); +}); + +describe("extractShadingDraws", () => { + it("returns null for a page with no shading", () => { + expect(extractShadingDraws("BT (hi) Tj ET")).toBeNull(); + }); + + it("keeps the state that positions the shading and drops the text", () => { + const extracted = extractShadingDraws( + "q 1 0 0 1 10 20 cm /GS0 gs /Sh0 sh Q BT /F1 12 Tf (hello) Tj ET", + ); + expect(extracted).not.toBeNull(); + expect(extracted?.content).toContain("1 0 0 1 10 20 cm"); + expect(extracted?.content).toContain("/GS0 gs"); + expect(extracted?.content).toContain("/Sh0 sh"); + expect(extracted?.content).not.toContain("Tj"); + expect(extracted?.content).not.toContain("Tf"); + expect(extracted?.needs.shading).toEqual(["Sh0"]); + expect(extracted?.needs.extGState).toEqual(["GS0"]); + }); + + it("keeps a clip path but paints nothing else", () => { + const extracted = extractShadingDraws( + "q 0 0 100 100 re W n 1 0 0 rg 5 5 10 10 re f /Sh0 sh Q", + ); + expect(extracted?.content).toContain("0 0 100 100 re"); + expect(extracted?.content).toContain("W"); + // The filled rectangle must survive as a path but never be painted. + expect(extracted?.content).not.toMatch(/(^|\n)f($|\n)/); + expect(extracted?.content).toContain("5 5 10 10 re"); + }); + + it("balances a stream whose q/Q pairs the generator left dangling", () => { + const extracted = extractShadingDraws("q q /Sh0 sh"); + const ops = parseOps(extracted?.content ?? ""); + const opens = ops.filter((o) => o.op === "q").length; + const closes = ops.filter((o) => o.op === "Q").length; + expect(opens).toBe(closes); + }); + + it("recognises a shading drawn before any text as a background", () => { + expect(extractShadingDraws("/Sh0 sh BT (x) Tj ET")?.isBackground).toBe( + true, + ); + expect(extractShadingDraws("BT (x) Tj ET /Sh0 sh")?.isBackground).toBe( + false, + ); + }); + + it("ignores shadings drawn inside a form XObject, which regeneration keeps", () => { + expect(extractShadingDraws("q /Fm0 Do Q")).toBeNull(); + }); +}); + +/** A page whose gradient PDFium would drop, plus the saved file without it. */ +function shadingFixtures(): { original: Uint8Array; saved: Uint8Array } { + const resources = + "/Resources << /Shading << /Sh0 6 0 R >> /Font << /F1 5 0 R >> >>"; + const pageBody = (contents: string): string => + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] ${resources} /Contents ${contents} >>`; + const common = [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { num: 5, body: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>" }, + { num: 6, body: "<< /ShadingType 2 /ColorSpace /DeviceRGB >>" }, + ]; + return { + original: buildClassicPdf( + [ + ...common, + { num: 3, body: pageBody("4 0 R") }, + { + num: 4, + body: streamBody( + "", + "q 200 0 0 200 0 0 cm /Sh0 sh Q BT /F1 12 Tf (hello) Tj ET", + ), + }, + ], + 1, + ), + saved: buildClassicPdf( + [ + ...common, + { num: 3, body: pageBody("4 0 R") }, + { num: 4, body: streamBody("", "BT /F1 12 Tf (hello there) Tj ET") }, + ], + 1, + ), + }; +} + +describe("preserveShadings", () => { + it("puts a dropped background gradient back, underneath the page content", async () => { + const { original, saved } = shadingFixtures(); + const out = await preserveShadings(saved, original, { pages: [0] }); + expect(out).not.toBeNull(); + + const pdf = await RawPdf.parse(out as Uint8Array); + const pageNum = pdf?.pageNumberAt(0) ?? 0; + const refs = pdf?.contentRefs(pdf?.objectBody(pageNum) ?? "") ?? []; + expect(refs).toHaveLength(2); + + const first = toLatin1( + (await pdf?.streamData(refs[0])) ?? new Uint8Array(), + ); + expect(first).toContain("/Sh0 sh"); + expect(first).toContain("200 0 0 200 0 0 cm"); + + const second = toLatin1( + (await pdf?.streamData(refs[1])) ?? new Uint8Array(), + ); + expect(second).toContain("hello there"); + }); + + it("does nothing when no page was regenerated", async () => { + const { original, saved } = shadingFixtures(); + expect(await preserveShadings(saved, original, { pages: [] })).toBeNull(); + }); + + it("declines when the saved file no longer declares the shading resource", async () => { + const { original } = shadingFixtures(); + const saved = buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { + num: 3, + body: + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + + "/Resources << >> /Contents 4 0 R >>", + }, + { num: 4, body: streamBody("", "BT (hello) Tj ET") }, + ], + 1, + ); + expect(await preserveShadings(saved, original, { pages: [0] })).toBeNull(); + }); + + it("leaves the saved bytes intact when it does apply", async () => { + const { original, saved } = shadingFixtures(); + const out = (await preserveShadings(saved, original, { + pages: [0], + })) as Uint8Array; + expect(out.slice(0, saved.length)).toEqual(saved); + }); +}); + +describe("shading phase ordering", () => { + it("keeps a background shading before the text and a later one after", () => { + const content = + "/ShBack sh BT /F1 12 Tf (hello) Tj ET q 1 0 0 1 5 5 cm /ShOver sh Q"; + const back = extractShadingDraws(content, "background"); + const over = extractShadingDraws(content, "foreground"); + expect(back?.needs.shading).toEqual(["ShBack"]); + expect(over?.needs.shading).toEqual(["ShOver"]); + // The foreground fragment still replays the state that positions it. + expect(over?.content).toContain("1 0 0 1 5 5 cm"); + expect(over?.content).not.toContain("/ShBack sh"); + }); + + it("reports no fragment for a phase with no shading in it", () => { + const content = "/Sh0 sh BT (x) Tj ET"; + expect(extractShadingDraws(content, "foreground")).toBeNull(); + expect(extractShadingDraws(content, "background")).not.toBeNull(); + }); + + it("treats every shading as background when the page has no text", () => { + const content = "q /Sh0 sh Q"; + expect(extractShadingDraws(content, "background")?.isBackground).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfRevision.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfRevision.test.ts new file mode 100644 index 0000000000..98f7103fcb --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfRevision.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { fromLatin1, toLatin1 } from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import { RawPdf } from "@app/tools/pdfTextEditor/pdfdoc/raw"; +import { + appendRevision, + plainObject, + streamObject, +} from "@app/tools/pdfTextEditor/pdfdoc/revision"; +import { + buildXrefStreamPdf, + singleContentPdf, + streamBody, +} from "@app/tools/pdfTextEditor/__tests__/pdfFixtures"; + +/** The property that makes an incremental revision safe for signed files. */ +function prefixIsIntact(before: Uint8Array, after: Uint8Array): boolean { + if (after.length < before.length) return false; + for (let i = 0; i < before.length; i += 1) { + if (before[i] !== after[i]) return false; + } + return true; +} + +describe("appendRevision", () => { + it("leaves every original byte in place", async () => { + const original = singleContentPdf(); + const pdf = await RawPdf.parse(original); + expect(pdf).not.toBeNull(); + const out = appendRevision(pdf as RawPdf, [ + { num: 6, body: plainObject("<< /Added true >>") }, + ]); + expect(out).not.toBeNull(); + expect(prefixIsIntact(original, out as Uint8Array)).toBe(true); + }); + + it("makes the appended object readable and shadows an existing one", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const out = appendRevision(pdf as RawPdf, [ + { num: 1, body: plainObject("<< /Type /Catalog /Pages 2 0 R /V 2 >>") }, + { num: 6, body: plainObject("<< /Added true >>") }, + ]); + const reparsed = await RawPdf.parse(out as Uint8Array); + expect(reparsed?.objectBody(6)).toContain("/Added true"); + expect(reparsed?.objectBody(1)).toContain("/V 2"); + expect(reparsed?.rootNum).toBe(1); + }); + + it("writes a classic table whose /Prev points at the previous section", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const text = toLatin1( + appendRevision(pdf as RawPdf, [ + { num: 6, body: plainObject("<< >>") }, + ]) as Uint8Array, + ); + expect(text).toContain("trailer"); + expect(text).toMatch(/\/Prev \d+/); + expect(text.trimEnd().endsWith("%%EOF")).toBe(true); + }); + + it("writes a cross-reference STREAM when the source file uses one", async () => { + const original = buildXrefStreamPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { num: 3, body: "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>" }, + { num: 4, body: streamBody("", "q Q") }, + ], + 1, + ); + const pdf = await RawPdf.parse(original); + const out = appendRevision(pdf as RawPdf, [ + { + num: 3, + body: plainObject("<< /Type /Page /Parent 2 0 R /Rotate 90 >>"), + }, + ]) as Uint8Array; + const tail = toLatin1(out).slice(original.length); + // A classic table here would be a structure readers reject. + expect(tail).not.toContain("\ntrailer"); + expect(tail).toContain("/Type /XRef"); + expect(prefixIsIntact(original, out)).toBe(true); + const reparsed = await RawPdf.parse(out); + expect(reparsed?.objectBody(3)).toContain("/Rotate 90"); + }); + + it("never gives the xref stream a number the batch already uses", async () => { + const original = buildXrefStreamPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { num: 3, body: "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>" }, + { num: 4, body: streamBody("", "q Q") }, + ], + 1, + ); + const pdf = await RawPdf.parse(original); + // Callers allocate from the same high-water mark this used to use, so the + // first new object and the xref stream collided. + const newNum = (pdf as RawPdf).highestObjectNumber + 1; + const out = appendRevision(pdf as RawPdf, [ + { num: newNum, body: streamObject("<< >>", fromLatin1("q Q")) }, + { + num: 3, + body: plainObject( + `<< /Type /Page /Parent 2 0 R /Contents ${newNum} 0 R >>`, + ), + }, + ]) as Uint8Array; + + const text = toLatin1(out); + expect( + text.match(new RegExp(`(^|[^0-9])${newNum} 0 obj`, "g")), + ).toHaveLength(1); + const reparsed = await RawPdf.parse(out); + expect(reparsed?.objectBody(newNum)).not.toContain("/Type /XRef"); + expect( + toLatin1((await reparsed?.streamData(newNum)) ?? new Uint8Array()), + ).toBe("q Q"); + }); + + it("round-trips a stream object it wrote", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const payload = fromLatin1("0 0 1 rg 10 10 50 50 re f"); + const out = appendRevision(pdf as RawPdf, [ + { num: 7, body: streamObject("<< >>", payload) }, + ]) as Uint8Array; + const reparsed = await RawPdf.parse(out); + const back = await reparsed?.streamData(7); + expect(toLatin1(back ?? new Uint8Array())).toBe( + "0 0 1 rg 10 10 50 50 re f", + ); + }); + + it("refuses a batch that writes the same object twice", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + expect( + appendRevision(pdf as RawPdf, [ + { num: 6, body: plainObject("<< /A 1 >>") }, + { num: 6, body: plainObject("<< /A 2 >>") }, + ]), + ).toBeNull(); + }); + + it("returns the input unchanged when there is nothing to write", async () => { + const original = singleContentPdf(); + const pdf = await RawPdf.parse(original); + expect(appendRevision(pdf as RawPdf, [])).toBe(original); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/prepareFixtures.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/prepareFixtures.test.ts new file mode 100644 index 0000000000..85712c8e57 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/prepareFixtures.test.ts @@ -0,0 +1,55 @@ +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { toLatin1 } from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import { RawPdf } from "@app/tools/pdfTextEditor/pdfdoc/raw"; +import { prepareForEditing } from "@app/tools/pdfTextEditor/pdfdoc/prepareForEditing"; + +// Held to the real corpus, not hand-built fixtures: whatever the load-time +// repair returns must still be the same document. +const FIXTURES = path.resolve(__dirname, "../../../tests/test-fixtures"); + +const pdfs = readdirSync(FIXTURES) + .filter((name) => name.toLowerCase().endsWith(".pdf")) + .sort(); + +describe("prepareForEditing over the real fixture corpus", () => { + it("finds fixtures to check", () => { + expect(pdfs.length).toBeGreaterThan(5); + }); + + for (const name of pdfs) { + it(`preserves every page of ${name}`, async () => { + const original = new Uint8Array(readFileSync(path.join(FIXTURES, name))); + const prepared = await prepareForEditing(original); + + if (prepared === original) return; // untouched is always correct + + const before = await RawPdf.parse(original); + const after = await RawPdf.parse(prepared); + expect(after).not.toBeNull(); + expect(after?.pageNumbers().length).toBe(before?.pageNumbers().length); + + // Every original byte must still be there: the repair only appends. + expect(prepared.slice(0, original.length)).toEqual(original); + + // And each page's content must still decode to the same operators. + const pageCount = after?.pageNumbers().length ?? 0; + for (let i = 0; i < pageCount; i += 1) { + const oldContent = await before?.pageContent( + before.pageNumberAt(i) ?? 0, + ); + const newContent = await after?.pageContent(after.pageNumberAt(i) ?? 0); + if (!oldContent || !newContent) continue; + expect(normalise(toLatin1(newContent))).toBe( + normalise(toLatin1(oldContent)), + ); + } + }); + } +}); + +/** Whitespace between operators is not significant. */ +function normalise(content: string): string { + return content.replace(/\s+/g, " ").trim(); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/rawPdf.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/rawPdf.test.ts new file mode 100644 index 0000000000..4d1db9b122 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/rawPdf.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; +import { + fromLatin1, + toLatin1, + undoPngPredictor, +} from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import { RawPdf } from "@app/tools/pdfTextEditor/pdfdoc/raw"; +import { + buildClassicPdf, + buildXrefStreamPdf, + singleContentPdf, + splitContentPdf, + streamBody, +} from "@app/tools/pdfTextEditor/__tests__/pdfFixtures"; + +describe("RawPdf.parse", () => { + it("indexes objects and resolves the catalogue of a classic-xref file", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + expect(pdf).not.toBeNull(); + expect(pdf?.rootNum).toBe(1); + expect(pdf?.usesXrefStream).toBe(false); + expect(pdf?.objectBody(1)).toContain("/Type /Catalog"); + }); + + it("finds the catalogue of a cross-reference-stream file, which has no trailer keyword", async () => { + const bytes = buildXrefStreamPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { num: 3, body: "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>" }, + { num: 4, body: streamBody("", "q Q") }, + ], + 1, + ); + expect(toLatin1(bytes)).not.toContain("trailer"); + const pdf = await RawPdf.parse(bytes); + expect(pdf?.rootNum).toBe(1); + expect(pdf?.usesXrefStream).toBe(true); + }); + + it("returns null for bytes that are not a PDF", async () => { + expect(await RawPdf.parse(fromLatin1("not a pdf at all"))).toBeNull(); + }); + + it("takes the last definition of an object, so an appended revision wins", async () => { + const base = toLatin1(singleContentPdf()); + const updated = fromLatin1( + `${base}\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Marker true >>\nendobj\n`, + ); + const pdf = await RawPdf.parse(updated); + expect(pdf?.objectBody(1)).toContain("/Marker true"); + }); + + it("does not mistake a longer object number for a shorter one", async () => { + const pdf = await RawPdf.parse( + buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [912 0 R] /Count 1 >>" }, + { num: 12, body: "<< /Decoy true >>" }, + { num: 912, body: "<< /Type /Page /Parent 2 0 R >>" }, + ], + 1, + ), + ); + expect(pdf?.objectBody(12)).toContain("/Decoy true"); + expect(pdf?.pageNumbers()).toEqual([912]); + }); +}); + +describe("RawPdf.valueSpan", () => { + it("reads a value from the outermost dictionary only", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const body = + "<< /Type /Page /Annots [<< /Contents 99 0 R >>] /Contents 7 0 R >>"; + expect(pdf?.dictRef(body, "Contents")).toBe(7); + }); + + it("is not fooled by a key name appearing inside a string", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const body = "<< /Title (a /Contents 42 0 R decoy) /Contents 8 0 R >>"; + expect(pdf?.dictRef(body, "Contents")).toBe(8); + }); + + it("treats an indirect reference as one value rather than an integer", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const body = "<< /Length 12 0 R >>"; + expect(pdf?.dictInt(body, "Length")).toBeNull(); + expect(pdf?.dictRef(body, "Length")).toBe(12); + }); + + it("reads names and arrays", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const body = "<< /Type /Page /Kids [1 0 R 2 0 R] >>"; + expect(pdf?.dictName(body, "Type")).toBe("Page"); + expect(pdf?.valueSpan(body, "Kids")?.text).toBe("[1 0 R 2 0 R]"); + }); +}); + +describe("RawPdf streams and pages", () => { + it("reads an uncompressed stream's payload", async () => { + const pdf = await RawPdf.parse(singleContentPdf("q 1 0 0 1 0 0 cm Q")); + const data = await pdf?.streamData(4); + expect(toLatin1(data ?? new Uint8Array())).toBe("q 1 0 0 1 0 0 cm Q"); + }); + + it("recovers when /Length is wrong by falling back to the endstream keyword", async () => { + const bytes = buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { num: 3, body: "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>" }, + { num: 4, body: "<< /Length 9999 >>\nstream\nHELLO\nendstream" }, + ], + 1, + ); + const pdf = await RawPdf.parse(bytes); + expect(toLatin1((await pdf?.streamData(4)) ?? new Uint8Array())).toBe( + "HELLO", + ); + }); + + it("prefers the ObjStm copy when the newest xref calls the object compressed", async () => { + // A stale top-level body for the same number must not win. + const inner = "<< /Type /Page /Parent 2 0 R /Contents 9 0 R >>"; + const first = `3 0 ${inner}`; + const objStm = + `<< /Type /ObjStm /N 1 /First ${"3 0 ".length} \n/Length ${first.length} >>` + + `\nstream\n${first}\nendstream`; + const bytes = buildXrefStreamPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { num: 3, body: "<< /Type /Page /Contents 4 0 R /Stale true >>" }, + { num: 8, body: objStm }, + ], + 1, + new Map([[3, 8]]), + ); + const pdf = await RawPdf.parse(bytes); + expect(pdf?.objectBody(3)).toContain("/Contents 9 0 R"); + expect(pdf?.objectBody(3)).not.toContain("/Stale"); + }); + + it("reads a direct /Filter name rather than treating it as unreadable", async () => { + const pdf = await RawPdf.parse(singleContentPdf("BT ET")); + expect(toLatin1((await pdf?.streamData(4)) ?? new Uint8Array())).toBe( + "BT ET", + ); + }); + + it("walks the page tree in document order, through intermediate nodes", async () => { + const pdf = await RawPdf.parse( + buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 3 >>" }, + { + num: 3, + body: "<< /Type /Pages /Parent 2 0 R /Kids [4 0 R 5 0 R] >>", + }, + { num: 4, body: "<< /Type /Page /Parent 3 0 R >>" }, + { num: 5, body: "<< /Type /Page /Parent 3 0 R >>" }, + { num: 6, body: "<< /Type /Page /Parent 2 0 R >>" }, + ], + 1, + ), + ); + expect(pdf?.pageNumbers()).toEqual([4, 5, 6]); + expect(pdf?.pageNumberAt(1)).toBe(5); + expect(pdf?.pageNumberAt(9)).toBeNull(); + }); + + it("survives a cyclic page tree instead of hanging", async () => { + const pdf = await RawPdf.parse( + buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] >>" }, + { + num: 3, + body: "<< /Type /Pages /Parent 2 0 R /Kids [2 0 R 4 0 R] >>", + }, + { num: 4, body: "<< /Type /Page /Parent 3 0 R >>" }, + ], + 1, + ), + ); + expect(pdf?.pageNumbers()).toEqual([4]); + }); + + it("inherits /Resources from an ancestor page-tree node", async () => { + const pdf = await RawPdf.parse( + buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { + num: 2, + body: + "<< /Type /Pages /Kids [3 0 R] /Count 1 " + + "/Resources << /Shading << /Sh0 9 0 R >> >> >>", + }, + { num: 3, body: "<< /Type /Page /Parent 2 0 R >>" }, + { num: 9, body: "<< /ShadingType 2 >>" }, + ], + 1, + ), + ); + const resources = pdf?.pageInherited(3, "Resources"); + expect(resources).toContain("/Sh0"); + }); + + it("concatenates a multi-part /Contents array in order", async () => { + const pdf = await RawPdf.parse( + splitContentPdf(["q 1 0 0", "1 0 0 cm", "Q"]), + ); + const page = pdf?.pageNumberAt(0) ?? 0; + const content = toLatin1( + (await pdf?.pageContent(page)) ?? new Uint8Array(), + ); + expect(content).toBe("q 1 0 0\n1 0 0 cm\nQ\n"); + }); +}); + +describe("undoPngPredictor", () => { + it("reverses an Up-filtered image back to its original rows", () => { + const rowLen = 3; + // Row 0 is filter 0 (None); row 1 is filter 2 (Up) with deltas. + const encoded = new Uint8Array([0, 10, 20, 30, 2, 1, 2, 3]); + const out = undoPngPredictor(encoded, 1, 8, rowLen); + expect(Array.from(out)).toEqual([10, 20, 30, 11, 22, 33]); + }); + + it("reverses a Sub-filtered row using the left neighbour", () => { + const encoded = new Uint8Array([1, 5, 5, 5]); + expect(Array.from(undoPngPredictor(encoded, 1, 8, 3))).toEqual([5, 10, 15]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/rotation.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/rotation.test.ts new file mode 100644 index 0000000000..14d7b2ba8d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/rotation.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { + rotationFromMatrix, + counterPageRotation, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +describe("rotationFromMatrix", () => { + it("returns undefined for upright text (identity / pure scale)", () => { + expect(rotationFromMatrix({ a: 1, b: 0 })).toBeUndefined(); + expect(rotationFromMatrix({ a: 12, b: 0 })).toBeUndefined(); // scale only + }); + + it("extracts normalised cos/sin for a 30deg run, scale-independent", () => { + const r = rotationFromMatrix({ a: 0.866, b: 0.5 })!; + expect(r.cos).toBeCloseTo(0.866, 2); + expect(r.sin).toBeCloseTo(0.5, 2); + // Same angle at 2x scale → same normalised rotation. + const r2 = rotationFromMatrix({ a: 1.732, b: 1.0 })!; + expect(r2.cos).toBeCloseTo(0.866, 2); + expect(r2.sin).toBeCloseTo(0.5, 2); + }); + + it("flags a horizontal flip (negative a) as a rotation", () => { + expect(rotationFromMatrix({ a: -1, b: 0 })).toBeDefined(); + }); + + it("flags a vertical mirror (negative determinant) that a,b alone miss", () => { + // y-flipped generator [1 0 0 -1]: sin~=0, cos>0, so the old a,b-only check + // called it upright and let the surgical horizontal path scatter it. + expect(rotationFromMatrix({ a: 1, b: 0, c: 0, d: -1 })).toBeDefined(); + }); + + it("does NOT flag pure shear (synthetic oblique, positive determinant)", () => { + // Surgical path preserves the shear on survivors; re-emit would drop it. + expect(rotationFromMatrix({ a: 1, b: 0, c: 0.3, d: 1 })).toBeUndefined(); + }); + + it("returns undefined for a degenerate zero matrix", () => { + expect(rotationFromMatrix({ a: 0, b: 0 })).toBeUndefined(); + }); +}); + +describe("counterPageRotation", () => { + it("is undefined for an unrotated page", () => { + expect(counterPageRotation(0)).toBeUndefined(); + expect(counterPageRotation(4)).toBeUndefined(); + }); + + it("counter-rotates 90/180/270 so new text reads upright", () => { + expect(counterPageRotation(1)).toEqual({ cos: 0, sin: 1 }); // +90 CCW + expect(counterPageRotation(2)).toEqual({ cos: -1, sin: 0 }); // 180 + expect(counterPageRotation(3)).toEqual({ cos: 0, sin: -1 }); // -90 + }); + + it("normalises out-of-range / negative quarter-turns", () => { + expect(counterPageRotation(5)).toEqual({ cos: 0, sin: 1 }); // 5 % 4 == 1 + expect(counterPageRotation(-3)).toEqual({ cos: 0, sin: 1 }); // -3 -> 1 + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/sha256.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/sha256.test.ts new file mode 100644 index 0000000000..18cd97de64 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/sha256.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; + +import { sha256Hex } from "@app/tools/pdfTextEditor/util/sha256"; + +// The pure-JS SHA-256 fingerprints embedded font programs so the backend can +// match the EXACT subset font a charcode request targets. +describe("sha256Hex", () => { + it("matches the FIPS 180-4 vectors", () => { + expect(sha256Hex(new Uint8Array(0))).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + expect(sha256Hex(new TextEncoder().encode("abc"))).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + expect( + sha256Hex( + new TextEncoder().encode( + "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", + ), + ), + ).toBe("248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); + }); + + it("agrees with node:crypto across block-boundary and large inputs", () => { + // Deterministic pseudo-random bytes; lengths straddle the 64-byte block + // size plus a large buffer like a real font program. + const lengths = [1, 55, 56, 63, 64, 65, 127, 128, 1000, 70_000]; + for (const len of lengths) { + const data = new Uint8Array(len); + let seed = 0x12345678 ^ len; + for (let i = 0; i < len; i++) { + seed = (seed * 1103515245 + 12345) >>> 0; + data[i] = seed & 0xff; + } + const expected = createHash("sha256").update(data).digest("hex"); + expect(sha256Hex(data), `length ${len}`).toBe(expected); + } + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/spellcheck.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/spellcheck.test.ts new file mode 100644 index 0000000000..302923a436 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/spellcheck.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + DEFAULT_SPELLCHECK_PREFERENCE, + SPELLCHECK_AUTO, + SPELLCHECK_LANGUAGES, + __resetSpellcheckForTests, + getSpellcheckPreference, + resolveLang, + setSpellcheckEnabled, + setSpellcheckLang, + setSpellcheckPreference, + subscribeSpellcheck, +} from "@app/tools/pdfTextEditor/util/spellcheck"; + +const STORAGE_KEY = "stirling.pdfTextEditor.spellcheck"; + +const realStorage = window.localStorage; + +function setStorage(value: Storage | undefined): void { + (window as unknown as { localStorage: Storage | undefined }).localStorage = + value; +} + +function throwingStorage(): Storage { + const boom = () => { + throw new Error("localStorage is blocked"); + }; + return { + get length(): number { + return 0; + }, + clear: boom, + getItem: boom, + key: boom, + removeItem: boom, + setItem: boom, + } as unknown as Storage; +} + +beforeEach(() => { + setStorage(realStorage); + realStorage.clear(); + __resetSpellcheckForTests(); +}); + +afterEach(() => { + setStorage(realStorage); + realStorage.clear(); + __resetSpellcheckForTests(); +}); + +describe("spellcheck default state", () => { + it("is off and automatic with nothing persisted", () => { + expect(getSpellcheckPreference()).toEqual({ + enabled: false, + lang: SPELLCHECK_AUTO, + }); + }); + + it("returns a stable snapshot reference until it changes", () => { + const first = getSpellcheckPreference(); + expect(getSpellcheckPreference()).toBe(first); + setSpellcheckEnabled(true); + expect(getSpellcheckPreference()).not.toBe(first); + }); + + it("offers en-US, en-GB and an RTL plus an Indic language", () => { + const tags = SPELLCHECK_LANGUAGES.map((l) => l.tag); + expect(tags).toEqual( + expect.arrayContaining(["en-US", "en-GB", "de", "fr", "es", "ar", "hi"]), + ); + expect(tags).not.toContain(SPELLCHECK_AUTO); + }); + + it("exposes a frozen default so callers cannot mutate it", () => { + expect(Object.isFrozen(DEFAULT_SPELLCHECK_PREFERENCE)).toBe(true); + }); +}); + +describe("spellcheck persistence", () => { + it("round-trips through localStorage", () => { + setSpellcheckEnabled(true); + setSpellcheckLang("de"); + expect(JSON.parse(realStorage.getItem(STORAGE_KEY) ?? "null")).toEqual({ + enabled: true, + lang: "de", + }); + __resetSpellcheckForTests(); + expect(getSpellcheckPreference()).toEqual({ enabled: true, lang: "de" }); + }); + + it("trims a padded tag and treats a blank one as automatic", () => { + setSpellcheckLang(" fr "); + expect(getSpellcheckPreference().lang).toBe("fr"); + setSpellcheckLang(" "); + expect(getSpellcheckPreference().lang).toBe(SPELLCHECK_AUTO); + }); + + it("falls back to the default when the stored JSON is corrupt", () => { + realStorage.setItem(STORAGE_KEY, "{not json"); + __resetSpellcheckForTests(); + expect(getSpellcheckPreference()).toEqual({ + enabled: false, + lang: SPELLCHECK_AUTO, + }); + }); + + it("ignores a stored value that is not an object", () => { + realStorage.setItem(STORAGE_KEY, '"enabled"'); + __resetSpellcheckForTests(); + expect(getSpellcheckPreference().enabled).toBe(false); + }); + + it("keeps the valid half of a partially wrong-typed entry", () => { + realStorage.setItem(STORAGE_KEY, '{"enabled":true,"lang":7}'); + __resetSpellcheckForTests(); + expect(getSpellcheckPreference()).toEqual({ + enabled: true, + lang: SPELLCHECK_AUTO, + }); + }); + + it("degrades to memory when localStorage throws", () => { + setStorage(throwingStorage()); + __resetSpellcheckForTests(); + expect(getSpellcheckPreference().enabled).toBe(false); + expect(() => setSpellcheckEnabled(true)).not.toThrow(); + expect(getSpellcheckPreference().enabled).toBe(true); + }); + + it("degrades to memory when localStorage is absent", () => { + setStorage(undefined); + __resetSpellcheckForTests(); + expect(getSpellcheckPreference()).toEqual({ + enabled: false, + lang: SPELLCHECK_AUTO, + }); + expect(() => setSpellcheckLang("es")).not.toThrow(); + expect(getSpellcheckPreference().lang).toBe("es"); + }); +}); + +describe("spellcheck subscribers", () => { + it("notifies on change and stops after unsubscribe", () => { + const seen: boolean[] = []; + const unsubscribe = subscribeSpellcheck((p) => seen.push(p.enabled)); + setSpellcheckEnabled(true); + unsubscribe(); + setSpellcheckEnabled(false); + expect(seen).toEqual([true]); + }); + + it("does not notify when the value is unchanged", () => { + const listener = vi.fn(); + subscribeSpellcheck(listener); + setSpellcheckPreference({ enabled: false, lang: SPELLCHECK_AUTO }); + expect(listener).not.toHaveBeenCalled(); + setSpellcheckPreference({ enabled: true, lang: SPELLCHECK_AUTO }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("keeps notifying after one listener throws", () => { + const later = vi.fn(); + subscribeSpellcheck(() => { + throw new Error("listener blew up"); + }); + subscribeSpellcheck(later); + setSpellcheckEnabled(true); + expect(later).toHaveBeenCalledWith({ + enabled: true, + lang: SPELLCHECK_AUTO, + }); + }); + + it("survives a listener that unsubscribes another mid-notify", () => { + const later = vi.fn(); + let unsubscribeLater = () => {}; + subscribeSpellcheck(() => unsubscribeLater()); + unsubscribeLater = subscribeSpellcheck(later); + setSpellcheckEnabled(true); + expect(later).toHaveBeenCalledTimes(1); + setSpellcheckEnabled(false); + expect(later).toHaveBeenCalledTimes(1); + }); +}); + +describe("resolveLang", () => { + it("returns null when spell-check is off", () => { + expect(resolveLang({ enabled: false, lang: "de" }, "fr")).toBeNull(); + }); + + it("returns the explicit tag when one is chosen", () => { + expect(resolveLang({ enabled: true, lang: "en-GB" }, "fr")).toBe("en-GB"); + }); + + it("trims an explicit tag", () => { + expect(resolveLang({ enabled: true, lang: " pt-BR " }, null)).toBe("pt-BR"); + }); + + it("rejects an explicit tag that is not BCP-47 shaped", () => { + expect(resolveLang({ enabled: true, lang: "not a tag" }, "fr")).toBeNull(); + }); + + it("falls back to the document language on auto", () => { + expect(resolveLang({ enabled: true, lang: SPELLCHECK_AUTO }, "hi")).toBe( + "hi", + ); + }); + + it("returns null on auto with no usable document language", () => { + const pref = { enabled: true, lang: SPELLCHECK_AUTO }; + expect(resolveLang(pref, null)).toBeNull(); + expect(resolveLang(pref, undefined)).toBeNull(); + expect(resolveLang(pref, "")).toBeNull(); + expect(resolveLang(pref, " ")).toBeNull(); + expect(resolveLang(pref, "en_US")).toBeNull(); + }); + + it("accepts every offered language", () => { + for (const lang of SPELLCHECK_LANGUAGES) { + expect(resolveLang({ enabled: true, lang: lang.tag }, null)).toBe( + lang.tag, + ); + } + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/textMatching.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/textMatching.test.ts new file mode 100644 index 0000000000..fd811bdbac --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/textMatching.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect } from "vitest"; +import { + findMatches, + foldForSearch, + isWordChar, + replaceMatch, + replaceMatches, +} from "@app/tools/pdfTextEditor/util/textMatching"; +import type { TextMatch } from "@app/tools/pdfTextEditor/util/textMatching"; + +const RESUME_ACCENTED = "résumé"; +const RESUME_DECOMPOSED = "résumé"; +const CAFE_DECOMPOSED = "café"; + +function slices(haystack: string, matches: TextMatch[]): string[] { + return matches.map((m) => haystack.slice(m.start, m.end)); +} + +describe("findMatches degenerate input", () => { + it("returns no matches for an empty needle", () => { + expect(findMatches("hello world", "")).toEqual([]); + expect(findMatches("", "")).toEqual([]); + }); + + it("returns no matches for an empty haystack", () => { + expect(findMatches("", "a")).toEqual([]); + }); + + it("returns no matches when the needle is longer than the haystack", () => { + expect(findMatches("abc", "abcd")).toEqual([]); + expect(findMatches("abc", "abcd", { ignoreAccents: true })).toEqual([]); + }); +}); + +describe("findMatches case handling", () => { + it("is case-insensitive by default", () => { + expect(findMatches("Foo foo FOO", "foo")).toEqual([ + { start: 0, end: 3 }, + { start: 4, end: 7 }, + { start: 8, end: 11 }, + ]); + }); + + it("honours matchCase", () => { + expect(findMatches("Foo foo FOO", "foo", { matchCase: true })).toEqual([ + { start: 4, end: 7 }, + ]); + }); + + it("case-folds non-ASCII letters", () => { + expect(findMatches("ÉCOLE", "école")).toEqual([{ start: 0, end: 5 }]); + expect(findMatches("ÉCOLE", "école", { matchCase: true })).toEqual([]); + }); +}); + +describe("findMatches overlapping candidates", () => { + it("returns non-overlapping matches, scanning left to right", () => { + expect(findMatches("aaaa", "aa")).toEqual([ + { start: 0, end: 2 }, + { start: 2, end: 4 }, + ]); + expect(findMatches("aaa", "aa")).toEqual([{ start: 0, end: 2 }]); + }); + + it("does not lose a later match when an earlier candidate is rejected", () => { + expect(findMatches("abcab ab", "ab", { wholeWord: true })).toEqual([ + { start: 6, end: 8 }, + ]); + }); +}); + +describe("findMatches accent folding", () => { + const hay = `Le ${RESUME_ACCENTED} final`; + + it("matches accented text against unaccented input when enabled", () => { + const found = findMatches(hay, "resume", { ignoreAccents: true }); + expect(found).toEqual([{ start: 3, end: 9 }]); + expect(slices(hay, found)).toEqual([RESUME_ACCENTED]); + }); + + it("does not match accented text when the flag is off", () => { + expect(findMatches(hay, "resume")).toEqual([]); + }); + + it("folds the needle as well as the haystack", () => { + expect( + findMatches("the resume", RESUME_ACCENTED, { ignoreAccents: true }), + ).toEqual([{ start: 4, end: 10 }]); + }); + + it("does not attempt non-diacritic folding such as sharp s", () => { + expect(findMatches("Straße", "Strasse", { ignoreAccents: true })).toEqual( + [], + ); + }); + + it("leaves standalone combining marks alone, so decomposed text is not folded", () => { + // Dropping the mark would shift every later offset, so length stability wins. + expect(RESUME_DECOMPOSED).toHaveLength(8); + expect( + findMatches(RESUME_DECOMPOSED, "resume", { ignoreAccents: true }), + ).toEqual([]); + }); +}); + +describe("foldForSearch offset stability", () => { + const mixed = `Élan \u{1f600} naïve İstanbul ${RESUME_ACCENTED} 中文 ẞ_1`; + + it("keeps the folded length identical to the original", () => { + for (const opts of [ + {}, + { matchCase: true }, + { ignoreAccents: true }, + { matchCase: true, ignoreAccents: true }, + ]) { + expect(foldForSearch(mixed, opts)).toHaveLength(mixed.length); + } + }); + + it("maps a folded index back to the identical index in the original", () => { + const folded = foldForSearch(mixed, { ignoreAccents: true }); + const at = folded.indexOf("naive"); + expect(at).toBeGreaterThan(-1); + expect(mixed.slice(at, at + 5)).toBe("naïve"); + }); + + it("reports offsets that slice the original text back out", () => { + const found = findMatches(mixed, "resume", { ignoreAccents: true }); + expect(slices(mixed, found)).toEqual([RESUME_ACCENTED]); + }); +}); + +describe("findMatches whole word", () => { + it("matches at the very start and end of the string", () => { + expect(findMatches("cat", "cat", { wholeWord: true })).toEqual([ + { start: 0, end: 3 }, + ]); + expect(findMatches("a cat", "cat", { wholeWord: true })).toEqual([ + { start: 2, end: 5 }, + ]); + expect(findMatches("cat nap", "cat", { wholeWord: true })).toEqual([ + { start: 0, end: 3 }, + ]); + }); + + it("rejects a match glued to other word characters", () => { + expect(findMatches("concatenate", "cat", { wholeWord: true })).toEqual([]); + expect(findMatches("cat5", "cat", { wholeWord: true })).toEqual([]); + expect(findMatches("cat_", "cat", { wholeWord: true })).toEqual([]); + expect(findMatches("_cat", "cat", { wholeWord: true })).toEqual([]); + }); + + it("accepts punctuation and whitespace as boundaries", () => { + expect(findMatches("(cat), cat.", "cat", { wholeWord: true })).toEqual([ + { start: 1, end: 4 }, + { start: 7, end: 10 }, + ]); + }); + + it("treats non-ASCII letters as word characters, unlike ASCII regex breaks", () => { + expect(findMatches("Straße", "stra", { wholeWord: true })).toEqual([]); + expect(findMatches("naïve", "na", { wholeWord: true })).toEqual([]); + expect(findMatches(`un café.`, "café", { wholeWord: true })).toEqual([ + { start: 3, end: 7 }, + ]); + }); + + it("treats a trailing combining mark as a word character", () => { + expect(findMatches(CAFE_DECOMPOSED, "cafe", { wholeWord: true })).toEqual( + [], + ); + }); + + it("combines with accent folding", () => { + expect( + findMatches("un café.", "cafe", { + wholeWord: true, + ignoreAccents: true, + }), + ).toEqual([{ start: 3, end: 7 }]); + }); + + it("classifies word characters Unicode-aware", () => { + expect(isWordChar("ß")).toBe(true); + expect(isWordChar("中")).toBe(true); + expect(isWordChar("٣")).toBe(true); + expect(isWordChar("_")).toBe(true); + expect(isWordChar("́")).toBe(true); + expect(isWordChar(" ")).toBe(false); + expect(isWordChar("-")).toBe(false); + expect(isWordChar("")).toBe(false); + expect(isWordChar(null)).toBe(false); + }); +}); + +// CJK ideographs are letters and are not space-delimited, so whole-word only +// matches a run bounded by punctuation or spaces. +describe("findMatches with CJK", () => { + it("matches freely when whole word is off", () => { + expect(findMatches("中文文档", "文")).toEqual([ + { start: 1, end: 2 }, + { start: 2, end: 3 }, + ]); + }); + + it("finds nothing mid-phrase when whole word is on", () => { + expect(findMatches("中文文档", "文", { wholeWord: true })).toEqual([]); + }); + + it("matches a delimited CJK phrase when whole word is on", () => { + expect( + findMatches("「中文」と", "中文", { + wholeWord: true, + }), + ).toEqual([{ start: 1, end: 3 }]); + }); +}); + +describe("findMatches with astral characters", () => { + it("does not split a surrogate pair when checking word boundaries", () => { + expect( + findMatches("\u{1f600}cat\u{1f600}", "cat", { wholeWord: true }), + ).toEqual([{ start: 2, end: 5 }]); + }); +}); + +describe("replaceMatch", () => { + it("splices the replacement literally", () => { + expect(replaceMatch("hello world", { start: 6, end: 11 }, "there")).toBe( + "hello there", + ); + }); + + it("never interprets $ sequences as regex references", () => { + expect(replaceMatch("say foo", { start: 4, end: 7 }, "$&")).toBe("say $&"); + expect(replaceMatch("say foo", { start: 4, end: 7 }, "$1$$$'")).toBe( + "say $1$$$'", + ); + }); + + it("supports deletion and guards out-of-range offsets", () => { + expect(replaceMatch("abcd", { start: 1, end: 3 }, "")).toBe("ad"); + expect(replaceMatch("abcd", { start: 2, end: 9 }, "x")).toBe("abcd"); + expect(replaceMatch("abcd", { start: 3, end: 1 }, "x")).toBe("abcd"); + }); +}); + +describe("replaceMatches", () => { + it("rewrites every match in one pass", () => { + const hay = "Foo foo FOO"; + expect(replaceMatches(hay, findMatches(hay, "foo"), "bar")).toBe( + "bar bar bar", + ); + }); + + it("returns the text unchanged when there are no matches", () => { + expect(replaceMatches("abc", [], "x")).toBe("abc"); + }); + + it("keeps replacement text literal", () => { + const hay = "a b a"; + expect(replaceMatches(hay, findMatches(hay, "a"), "$&")).toBe("$& b $&"); + }); + + it("preserves accented context around folded matches", () => { + const hay = `Le ${RESUME_ACCENTED} final`; + const found = findMatches(hay, "resume", { ignoreAccents: true }); + expect(replaceMatches(hay, found, "summary")).toBe("Le summary final"); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/toolbarState.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/toolbarState.test.ts new file mode 100644 index 0000000000..e30f585e1e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/toolbarState.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { deriveToolbarState } from "@app/tools/pdfTextEditor/util/toolbarState"; +import type { + PageSnapshot, + SelectionState, +} from "@app/tools/pdfTextEditor/types"; + +function mkRun(id: string, fontId: string, fontSize = 12) { + return { + id, + pageIndex: 0, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, + text: "x", + fontId, + fontSize, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + }; +} +function mkPages(runs: ReturnType[]): PageSnapshot[] { + return [ + { + pageIndex: 0, + width: 100, + height: 100, + revision: 0, + dirty: false, + runs, + images: [], + } as unknown as PageSnapshot, + ]; +} +function mkSel(runIds: string[]): SelectionState { + return { runIds, imageIds: [] } as unknown as SelectionState; +} + +describe("deriveToolbarState mixed.fontFamily", () => { + it("flags fontFamily mixed when selected runs differ", () => { + const s = deriveToolbarState( + mkPages([mkRun("a", "pdf:1:Arial"), mkRun("b", "pdf:2:Times")]), + mkSel(["a", "b"]), + ); + expect(s.mixed.fontFamily).toBe(true); + }); + + it("does not flag fontFamily mixed when fontIds match", () => { + const s = deriveToolbarState( + mkPages([mkRun("a", "pdf:1:Arial"), mkRun("b", "pdf:1:Arial")]), + mkSel(["a", "b"]), + ); + expect(s.mixed.fontFamily).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/charcode/BackendResolver.ts b/frontend/editor/src/core/tools/pdfTextEditor/charcode/BackendResolver.ts new file mode 100644 index 0000000000..9192d7c064 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/charcode/BackendResolver.ts @@ -0,0 +1,842 @@ +import apiClient from "@app/services/apiClient"; +import type { + CharcodeResolver, + CharcodeResolveResult, + ResolverContext, +} from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; +import { getActiveCharcodeStrategy } from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; +import { getCachedFontProgramSha256 } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; + +/** Strategy 3: ask the Spring backend (PDFBox) to encode chars. */ + +/** Cache: per (fontPtr, char) → charcode integer (or null = missing). */ +const charCache = new Map(); + +// Expiry timestamps for TRANSIENT-failure nulls (network error, backend down, +// serialize hiccup). +const negativeUntil = new Map(); +const NEGATIVE_TTL_MS = 30_000; + +function setTransientNull(key: string): void { + charCache.set(key, null); + negativeUntil.set(key, Date.now() + NEGATIVE_TTL_MS); +} + +/** Track in-flight prefetches so we don't double-fire. */ +const inFlight = new Set(); + +/** Hard cap on CONCURRENT auto-prefetches. */ +const MAX_CONCURRENT_AUTO_PREFETCH = 2; +// Font batches in flight within a single prefetch. Matches the cap +// prewarmPageCharcodes uses so both paths load the backend the same way. +const PREFETCH_BATCH_CONCURRENCY = 6; +let autoPrefetchActive = 0; + +/** Short-lived cache of the serialized document, shared by prefetch bursts. */ +let serializedCache: { bytes: Uint8Array; at: number } | null = null; +const SERIALIZE_TTL_MS = 4000; + +function serializeDocCached( + save: { serialize: (d: D) => Uint8Array }, + doc: D, +): Uint8Array | null { + const now = Date.now(); + if (serializedCache && now - serializedCache.at < SERIALIZE_TTL_MS) { + return serializedCache.bytes; + } + const bytes = save.serialize(doc); + if (!bytes || bytes.byteLength === 0) return null; + serializedCache = { bytes, at: now }; + return bytes; +} + +/** Endpoint config - resolved relative to current origin in dev. */ +const ENDPOINT = "/api/v1/general/pdf-text-editor/encode-charcodes"; + +/** Shape of the encode-charcodes JSON response (mirrors the controller). */ +interface EncodeCharcodesResponse { + charcodes?: number[]; + missing?: string[]; + note?: string; + error?: string; +} + +// POST JSON to the charcode endpoint via the shared `apiClient`. `apiClient` is +// the canonical Stirling HTTP helper. +async function postCharcodes( + body: Record, +): Promise { + try { + const resp = await apiClient.post(ENDPOINT, body, { + suppressErrorToast: true, + skipAuthRedirect: true, + }); + return resp.data ?? null; + } catch { + return null; + } +} + +export class BackendResolver implements CharcodeResolver { + readonly name = "backend" as const; + + resolve( + font: number, + text: string, + ctx: ResolverContext, + ): CharcodeResolveResult | null { + if (!font || !text) return null; + const charcodes: number[] = []; + const missing: string[] = []; + const cacheMisses: string[] = []; + for (const ch of text) { + // Whitespace is never charcode-reused (no real space glyph in subset + // fonts; SetCharcodes(0x20) paints garbage like „). + if (/\s/.test(ch)) { + missing.push(ch); + continue; + } + const key = cacheKey(font, ch); + if (!charCache.has(key)) { + cacheMisses.push(ch); + missing.push(ch); + continue; + } + const code = charCache.get(key); + if (code === null) { + // A transient-failure null past its TTL becomes a cache miss so + // the prefetch below retries it. + const until = negativeUntil.get(key); + if (until !== undefined && Date.now() >= until) { + charCache.delete(key); + negativeUntil.delete(key); + cacheMisses.push(ch); + } + missing.push(ch); + continue; + } + if (typeof code === "number") charcodes.push(code); + } + // Auto-kick a background prefetch for the cache-miss chars so the next time + // the user types them we have charcodes to use. + if (cacheMisses.length > 0) { + maybeAutoPrefetch(font, cacheMisses, ctx); + } + return { + charcodes, + coverage: charcodes.length, + missing, + note: + cacheMisses.length > 0 + ? `backend cache miss for ${JSON.stringify(cacheMisses.join(""))} - prefetch kicked off in background, retry the keystroke in a moment` + : `backend cache served ${charcodes.length} of ${text.length} char(s)`, + }; + } +} + +// Fire-and-forget prefetch triggered from inside `resolve()` when the cache +// doesn't yet have the chars the user just typed. +function maybeAutoPrefetch( + fontPtr: number, + chars: string[], + ctx: ResolverContext, +): void { + // Never round-trip whitespace - it has no reusable glyph (see resolve()). + // Dedupe too: resolve() pushes one entry per occurrence, so a repeated + // character would otherwise cost one request per repeat. + chars = [...new Set(chars.filter((ch) => !/\s/.test(ch)))]; + if (chars.length === 0) return; + // Concurrency cap: dropping is safe - the chars stay cache-miss and a + // later keystroke re-fires once a slot frees up. + if (autoPrefetchActive >= MAX_CONCURRENT_AUTO_PREFETCH) return; + // Avoid re-firing while a prefetch for these chars is in flight. + const reqKey = `auto:${fontPtr}:${chars.join("")}`; + if (inFlight.has(reqKey)) return; + inFlight.add(reqKey); + autoPrefetchActive += 1; + void (async () => { + try { + const { PdfiumSave } = + await import("@app/tools/pdfTextEditor/pdfium/PdfiumSave"); + const doc = getEditorDocument(); + if (!doc) { + if (typeof console !== "undefined") { + console.warn( + "[charcode] backend auto-prefetch: editor document unavailable", + ); + } + for (const ch of chars) setTransientNull(cacheKey(fontPtr, ch)); + return; + } + const bytes = serializeDocCached(PdfiumSave, doc); + if (!bytes) { + for (const ch of chars) setTransientNull(cacheKey(fontPtr, ch)); + return; + } + const pdfBase64 = uint8ToBase64(bytes); + const pageIdx = pageIdxOfPagePtr(ctx); + + // Batch by font: one request per font carrying all of that font's + // missing chars, mirroring prewarmPageCharcodes. Previously this fired + // one request per character, each re-sending the entire base64 PDF. + const byFont = new Map(); + for (const ch of chars) { + const perCharFont = findFontForChar(ch, ctx) || fontPtr; + const arr = byFont.get(perCharFont); + if (arr) arr.push(ch); + else byFont.set(perCharFont, [ch]); + } + + const batches = [...byFont.entries()]; + let batchIdx = 0; + const workers: Promise[] = []; + for ( + let w = 0; + w < Math.min(PREFETCH_BATCH_CONCURRENCY, batches.length); + w++ + ) { + workers.push( + (async () => { + while (true) { + const me = batchIdx++; + if (me >= batches.length) return; + const [perCharFont, fontChars] = batches[me]; + const json = await postCharcodes({ + pdfBase64, + pageIndex: pageIdx >= 0 ? pageIdx : 0, + // Any of this font's chars is a valid locator. + locatorChar: fontChars[0], + fontName: readFontName(ctx.module, perCharFont), + // Program-bytes hash: the only identity that survives PDFium's + // subset-tag stripping. + fontSha256: + getCachedFontProgramSha256(perCharFont) ?? undefined, + text: fontChars.join(""), + }); + + if (!json || json.error) { + // Network failure / backend error: retry after the TTL. Only a + // real "encoded 0 of N" answer is a permanent miss. + for (const ch of fontChars) { + setTransientNull(cacheKey(perCharFont, ch)); + } + } else { + // The backend appends one charcode per NON-missing char, in + // request order. + const missing = new Set(json.missing ?? []); + const codes = json.charcodes ?? []; + let k = 0; + for (const ch of fontChars) { + if (missing.has(ch)) { + charCache.set(cacheKey(perCharFont, ch), null); + continue; + } + const code = codes[k++]; + charCache.set( + cacheKey(perCharFont, ch), + typeof code === "number" ? code : null, + ); + } + } + + // Stop the per-keystroke prefetch storm. resolve looks these + // chars up under the QUERIED font, not perCharFont. Use the + // TTL'd null: this font was never actually asked, so a permanent + // null would kill the pair for the rest of the session. + if (perCharFont !== fontPtr) { + for (const ch of fontChars) { + setTransientNull(cacheKey(fontPtr, ch)); + } + } + } + })(), + ); + } + await Promise.all(workers); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (typeof console !== "undefined") { + console.warn("[charcode] backend prefetch threw:", err); + } + // Negative-cache with TTL so we don't retry the same chars in a tight + // loop but DO recover once the backend is reachable again. + for (const ch of chars) setTransientNull(cacheKey(fontPtr, ch)); + // Lazy-import charcodeRegistry to avoid the cyclic + // BackendResolver ↔ charcodeRegistry module init. + try { + const { emitCharcodeEvent } = + await import("@app/tools/pdfTextEditor/charcode/charcodeRegistry"); + emitCharcodeEvent({ + strategy: getActiveCharcodeStrategy(), + text: chars.join(""), + fontPtr, + resolved: [], + missing: [...chars], + note: `backend prefetch threw: ${msg}`, + outcome: "partial-coverage-fallback", + }); + } catch { + /* registry import itself failed - already logged above */ + } + } finally { + inFlight.delete(reqKey); + autoPrefetchActive -= 1; + } + })(); +} + +interface TextPageModule { + FPDFText_LoadPage?: (page: number) => number; + FPDFText_ClosePage?: (textPage: number) => void; + FPDFText_CountChars?: (textPage: number) => number; + FPDFText_GetUnicode?: (textPage: number, idx: number) => number; + FPDFText_GetTextObject?: (textPage: number, idx: number) => number; +} + +interface FontReadModule { + FPDFTextObj_GetFont?: (obj: number) => number; +} + +// Find an existing char on the current page whose text object uses the given +// font. +const fontForCharCache = new Map(); + +/** Bold/italic classification of a font, read from its /BaseFont name. */ +export interface FontStyleClass { + bold: boolean; + italic: boolean; +} + +/** + * Classify a font handle as bold/italic from its /BaseFont name. + * + * Borrowing a glyph from a face of a different weight is what made edited body + * text come back bold: the first "o" in document order often lives in a bold + * heading. + */ +export function fontStyleClass( + m: ResolverContext["module"], + fontPtr: number, +): FontStyleClass | null { + const name = readFontName(m, fontPtr); + if (!name) return null; + return styleClassFromName(name); +} + +/** Same classification from a font FAMILY name (base-14 or device font). */ +export function styleClassFromName(name: string): FontStyleClass { + return { + bold: /bold|black|heavy|semibold|demi/i.test(name), + italic: /italic|oblique/i.test(name), + }; +} + +const reusableFontCache = new Map(); + +/** + * Whether a font has a real font program behind it. + * + * A Type 3 face is a dictionary of content-stream procedures, so PDFium can + * report neither a glyph advance nor a usable ink box for it. Its glyphs are + * still drawable - callers may reuse one when they can measure its advance + * some other way - but laying out new text on PDFium's numbers alone stacks + * every glyph on the previous one. + */ +export function fontIsReusable( + m: ResolverContext["module"], + fontPtr: number, +): boolean { + if (!fontPtr) return false; + const cached = reusableFontCache.get(fontPtr); + if (cached !== undefined) return cached; + const getData = ( + m as unknown as { + FPDFFont_GetFontData?: ( + font: number, + buf: number, + buflen: number, + outLen: number, + ) => boolean; + } + ).FPDFFont_GetFontData; + // No API to ask with: assume reusable so nothing regresses. + if (typeof getData !== "function") { + reusableFontCache.set(fontPtr, true); + return true; + } + // A Type 3 font is a dictionary of content-stream procedures, not a font + // program. PDFium still answers "true" for it, but reports a length of 0 - + // the length is the part that distinguishes a real face. + let ok = false; + const out = m.pdfium.wasmExports.malloc(4); + try { + m.pdfium.setValue(out, 0, "i32"); + ok = getData(fontPtr, 0, 0, out) && m.pdfium.getValue(out, "i32") > 0; + } catch { + ok = false; + } finally { + m.pdfium.wasmExports.free(out); + } + reusableFontCache.set(fontPtr, ok); + return ok; +} + +/** Test-only: clear the reusable-font cache. */ +export function _clearReusableFontCacheForTests(): void { + reusableFontCache.clear(); +} + +export function findFontForChar( + unicodeChar: string, + ctx: ResolverContext, + // When given, only fonts with the SAME bold/italic class as this one are + // accepted, so a borrowed glyph never changes the run's weight or slant. + likeFontPtr?: number, + // Used when there is no source font handle to read a style from - notably on + // the undo path, which re-emits with `originalFontPtr: 0`. Without it the + // borrow is unconstrained again and restored body text comes back bold. + likeStyle?: FontStyleClass | null, +): number | null { + if (!unicodeChar) return null; + const cp = unicodeChar.codePointAt(0); + if (cp === undefined) return null; + const m = ctx.module; + const want = + (likeFontPtr ? fontStyleClass(m, likeFontPtr) : null) ?? likeStyle ?? null; + // The style is part of the answer, so it must be part of the cache key. + const styleK = want + ? `${want.bold ? "b" : ""}${want.italic ? "i" : ""}|` + : ""; + // So is the source face: the borrow prefers the run's own family, so two + // runs of different families must not share an answer. + const likeName = likeFontPtr + ? baseFontFamily(readFontName(m, likeFontPtr)) + : undefined; + const cacheK = `${ctx.pagePtr}:${styleK}${likeName ?? ""}|${cp}`; + if (fontForCharCache.has(cacheK)) return fontForCharCache.get(cacheK) ?? null; + const tpMod = m as unknown as TextPageModule; + const fontMod = m as unknown as FontReadModule; + if ( + !tpMod.FPDFText_LoadPage || + !tpMod.FPDFText_CountChars || + !tpMod.FPDFText_GetUnicode || + !tpMod.FPDFText_GetTextObject || + !fontMod.FPDFTextObj_GetFont + ) { + fontForCharCache.set(cacheK, null); + return null; + } + const textPage = tpMod.FPDFText_LoadPage(ctx.pagePtr); + if (!textPage) { + fontForCharCache.set(cacheK, null); + return null; + } + try { + const count = tpMod.FPDFText_CountChars(textPage); + // The run's OWN family, wherever the page happens to draw this char in it, + // beats whichever style-compatible face comes first in content order. A + // word the document already uses otherwise came back in a near-miss face - + // right weight, slightly wrong shapes and advances. + let fallback: number | null = null; + for (let i = 0; i < count; i++) { + const u = tpMod.FPDFText_GetUnicode(textPage, i); + if (u !== cp) continue; + const obj = tpMod.FPDFText_GetTextObject(textPage, i); + if (!obj) continue; + try { + const f = fontMod.FPDFTextObj_GetFont(obj); + if (!f) continue; + if (want) { + const got = fontStyleClass(m, f); + // An unnamed font can't be vouched for; skip it rather than risk a + // weight change. + if (!got || got.bold !== want.bold || got.italic !== want.italic) { + continue; + } + } + if (!likeName || baseFontFamily(readFontName(m, f)) === likeName) { + fontForCharCache.set(cacheK, f); + return f; + } + if (fallback === null) fallback = f; + } catch { + continue; + } + } + if (fallback !== null) { + fontForCharCache.set(cacheK, fallback); + return fallback; + } + } finally { + if (tpMod.FPDFText_ClosePage) { + try { + tpMod.FPDFText_ClosePage(textPage); + } catch { + /* best-effort */ + } + } + } + fontForCharCache.set(cacheK, null); + return null; +} + +/** Test-only: clear the per-char-font cache. */ +export function _clearFontForCharCacheForTests(): void { + fontForCharCache.clear(); +} + +interface FontNameModule { + FPDFFont_GetBaseFontName?: (font: number, buf: number, len: number) => number; +} + +/** + * A face's family, with the subset tag and style suffix stripped: + * "ABCDEF+LMRoman12-Regular" -> "lmroman12". Two handles that agree here are + * the same design, so a glyph borrowed across them keeps the run's look. + */ +function baseFontFamily(name: string | undefined): string | undefined { + if (!name) return undefined; + const family = name.replace(/^[A-Z]{6}\+/, "").split(/[-,]/)[0]; + return family ? family.toLowerCase() : undefined; +} + +const fontNameCache = new Map(); + +/** Test-only: clear the memoised /BaseFont names. */ +export function _clearFontNameCacheForTests(): void { + fontNameCache.clear(); +} + +// Read a font's /BaseFont name so the backend can disambiguate WHICH font to +// encode against when two fonts on the page render the same char. +function readFontName( + m: ResolverContext["module"], + fontPtr: number, +): string | undefined { + if (!fontPtr) return undefined; + if (fontNameCache.has(fontPtr)) return fontNameCache.get(fontPtr); + const name = loadFontName(m, fontPtr); + fontNameCache.set(fontPtr, name); + return name; +} + +function loadFontName( + m: ResolverContext["module"], + fontPtr: number, +): string | undefined { + const fn = (m as unknown as FontNameModule).FPDFFont_GetBaseFontName; + if (typeof fn !== "function") return undefined; + try { + const len = fn(fontPtr, 0, 0); + if (len <= 1) return undefined; + const buf = m.pdfium.wasmExports.malloc(len); + try { + fn(fontPtr, buf, len); + return m.pdfium.UTF8ToString(buf) || undefined; + } finally { + m.pdfium.wasmExports.free(buf); + } + } catch { + return undefined; + } +} + +/** Per-page idempotency guard for `prewarmBackendCacheForPage`. */ +const prewarmedPages = new Set(); + +// Pre-warm the backend cache for every Unicode char that already lives on the +// given page. +const TYPEABLE_CHARS: string[] = (() => { + const out: string[] = []; + for (let cp = 0x21; cp <= 0x7e; cp += 1) out.push(String.fromCodePoint(cp)); + return out; +})(); + +const MAX_PREWARM_PROBES = 4000; + +function addTypeableProbes( + probes: Array<{ ch: string; perCharFont: number }>, + seen: Set, +): void { + const fonts = [...new Set(probes.map((p) => p.perCharFont))]; + for (const font of fonts) { + for (const ch of TYPEABLE_CHARS) { + if (probes.length >= MAX_PREWARM_PROBES) return; + const key = `${font}:${ch}`; + if (seen.has(key)) continue; + seen.add(key); + if (charCache.has(cacheKey(font, ch))) continue; + probes.push({ ch, perCharFont: font }); + } + } +} + +export async function prewarmBackendCacheForPage( + pageIndex: number, +): Promise { + // Always log entry so tests + debug have a single signal that "prewarm was at + // least invoked for page N" regardless of which early-return path the body. + if (typeof console !== "undefined") { + console.debug(`[charcode] backend prewarm-start pageIdx=${pageIndex}`); + } + const editorCtx = getEditorContextForPage(pageIndex); + if (!editorCtx) { + if (typeof console !== "undefined") { + console.debug( + `[charcode] backend prewarm pageIdx=${pageIndex} probes=0 (no-editor-ctx)`, + ); + } + return; + } + const { module: m, pagePtr } = editorCtx; + if (prewarmedPages.has(pagePtr)) { + if (typeof console !== "undefined") { + console.debug( + `[charcode] backend prewarm pageIdx=${pageIndex} probes=0 (already-prewarmed)`, + ); + } + return; + } + + // Walk the page text once, collecting (perCharFont, unicode) for every + // glyph. Dedupe so each (font, char) probe fires at most once per page. + const tpMod = m as unknown as TextPageModule; + const fontMod = m as unknown as FontReadModule; + if ( + !tpMod.FPDFText_LoadPage || + !tpMod.FPDFText_CountChars || + !tpMod.FPDFText_GetUnicode || + !tpMod.FPDFText_GetTextObject || + !fontMod.FPDFTextObj_GetFont + ) + return; + + const probes: Array<{ ch: string; perCharFont: number }> = []; + const seen = new Set(); + const textPage = tpMod.FPDFText_LoadPage(pagePtr); + if (!textPage) return; + try { + const count = tpMod.FPDFText_CountChars(textPage); + for (let i = 0; i < count; i++) { + const cp = tpMod.FPDFText_GetUnicode(textPage, i); + if (!cp) continue; + const ch = String.fromCodePoint(cp); + const obj = tpMod.FPDFText_GetTextObject(textPage, i); + if (!obj) continue; + let f = 0; + try { + f = fontMod.FPDFTextObj_GetFont(obj); + } catch { + continue; + } + if (!f) continue; + const key = `${f}:${ch}`; + if (seen.has(key)) continue; + seen.add(key); + // Skip whitespace - those aren't worth round-tripping and + // editTextHelpers' per-char branch bails on whitespace anyway. + if (/\s/.test(ch)) continue; + // Skip if already cached under this perChar font. + if (charCache.has(cacheKey(f, ch))) continue; + probes.push({ ch, perCharFont: f }); + // Seed findFontForChar's cache so the emit-path probe doesn't + // re-walk the text page for the same char. + fontForCharCache.set(`${pagePtr}:${cp}`, f); + } + } finally { + if (tpMod.FPDFText_ClosePage) { + try { + tpMod.FPDFText_ClosePage(textPage); + } catch { + /* best-effort */ + } + } + } + addTypeableProbes(probes, seen); + if (probes.length === 0) return; + + // Guard the page only once we're committed to the fetch fan-out. + prewarmedPages.add(pagePtr); + + try { + const { PdfiumSave } = + await import("@app/tools/pdfTextEditor/pdfium/PdfiumSave"); + const doc = getEditorDocument(); + if (!doc) return; + const bytes = PdfiumSave.serialize(doc); + if (!bytes || bytes.byteLength === 0) return; + const pdfBase64 = uint8ToBase64(bytes); + + // Batch by font: fire ONE encode-charcodes request per font carrying ALL of + // that font's page chars, instead of one request per (font, char). + const byFont = new Map(); + for (const { ch, perCharFont } of probes) { + const arr = byFont.get(perCharFont); + if (arr) arr.push(ch); + else byFont.set(perCharFont, [ch]); + } + const fontBatches = [...byFont.entries()].map(([font, chars]) => ({ + font, + chars, + })); + + // Cap concurrent encode-charcodes requests to avoid overwhelming the Spring + // backend's PDFBox parser (many parallel POSTs can saturate the thread pool). + const CONCURRENCY = 6; + let batchIdx = 0; + let probesSucceeded = 0; + const workers: Promise[] = []; + for (let w = 0; w < CONCURRENCY; w++) { + workers.push( + (async () => { + while (true) { + const me = batchIdx++; + if (me >= fontBatches.length) return; + const { font, chars } = fontBatches[me]; + const reqKey = `prewarm:${font}:${chars.join("")}`; + if (inFlight.has(reqKey)) continue; + inFlight.add(reqKey); + try { + const json = await postCharcodes({ + pdfBase64, + pageIndex, + // Any of this font's chars is a valid locator (the font renders + // them all). + locatorChar: chars[0], + fontName: readFontName(m, font), + // Program-bytes hash beats the name: PDFium strips subset tags. + fontSha256: getCachedFontProgramSha256(font) ?? undefined, + text: chars.join(""), + }); + if (!json || json.error) continue; + // Map returned charcodes back to chars: the backend appends one + // charcode per NON-missing char in request order. + const missing = new Set(json.missing ?? []); + const codes = json.charcodes ?? []; + let k = 0; + for (const ch of chars) { + if (missing.has(ch)) { + charCache.set(cacheKey(font, ch), null); + continue; + } + const code = codes[k++]; + if (typeof code === "number") { + charCache.set(cacheKey(font, ch), code); + probesSucceeded += 1; + } else { + charCache.set(cacheKey(font, ch), null); + } + } + } finally { + inFlight.delete(reqKey); + } + } + })(), + ); + } + await Promise.all(workers); + if (typeof console !== "undefined") { + console.debug( + `[charcode] backend prewarm pageIdx=${pageIndex} probes=${probes.length} succeeded=${probesSucceeded}`, + ); + } + // If EVERY probe failed (auth, backend down, all 500s) un-mark the page so + // a subsequent focus can retry instead of silently returning early forever. + if (probesSucceeded === 0) { + prewarmedPages.delete(pagePtr); + } + } catch { + /* prewarm is best-effort - errors are silently swallowed */ + prewarmedPages.delete(pagePtr); + } +} + +/** Test-only: clear the per-page prewarm guard. */ +export function _clearPrewarmGuardForTests(): void { + prewarmedPages.clear(); +} + +function getEditorContextForPage(pageIndex: number): { + module: import("@embedpdf/pdfium").WrappedPdfiumModule; + pagePtr: number; + docPtr: number; +} | null { + const doc = getEditorDocument(); + if (!doc) return null; + const pages = doc.loadedPages?.(); + if (!pages) return null; + for (const p of pages) { + if (p.index === pageIndex) { + return { module: doc.module, pagePtr: p.pagePtr, docPtr: doc.docPtr }; + } + } + return null; +} + +function pageIdxOfPagePtr(ctx: ResolverContext): number { + // The ResolverContext only carries pagePtr; map back to index by asking the + // doc model. + const w = window as unknown as { + __editor_store?: { + document?: { + loadedPages?: () => Iterable<{ pagePtr: number; index: number }>; + } | null; + }; + }; + const pages = w.__editor_store?.document?.loadedPages?.(); + if (!pages) return -1; + for (const p of pages) if (p.pagePtr === ctx.pagePtr) return p.index; + return -1; +} + +function getEditorDocument(): + | import("@app/tools/pdfTextEditor/model/EditorDocument").EditorDocument + | null { + // EditorStore.doc is TypeScript-private; the public surface is the + // `document` getter. Always read through that. + const w = window as unknown as { + __editor_store?: { + document?: + | import("@app/tools/pdfTextEditor/model/EditorDocument").EditorDocument + | null; + }; + }; + return w.__editor_store?.document ?? null; +} + +function uint8ToBase64(bytes: Uint8Array): string { + let bin = ""; + const chunk = 0x8000; + // Pass the typed-array subarray straight to apply() (it is array-like) so we + // don't allocate an intermediate Array per chunk for large PDFs. + for (let i = 0; i < bytes.length; i += chunk) { + bin += String.fromCharCode.apply( + null, + bytes.subarray(i, i + chunk) as unknown as number[], + ); + } + return btoa(bin); +} + +function cacheKey(fontPtr: number, ch: string): string { + return `${fontPtr}:${ch}`; +} + +/** Test-only: clear the per-char cache. */ +export function _clearBackendCacheForTests(): void { + charCache.clear(); + negativeUntil.clear(); + inFlight.clear(); +} + +// Reset ALL module-level caches keyed by raw PDFium pointers (per-char +// charcodes, per-page prewarm guard, per-char font handles, in-flight set). +export function resetBackendResolverCaches(): void { + charCache.clear(); + negativeUntil.clear(); + inFlight.clear(); + prewarmedPages.clear(); + fontForCharCache.clear(); + serializedCache = null; + autoPrefetchActive = 0; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/charcode/CharcodeStrategy.ts b/frontend/editor/src/core/tools/pdfTextEditor/charcode/CharcodeStrategy.ts new file mode 100644 index 0000000000..a0b4fb69a6 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/charcode/CharcodeStrategy.ts @@ -0,0 +1,82 @@ +// Strategy for resolving Unicode chars to font-specific charcodes when writing +// new text into an existing embedded subset font. +export type CharcodeStrategy = + | "helvetica" // Legacy: always fall back to Helvetica for new chars. + | "cmap" // Parse the embedded font's cmap table. + | "content-stream" // Read raw PDF content streams to extract charcode bytes. + | "backend"; // Send to Spring backend, PDFBox encodes server-side. + +export const CHARCODE_STRATEGIES: readonly CharcodeStrategy[] = [ + "helvetica", + "cmap", + "content-stream", + "backend", +] as const; + +const STORAGE_KEY = "pdfTextEditor.charcodeStrategy"; +const URL_PARAM = "charcodeStrategy"; + +// Resolve the active strategy: URL param wins over localStorage, which wins +// over the default. +export const DEFAULT_CHARCODE_STRATEGY: CharcodeStrategy = "backend"; + +export function getActiveCharcodeStrategy(): CharcodeStrategy { + if (typeof window === "undefined") return DEFAULT_CHARCODE_STRATEGY; + try { + const url = new URL(window.location.href); + const fromUrl = url.searchParams.get(URL_PARAM); + if (fromUrl && isStrategy(fromUrl)) return fromUrl; + } catch { + /* ignore malformed URL */ + } + try { + const fromLs = window.localStorage.getItem(STORAGE_KEY); + if (fromLs && isStrategy(fromLs)) return fromLs; + } catch { + /* localStorage may be disabled */ + } + return DEFAULT_CHARCODE_STRATEGY; +} + +export function setActiveCharcodeStrategy(s: CharcodeStrategy): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, s); + } catch { + /* best-effort */ + } +} + +function isStrategy(value: string): value is CharcodeStrategy { + return (CHARCODE_STRATEGIES as readonly string[]).includes(value); +} + +// Per-strategy result for a Unicode→charcodes resolve attempt. `charcodes`: the +// array of font-specific bytes/CIDs to pass to FPDFText_SetCharcodes. +export interface CharcodeResolveResult { + charcodes: number[]; + coverage: number; + missing: string[]; + note: string; +} + +// Contract every strategy implementation satisfies. `null` from resolve means +// the strategy can't run AT ALL for this font - caller falls back. +export interface CharcodeResolver { + readonly name: CharcodeStrategy; + // Resolve every char in `text` to a charcode usable with + // FPDFText_SetCharcodes against the given font pointer. + resolve( + font: number, + text: string, + ctx: ResolverContext, + ): CharcodeResolveResult | null; +} + +// Hooks every strategy needs: PDFium module access, the source page handle (for +// content-stream parsing), and fetch() for backend. +export interface ResolverContext { + module: import("@embedpdf/pdfium").WrappedPdfiumModule; + pagePtr: number; + docPtr: number; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/charcode/CmapResolver.ts b/frontend/editor/src/core/tools/pdfTextEditor/charcode/CmapResolver.ts new file mode 100644 index 0000000000..cb04944c70 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/charcode/CmapResolver.ts @@ -0,0 +1,339 @@ +import type { + CharcodeResolver, + CharcodeResolveResult, + ResolverContext, +} from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; +import { sha256Hex } from "@app/tools/pdfTextEditor/util/sha256"; + +/** Strategy 1: parse the embedded font's cmap table. */ + +interface FontDataModule { + FPDFFont_GetFontData?: ( + font: number, + bufferPtr: number, + length: number, + outSizePtr: number, + ) => boolean; +} + +/** Per-font cmap cache. Keyed by font pointer (stable per document). */ +const cmapCache = new Map | null>(); + +// Per-font SHA-256 (hex) of the embedded font PROGRAM bytes, computed from the +// same FPDFFont_GetFontData read that feeds the cmap parse. +const fontShaCache = new Map(); + +/** Don't hash font programs above this size. */ +const MAX_HASH_BYTES = 8 * 1024 * 1024; + +export class CmapResolver implements CharcodeResolver { + readonly name = "cmap" as const; + + resolve( + font: number, + text: string, + ctx: ResolverContext, + ): CharcodeResolveResult | null { + if (!font) return null; + const cmap = getOrBuildCmap(font, ctx); + if (!cmap) { + return { + charcodes: [], + coverage: 0, + missing: [...text], + note: "cmap unavailable for this font", + }; + } + const charcodes: number[] = []; + const missing: string[] = []; + for (const ch of text) { + const cp = ch.codePointAt(0) ?? 0; + const gid = cmap.get(cp); + if (gid === undefined) { + missing.push(ch); + continue; + } + charcodes.push(gid); + } + return { + charcodes, + coverage: charcodes.length, + missing, + note: `cmap entries: ${cmap.size}, requested: ${text.length}, resolved: ${charcodes.length}`, + }; + } +} + +function getOrBuildCmap( + font: number, + ctx: ResolverContext, +): Map | null { + const cached = cmapCache.get(font); + if (cached !== undefined) return cached; + const built = buildCmap(font, ctx); + cmapCache.set(font, built); + return built; +} + +/** Build + cache a font's cmap. */ +export function primeFontGlyphMap( + font: number, + module: import("@embedpdf/pdfium").WrappedPdfiumModule, +): void { + if (!font) return; + getOrBuildCmap(font, { module, pagePtr: 0, docPtr: 0 }); +} + +/** Read a font's cached Unicode→glyphId cmap WITHOUT touching PDFium. */ +export function getCachedFontGlyphMap( + font: number, +): Map | null { + return cmapCache.get(font) ?? null; +} + +// SHA-256 hex of the font's embedded program bytes, cached by {@link +// primeFontGlyphMap} during the load phase. Safe to call any time. +export function getCachedFontProgramSha256(font: number): string | null { + return fontShaCache.get(font) ?? null; +} + +function buildCmap( + font: number, + ctx: ResolverContext, +): Map | null { + const bytes = readFontData(font, ctx.module); + // Hash alongside the cmap parse - same single PDFium read serves both. + if (!fontShaCache.has(font)) { + let sha: string | null = null; + if (bytes && bytes.length > 0 && bytes.length <= MAX_HASH_BYTES) { + try { + sha = sha256Hex(bytes); + } catch { + sha = null; + } + } + fontShaCache.set(font, sha); + } + if (!bytes) return null; + return parseTrueTypeCmap(bytes); +} + +/** Copy a font's embedded program bytes out of the WASM heap (null = none). */ +function readFontData( + font: number, + m: import("@embedpdf/pdfium").WrappedPdfiumModule, +): Uint8Array | null { + const fontMod = m as unknown as FontDataModule; + if (!fontMod.FPDFFont_GetFontData) return null; + + // First call: ask for the buffer size (pass length=0, read outSize). + const sizePtr = m.pdfium.wasmExports.malloc(4); + try { + const ok = fontMod.FPDFFont_GetFontData(font, 0, 0, sizePtr); + if (!ok) return null; + const size = m.pdfium.getValue(sizePtr, "i32"); + if (size <= 0) return null; + const dataPtr = m.pdfium.wasmExports.malloc(size); + try { + const ok2 = fontMod.FPDFFont_GetFontData(font, dataPtr, size, sizePtr); + if (!ok2) return null; + // Slice() copies out of the WASM heap so we own the bytes. + const heapU8 = (m.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8; + return new Uint8Array(heapU8.buffer, dataPtr, size).slice(); + } finally { + m.pdfium.wasmExports.free(dataPtr); + } + } finally { + m.pdfium.wasmExports.free(sizePtr); + } +} + +/** Minimal TrueType / OpenType cmap parser. */ +export function parseTrueTypeCmap( + bytes: Uint8Array, +): Map | null { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (bytes.length < 12) return null; + + // sfnt header: first 4 bytes are the scaler type + // (0x00010000 for TrueType, 'OTTO' for OpenType/CFF, 'true', 'typ1'). + const scaler = dv.getUint32(0); + const isOpenTypeCff = scaler === 0x4f54544f; // 'OTTO' + const isTrueType = + scaler === 0x00010000 || + scaler === 0x74727565 || // 'true' + scaler === 0x74797031; // 'typ1' + if (!isOpenTypeCff && !isTrueType) return null; + + const numTables = dv.getUint16(4); + const tableRecordStart = 12; + // Find the 'cmap' table record. + let cmapOffset = 0; + for (let i = 0; i < numTables; i++) { + const recordOffset = tableRecordStart + i * 16; + if (recordOffset + 16 > bytes.length) return null; + const tag = dv.getUint32(recordOffset); + if (tag === CMAP_TABLE_TAG) { + cmapOffset = dv.getUint32(recordOffset + 8); + break; + } + } + if (cmapOffset === 0 || cmapOffset + 4 > bytes.length) return null; + + const numSubtables = dv.getUint16(cmapOffset + 2); + // Pick the best subtable: prefer Unicode platform (0), then + // Microsoft Unicode (3, encoding 1 or 10). + let bestSubtableOffset = 0; + let bestRank = -1; + for (let i = 0; i < numSubtables; i++) { + const recordOffset = cmapOffset + 4 + i * 8; + if (recordOffset + 8 > bytes.length) continue; + const platformId = dv.getUint16(recordOffset); + const encodingId = dv.getUint16(recordOffset + 2); + const subtableOffset = cmapOffset + dv.getUint32(recordOffset + 4); + const rank = rankSubtable(platformId, encodingId); + if (rank > bestRank) { + bestRank = rank; + bestSubtableOffset = subtableOffset; + } + } + if (bestSubtableOffset === 0) return null; + + // A malformed subtable can read past the buffer (RangeError); never let one + // bad font throw out of the loader's synchronous prime - treat as no cmap. + try { + const format = dv.getUint16(bestSubtableOffset); + if (format === 4) return parseFormat4(dv, bestSubtableOffset); + if (format === 6) return parseFormat6(dv, bestSubtableOffset); + if (format === 12) return parseFormat12(dv, bestSubtableOffset); + } catch { + return null; + } + return null; +} + +function rankSubtable(platformId: number, encodingId: number): number { + // Microsoft Unicode UCS-4 (3, 10) is the highest priority - covers chars + // above U+FFFF. + if (platformId === 3 && encodingId === 10) return 100; + if (platformId === 0 && encodingId === 4) return 90; + if (platformId === 0 && encodingId === 6) return 90; + if (platformId === 3 && encodingId === 1) return 80; + if (platformId === 0) return 70; + return 0; +} + +/** Format 4: segment-mapping-to-delta. The most common cmap subtable. */ +function parseFormat4( + dv: DataView, + offset: number, +): Map | null { + const length = dv.getUint16(offset + 2); + if (offset + length > dv.byteLength) return null; + const segCountX2 = dv.getUint16(offset + 6); + const segCount = segCountX2 / 2; + const endCodesOffset = offset + 14; + const startCodesOffset = endCodesOffset + segCountX2 + 2; + const idDeltasOffset = startCodesOffset + segCountX2; + const idRangeOffsetsOffset = idDeltasOffset + segCountX2; + const glyphIdArrayOffset = idRangeOffsetsOffset + segCountX2; + const out = new Map(); + for (let i = 0; i < segCount; i++) { + const endCode = dv.getUint16(endCodesOffset + i * 2); + const startCode = dv.getUint16(startCodesOffset + i * 2); + const idDelta = dv.getInt16(idDeltasOffset + i * 2); + const idRangeOffset = dv.getUint16(idRangeOffsetsOffset + i * 2); + if (startCode === 0xffff && endCode === 0xffff) continue; + for (let c = startCode; c <= endCode; c++) { + // Cap entries like formats 6/12 - hostile format-4 cmaps can span huge ranges. + if (out.size >= MAX_CMAP_ENTRIES) return out; + let glyphId: number; + if (idRangeOffset === 0) { + glyphId = (c + idDelta) & 0xffff; + } else { + // The spec's idRangeOffset trick: an offset INTO the + // idRangeOffset array itself that points to the glyphIdArray. + const glyphIdOffset = + idRangeOffsetsOffset + i * 2 + idRangeOffset + (c - startCode) * 2; + if ( + glyphIdOffset + 2 > + glyphIdArrayOffset + (length - (glyphIdArrayOffset - offset)) + ) { + continue; + } + const raw = dv.getUint16(glyphIdOffset); + if (raw === 0) continue; + glyphId = (raw + idDelta) & 0xffff; + } + if (glyphId !== 0) out.set(c, glyphId); + } + if (out.size >= MAX_CMAP_ENTRIES) break; + } + return out; +} + +/** Big-endian "cmap" as an sfnt table tag. */ +const CMAP_TABLE_TAG = 0x636d6170; + +// Hard cap on entries built from any one cmap. +const MAX_CMAP_ENTRIES = 200_000; + +/** Format 6: trimmed table mapping. Compact contiguous range. */ +function parseFormat6(dv: DataView, offset: number): Map { + const firstCode = dv.getUint16(offset + 6); + const entryCount = dv.getUint16(offset + 8); + const out = new Map(); + // Bound the loop to the buffer AND the entry cap. + const safeCount = Math.min( + entryCount, + Math.max(0, Math.floor((dv.byteLength - (offset + 10)) / 2)), + MAX_CMAP_ENTRIES, + ); + for (let i = 0; i < safeCount; i++) { + const glyphId = dv.getUint16(offset + 10 + i * 2); + if (glyphId !== 0) out.set(firstCode + i, glyphId); + } + return out; +} + +/** Format 12: segmented coverage for chars above U+FFFF (emoji etc.). */ +function parseFormat12(dv: DataView, offset: number): Map { + const numGroups = dv.getUint32(offset + 12); + const groupsOffset = offset + 16; + const out = new Map(); + // Bound group count to what actually fits in the buffer (12 bytes/group). + const safeGroups = Math.min( + numGroups, + Math.max(0, Math.floor((dv.byteLength - groupsOffset) / 12)), + ); + for (let i = 0; i < safeGroups; i++) { + const recordOffset = groupsOffset + i * 12; + const startCharCode = dv.getUint32(recordOffset); + const endCharCode = dv.getUint32(recordOffset + 4); + const startGlyphId = dv.getUint32(recordOffset + 8); + // Skip inverted ranges; cap a single group's span so one huge/corrupt + // group can't blow the entry budget. + if (endCharCode < startCharCode) continue; + const last = Math.min( + endCharCode, + startCharCode + (MAX_CMAP_ENTRIES - out.size) - 1, + ); + for (let c = startCharCode; c <= last; c++) { + const gid = startGlyphId + (c - startCharCode); + if (gid !== 0) out.set(c, gid); + } + if (out.size >= MAX_CMAP_ENTRIES) break; + } + return out; +} + +/** Clear the per-font cmap + program-hash caches. */ +export function resetCmapCache(): void { + cmapCache.clear(); + fontShaCache.clear(); +} + +/** Test-only alias for {@link resetCmapCache}. */ +export function _clearCmapCacheForTests(): void { + resetCmapCache(); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/charcode/ContentStreamResolver.ts b/frontend/editor/src/core/tools/pdfTextEditor/charcode/ContentStreamResolver.ts new file mode 100644 index 0000000000..c6bcf38fb9 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/charcode/ContentStreamResolver.ts @@ -0,0 +1,139 @@ +import type { + CharcodeResolver, + CharcodeResolveResult, + ResolverContext, +} from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; + +// Strategy 2: scrape Unicode→charcode mappings by walking the page's existing +// text via PDFium's text page API. + +interface TextPageModule { + FPDFText_LoadPage?: (page: number) => number; + FPDFText_ClosePage?: (textPage: number) => void; + FPDFText_CountChars?: (textPage: number) => number; + FPDFText_GetUnicode?: (textPage: number, idx: number) => number; + FPDFText_GetTextObject?: (textPage: number, idx: number) => number; +} + +interface FontReadModule { + FPDFTextObj_GetFont?: (obj: number) => number; +} + +/** Cache: per-page-pointer Map>. */ +const perPageCache = new Map>>(); + +export class ContentStreamResolver implements CharcodeResolver { + readonly name = "content-stream" as const; + + resolve( + font: number, + text: string, + ctx: ResolverContext, + ): CharcodeResolveResult | null { + if (!font) return null; + const unicodeToCharcode = getOrBuildMap(font, ctx); + if (!unicodeToCharcode) { + return { + charcodes: [], + coverage: 0, + missing: [...text], + note: "content-stream scan returned no entries for this font", + }; + } + const charcodes: number[] = []; + const missing: string[] = []; + for (const ch of text) { + const cp = ch.codePointAt(0) ?? 0; + const cc = unicodeToCharcode.get(cp); + if (cc === undefined) { + missing.push(ch); + continue; + } + charcodes.push(cc); + } + return { + charcodes, + coverage: charcodes.length, + missing, + note: `content-stream entries: ${unicodeToCharcode.size}, requested: ${text.length}, resolved: ${charcodes.length}`, + }; + } +} + +function getOrBuildMap( + font: number, + ctx: ResolverContext, +): Map | null { + let pageMap = perPageCache.get(ctx.pagePtr); + if (!pageMap) { + pageMap = buildPageMap(ctx); + perPageCache.set(ctx.pagePtr, pageMap); + } + return pageMap.get(font) ?? null; +} + +function buildPageMap(ctx: ResolverContext): Map> { + const m = ctx.module; + const tpMod = m as unknown as TextPageModule; + const fontMod = m as unknown as FontReadModule; + const out = new Map>(); + if ( + !tpMod.FPDFText_LoadPage || + !tpMod.FPDFText_CountChars || + !tpMod.FPDFText_GetUnicode || + !tpMod.FPDFText_GetTextObject || + !fontMod.FPDFTextObj_GetFont + ) { + return out; + } + const textPage = tpMod.FPDFText_LoadPage(ctx.pagePtr); + if (!textPage) return out; + try { + const count = tpMod.FPDFText_CountChars(textPage); + // Per-FONT counter (not per-text-object): every unique Unicode we encounter + // in a given font gets the next sequential CID starting at 1. + const perFontNext = new Map(); + for (let i = 0; i < count; i++) { + const unicode = tpMod.FPDFText_GetUnicode(textPage, i); + if (!unicode) continue; + const obj = tpMod.FPDFText_GetTextObject(textPage, i); + if (!obj) continue; + let font = 0; + try { + font = fontMod.FPDFTextObj_GetFont(obj); + } catch { + /* skip */ + } + if (!font) continue; + let fontMap = out.get(font); + if (!fontMap) { + fontMap = new Map(); + out.set(font, fontMap); + } + if (!fontMap.has(unicode)) { + const nextCid = (perFontNext.get(font) ?? 0) + 1; + perFontNext.set(font, nextCid); + fontMap.set(unicode, nextCid); + } + } + } finally { + if (tpMod.FPDFText_ClosePage) { + try { + tpMod.FPDFText_ClosePage(textPage); + } catch { + /* best-effort */ + } + } + } + return out; +} + +/** Clear the per-page Unicode→charcode cache. */ +export function resetContentStreamCache(): void { + perPageCache.clear(); +} + +/** Test-only alias for {@link resetContentStreamCache}. */ +export function _clearContentStreamCacheForTests(): void { + resetContentStreamCache(); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/charcode/charcodeRegistry.ts b/frontend/editor/src/core/tools/pdfTextEditor/charcode/charcodeRegistry.ts new file mode 100644 index 0000000000..ab93eccbf5 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/charcode/charcodeRegistry.ts @@ -0,0 +1,185 @@ +import { + BackendResolver, + findFontForChar, + fontIsReusable, + prewarmBackendCacheForPage, + styleClassFromName, +} from "@app/tools/pdfTextEditor/charcode/BackendResolver"; + +/** Re-export so the emit path can do per-char font lookup. */ +export { + findFontForChar, + fontIsReusable, + prewarmBackendCacheForPage, + styleClassFromName, +}; +import { + CharcodeResolver, + CharcodeStrategy, + getActiveCharcodeStrategy, + ResolverContext, +} from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; +import { CmapResolver } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import { ContentStreamResolver } from "@app/tools/pdfTextEditor/charcode/ContentStreamResolver"; + +/** Per-emit telemetry. */ +export interface CharcodeEvent { + timestamp: number; + strategy: CharcodeStrategy; + text: string; + fontPtr: number; + resolved: number[]; + missing: string[]; + note: string; + outcome: + | "charcodes-ok" + | "charcodes-call-failed" + | "partial-coverage-fallback" + | "no-strategy" + | "no-font"; +} + +const eventListeners = new Set<(e: CharcodeEvent) => void>(); +const recentEvents: CharcodeEvent[] = []; +const MAX_RECENT = 50; + +export function subscribeCharcodeEvents( + cb: (e: CharcodeEvent) => void, +): () => void { + eventListeners.add(cb); + return () => eventListeners.delete(cb); +} + +export function getRecentCharcodeEvents(): CharcodeEvent[] { + return [...recentEvents]; +} + +function emitEvent(e: CharcodeEvent): void { + recentEvents.push(e); + if (recentEvents.length > MAX_RECENT) recentEvents.shift(); + // Expose recent events on window for emit-path-aware Playwright tests. + if (typeof window !== "undefined") { + ( + window as unknown as { + __charcode_events?: CharcodeEvent[]; + } + ).__charcode_events = [...recentEvents]; + } + for (const cb of eventListeners) { + try { + cb(e); + } catch { + /* swallow listener errors */ + } + } +} + +/** Test-only: clear the in-memory recent-events buffer + window hook. */ +export function _clearRecentCharcodeEventsForTests(): void { + recentEvents.length = 0; + if (typeof window !== "undefined") { + ( + window as unknown as { __charcode_events?: CharcodeEvent[] } + ).__charcode_events = []; + } +} + +/** Public entry point for the emit path to record an attempt. */ +export function emitCharcodeEvent( + e: Omit & { + timestamp?: number; + }, +): void { + emitEvent({ + ...e, + // performance.now is available in browser + Node 16+. + timestamp: + typeof performance !== "undefined" && performance.now + ? performance.now() + : recentEvents.length, + }); +} + +const resolvers: Record = { + helvetica: null, // legacy: do nothing, caller falls back. + cmap: new CmapResolver(), + "content-stream": new ContentStreamResolver(), + backend: new BackendResolver(), +}; + +// Get the resolver for the currently active strategy. Returns null for +// `helvetica` (the legacy "always fall back" mode). +export function activeResolver(): CharcodeResolver | null { + const s = getActiveCharcodeStrategy(); + return resolvers[s]; +} + +interface SetCharcodesModule { + FPDFText_SetCharcodes?: ( + textObj: number, + charcodesPtr: number, + count: number, + ) => boolean; +} + +/** Write `charcodes` into `textObj` via FPDFText_SetCharcodes. */ +export function setCharcodesOn( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + textObj: number, + charcodes: number[], +): boolean { + const ccMod = m as unknown as SetCharcodesModule; + if (!ccMod.FPDFText_SetCharcodes || charcodes.length === 0) return false; + // Allocate a uint32 buffer in the WASM heap. + const bufSize = charcodes.length * 4; + const buf = m.pdfium.wasmExports.malloc(bufSize); + try { + const heapU8 = (m.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8; + const view = new Uint32Array(heapU8.buffer, buf, charcodes.length); + for (let i = 0; i < charcodes.length; i++) view[i] = charcodes[i] >>> 0; + return !!ccMod.FPDFText_SetCharcodes(textObj, buf, charcodes.length); + } catch { + return false; + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +/** Strategy-aware resolve helper used by the emit path. */ +export function tryResolveCharcodes( + font: number, + text: string, + ctx: ResolverContext, + allowContentStreamFallback = false, +): { + strategy: CharcodeStrategy; + result: ReturnType; +} | null { + const r = activeResolver(); + if (r) { + const result = r.resolve(font, text, ctx); + if (result && result.coverage === [...text].length) { + return { strategy: r.name, result }; + } + // Active resolver (e.g. backend with a cold cache) did not fully cover the + // text. + if (allowContentStreamFallback && r.name !== "content-stream") { + const cs = resolvers["content-stream"]; + const csResult = cs?.resolve(font, text, ctx); + if (csResult && csResult.coverage === [...text].length) { + return { strategy: "content-stream", result: csResult }; + } + } + return { strategy: r.name, result }; + } + // No active resolver (helvetica strategy). Still try the client-side + // content-stream reuse when explicitly allowed. + if (allowContentStreamFallback) { + const cs = resolvers["content-stream"]; + const csResult = cs?.resolve(font, text, ctx); + if (csResult && csResult.coverage === [...text].length) { + return { strategy: "content-stream", result: csResult }; + } + } + return null; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/AlignParagraphLinesCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/AlignParagraphLinesCommand.ts new file mode 100644 index 0000000000..b352efbb52 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/AlignParagraphLinesCommand.ts @@ -0,0 +1,153 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { transformObject } from "@app/tools/pdfTextEditor/util/objectTransform"; + +export type LineAlignMode = "left" | "center-h" | "right"; + +// Horizontally align the LINES inside a single multi-line paragraph run +// relative to each other. +export class AlignParagraphLinesCommand implements Command { + readonly type = "align-paragraph-lines"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly mode: LineAlignMode; + /** Per-line dx actually applied, parallel to the run's line slots. */ + private appliedDx: number[] = []; + + constructor(opts: { pageIndex: number; runId: string; mode: LineAlignMode }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.mode = opts.mode; + } + + /** True when this run can be line-aligned (a multi-line paragraph). */ + static canAlign(run: TextRun): boolean { + return run.paragraphLineSlots.length >= 2; + } + + private lineExtent( + run: TextRun, + i: number, + ): { left: number; right: number } | null { + const slot = run.paragraphLineSlots[i]; + if (!slot || slot.mergedFromBounds.length === 0) return null; + let left = Infinity; + let right = -Infinity; + for (const b of slot.mergedFromBounds) { + if (b.x < left) left = b.x; + if (b.right > right) right = b.right; + } + return Number.isFinite(left) && Number.isFinite(right) + ? { left, right } + : null; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run || !AlignParagraphLinesCommand.canAlign(run)) return; + + // Paragraph-wide left/right edge across every line. + const extents = run.paragraphLineSlots.map((_, i) => + this.lineExtent(run, i), + ); + let paraLeft = Infinity; + let paraRight = -Infinity; + for (const e of extents) { + if (!e) continue; + if (e.left < paraLeft) paraLeft = e.left; + if (e.right > paraRight) paraRight = e.right; + } + if (!Number.isFinite(paraLeft) || !Number.isFinite(paraRight)) return; + const paraCentre = (paraLeft + paraRight) / 2; + + const m = doc.module; + this.appliedDx = run.paragraphLineSlots.map((_, i) => { + const e = extents[i]; + if (!e) return 0; + const dx = + this.mode === "left" + ? paraLeft - e.left + : this.mode === "right" + ? paraRight - e.right + : paraCentre - (e.left + e.right) / 2; + return Math.abs(dx) < 0.01 ? 0 : dx; + }); + + let moved = false; + run.paragraphLineSlots.forEach((_slot, i) => { + const dx = this.appliedDx[i]; + if (!dx) return; + this.shiftLine(m, run, i, dx); + moved = true; + }); + if (!moved) { + this.appliedDx = []; + return; + } + this.refreshBounds(run); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (this.appliedDx.length === 0) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + const m = doc.module; + run.paragraphLineSlots.forEach((_slot, i) => { + const dx = this.appliedDx[i]; + if (!dx) return; + this.shiftLine(m, run, i, -dx); + }); + this.refreshBounds(run); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + this.appliedDx = []; + } + + /** Translate one line's glyph objects + its model bounds by dx. */ + private shiftLine( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + run: TextRun, + i: number, + dx: number, + ): void { + const slot = run.paragraphLineSlots[i]; + if (!slot) return; + const seen = new Set(); + for (const ptr of slot.mergedFromPtrs) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + try { + transformObject(m, ptr, 1, 0, 0, 1, dx, 0); + } catch { + /* best-effort */ + } + } + slot.matrixE += dx; + slot.mergedFromBounds = slot.mergedFromBounds.map((b) => ({ + x: b.x + dx, + right: b.right + dx, + })); + } + + /** Recompute the paragraph rep's horizontal bounds from its lines. */ + private refreshBounds(run: TextRun): void { + let left = Infinity; + let right = -Infinity; + for (let i = 0; i < run.paragraphLineSlots.length; i++) { + const e = this.lineExtent(run, i); + if (!e) continue; + if (e.left < left) left = e.left; + if (e.right > right) right = e.right; + } + if (Number.isFinite(left) && Number.isFinite(right)) { + run.bounds = { ...run.bounds, x: left, width: Math.max(0, right - left) }; + } + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/ChangeZOrderCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/ChangeZOrderCommand.ts new file mode 100644 index 0000000000..52ea1f39b3 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/ChangeZOrderCommand.ts @@ -0,0 +1,140 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { collectMemberPtrs } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +export type ZOrderMode = + | "to-front" // top of stack (rendered last, on top of everything) + | "to-back" // bottom of stack (rendered first, underneath everything) + | "forward" // swap with the object directly above it + | "backward"; // swap with the object directly below it + +interface InsertAtModule { + FPDFPage_InsertObjectAtIndex?: ( + page: number, + obj: number, + idx: number, + ) => boolean; +} + +/** One warning per session, not one per apply() - a drag can fire dozens. */ +let warnedMissingInsertAt = false; + +/** Re-order a text run or image within its page's content-stream stack. */ +export class ChangeZOrderCommand implements Command { + readonly type = "change-z-order"; + private readonly pageIndex: number; + private readonly runId: string | null; + private readonly imageId: string | null; + private readonly mode: ZOrderMode; + /** Member ptrs at their pre-apply indices, ascending. */ + private memberPrev: Array<{ ptr: number; idx: number }>; + + constructor(opts: { + pageIndex: number; + runId?: string; + imageId?: string; + mode: ZOrderMode; + }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId ?? null; + this.imageId = opts.imageId ?? null; + this.mode = opts.mode; + this.memberPrev = []; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const m = doc.module; + const ext = m as unknown as InsertAtModule; + if (!ext.FPDFPage_InsertObjectAtIndex) { + if (typeof console !== "undefined" && !warnedMissingInsertAt) { + warnedMissingInsertAt = true; + console.warn( + "[z-order] FPDFPage_InsertObjectAtIndex unavailable - ChangeZOrderCommand is a no-op for this PDFium build", + ); + } + return; + } + const ptrs = this.resolveMemberPtrs(page); + if (ptrs.size === 0) return; + const total = m.FPDFPage_CountObjects(page.pagePtr); + // Locate every member at page level, ascending by index. Members + // nested inside form XObjects don't appear here (known limitation). + const located: Array<{ ptr: number; idx: number }> = []; + for (let i = 0; i < total; i++) { + const o = m.FPDFPage_GetObject(page.pagePtr, i); + if (ptrs.has(o)) located.push({ ptr: o, idx: i }); + } + if (located.length === 0 || located.length === total) return; + const k = located.length; + const bottomIdx = located[0].idx; + const topIdx = located[k - 1].idx; + // The group is only "already in place" when it is contiguous AND at the + // target edge. + const contiguous = topIdx - bottomIdx === k - 1; + let insertAt: number; + switch (this.mode) { + case "to-front": + if (contiguous && topIdx === total - 1) return; // already at front + insertAt = total - k; + break; + case "to-back": + if (contiguous && bottomIdx === 0) return; // already at back + insertAt = 0; + break; + case "forward": + // Land just above the object that sat directly above the group's top. + if (topIdx >= total - 1) return; + insertAt = topIdx + 2 - k; + break; + case "backward": + // Land just below the object that sat directly below the group's bottom. + if (bottomIdx <= 0) return; + insertAt = bottomIdx - 1; + break; + } + this.memberPrev = located; + for (const { ptr } of located) { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } + located.forEach(({ ptr }, j) => { + ext.FPDFPage_InsertObjectAtIndex!(page.pagePtr, ptr, insertAt + j); + }); + // markDirty bumps the revision so PageView re-renders the bitmap. + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (this.memberPrev.length === 0) return; + const page = doc.page(this.pageIndex); + const m = doc.module; + const ext = m as unknown as InsertAtModule; + if (!ext.FPDFPage_InsertObjectAtIndex) return; + for (const { ptr } of this.memberPrev) { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } + // Re-inserting in ascending original index order reconstructs the + // exact pre-apply list. + for (const { ptr, idx } of this.memberPrev) { + ext.FPDFPage_InsertObjectAtIndex(page.pagePtr, ptr, idx); + } + page.markDirty(); + page.markNeedsGenerate(); + } + + private resolveMemberPtrs( + page: import("@app/tools/pdfTextEditor/model/Page").Page, + ): Set { + if (this.runId) { + const run = page.runs.find((r) => r.id === this.runId); + if (!run) return new Set(); + return new Set(collectMemberPtrs(run).filter((p) => p !== 0)); + } + if (this.imageId) { + const img = page.images.find((i) => i.id === this.imageId); + return img?.pdfiumObjPtr ? new Set([img.pdfiumObjPtr]) : new Set(); + } + return new Set(); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/Command.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/Command.ts new file mode 100644 index 0000000000..ee05506068 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/Command.ts @@ -0,0 +1,18 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +// Every user-initiated mutation goes through a Command so it can be recorded, +// replayed, and reverted by the HistoryStack. +export interface Command { + /** Stable identifier for telemetry / debugging. */ + readonly type: string; + apply(doc: EditorDocument): void; + revert(doc: EditorDocument): void; + // Optional - some commands describe themselves for the UI (e.g. "Type in run + // 'A1'", shown in undo history tooltips). + describe?(): string; + /** Optional coalescing key. Return null / undefined to never coalesce. */ + coalesceKey?(): string | null; + // Optional - when true, a matching `coalesceKey` merges this command into the + // previous undo step however long ago that step ran. + coalesceIgnoresTimeWindow?(previous: Command | null): boolean; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/CompositeCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/CompositeCommand.ts new file mode 100644 index 0000000000..8433c689f8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/CompositeCommand.ts @@ -0,0 +1,40 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +/** Groups several already-applied commands into one undo/redo step. */ +export class CompositeCommand implements Command { + readonly type = "composite"; + private readonly commands: Command[]; + + constructor(commands: Command[]) { + this.commands = commands; + } + + /** Append another already-applied command to this group. */ + push(cmd: Command): void { + this.commands.push(cmd); + } + + /** The most recent child - used to derive the group's coalesce key. */ + get last(): Command { + return this.commands[this.commands.length - 1]; + } + + apply(doc: EditorDocument): void { + for (const cmd of this.commands) cmd.apply(doc); + } + + revert(doc: EditorDocument): void { + for (let i = this.commands.length - 1; i >= 0; i--) { + this.commands[i].revert(doc); + } + } + + coalesceKey(): string | null { + return this.last.coalesceKey?.() ?? null; + } + + describe(): string { + return this.last.describe?.() ?? "Edit"; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteImageCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteImageCommand.ts new file mode 100644 index 0000000000..c860d53d90 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteImageCommand.ts @@ -0,0 +1,112 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import type { ImageObjectSnapshot } from "@app/tools/pdfTextEditor/types"; + +/** Remove an image object from a page. */ +export class DeleteImageCommand implements Command { + readonly type = "delete-image"; + private readonly pageIndex: number; + private readonly imageId: string; + private snapshot: ImageObjectSnapshot | null; + private cachedObjPtr: number; + /** Index in the page's object list at the moment of deletion. */ + private originalIndex: number; + + constructor(opts: { pageIndex: number; imageId: string }) { + this.pageIndex = opts.pageIndex; + this.imageId = opts.imageId; + this.snapshot = null; + this.cachedObjPtr = 0; + this.originalIndex = -1; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img) return; + if (this.snapshot === null) { + this.snapshot = img.snapshot(); + this.cachedObjPtr = img.pdfiumObjPtr; + // Record the original index so revert can re-insert in place. + const total = doc.module.FPDFPage_CountObjects(page.pagePtr); + let foundIdx = -1; + for (let i = 0; i < total; i++) { + if ( + doc.module.FPDFPage_GetObject(page.pagePtr, i) === img.pdfiumObjPtr + ) { + foundIdx = i; + break; + } + } + this.originalIndex = foundIdx; + } + if (img.pdfiumObjPtr) { + doc.module.FPDFPage_RemoveObject(page.pagePtr, img.pdfiumObjPtr); + } + page.setImages(page.images.filter((i) => i.id !== img.id)); + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (!this.snapshot || !this.cachedObjPtr) return; + const page = doc.page(this.pageIndex); + const m = doc.module as unknown as { + FPDFPage_InsertObjectAtIndex?: ( + page: number, + obj: number, + index: number, + ) => boolean; + FPDFPage_InsertObject: (page: number, obj: number) => void; + }; + const insertAt = m.FPDFPage_InsertObjectAtIndex; + let inserted = false; + if (typeof insertAt === "function" && this.originalIndex >= 0) { + try { + inserted = insertAt.call( + m, + page.pagePtr, + this.cachedObjPtr, + this.originalIndex, + ); + } catch { + inserted = false; + } + } + if (!inserted) { + // Fallback: re-insert at end. + doc.module.FPDFPage_InsertObject(page.pagePtr, this.cachedObjPtr); + if (this.originalIndex >= 0) { + const total = doc.module.FPDFPage_CountObjects(page.pagePtr); + const lastIdx = total - 1; + // Step the newly-inserted object down by removing+reinserting the + // objects that should be ABOVE it. + for (let i = this.originalIndex; i < lastIdx; i++) { + const ptr = doc.module.FPDFPage_GetObject( + page.pagePtr, + this.originalIndex, + ); + if (!ptr || ptr === this.cachedObjPtr) break; + doc.module.FPDFPage_RemoveObject(page.pagePtr, ptr); + doc.module.FPDFPage_InsertObject(page.pagePtr, ptr); + } + } + } + const restored = new ImageObject({ + ...this.snapshot, + pdfiumObjPtr: this.cachedObjPtr, + }); + // Insert back into the images array at the original position when + // we know it, so any UI ordering matches the visual stacking. + const images = [...page.images]; + if (this.originalIndex >= 0 && this.originalIndex <= images.length) { + images.splice(this.originalIndex, 0, restored); + } else { + images.push(restored); + } + page.setImages(images); + page.markDirty(); + page.markNeedsGenerate(); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteObjectCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteObjectCommand.ts new file mode 100644 index 0000000000..bf4526bf9b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteObjectCommand.ts @@ -0,0 +1,94 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { TextRunSnapshot } from "@app/tools/pdfTextEditor/types"; +import type { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { + collectContainersByPtr, + collectMemberPtrs, + removeMemberPtrs, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +/** Remove a run from the page model and from PDFium. */ +interface CapturedPtr { + ptr: number; + containerPtr: number; +} + +export class DeleteObjectCommand implements Command { + readonly type = "delete-object"; + private readonly pageIndex: number; + private readonly runId: string; + private snapshot: TextRunSnapshot | null; + /** Every sub-object pointer + its container at apply time. */ + private cachedPtrs: CapturedPtr[]; + /** The live run instance, re-attached on revert to keep all fields intact. */ + private removedRun: TextRun | null = null; + + constructor(opts: { pageIndex: number; runId: string }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.snapshot = null; + this.cachedPtrs = []; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + if (this.snapshot === null) { + this.snapshot = run.snapshot(); + this.removedRun = run; + const memberPtrs = collectMemberPtrs(run); + const containerByPtr = collectContainersByPtr(run); + const seen = new Set(); + this.cachedPtrs = []; + for (const ptr of memberPtrs) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + this.cachedPtrs.push({ + ptr, + containerPtr: containerByPtr.get(ptr) ?? run.containerPtr, + }); + } + } + removeMemberPtrs( + doc.module, + page, + this.cachedPtrs.map((c) => c.ptr), + new Map(this.cachedPtrs.map((c) => [c.ptr, c.containerPtr])), + run.containerPtr, + ); + page.setRuns(page.runs.filter((r) => r.id !== run.id)); + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (!this.removedRun || this.cachedPtrs.length === 0) return; + const page = doc.page(this.pageIndex); + const m = doc.module; + const formMod = m as unknown as { + FPDFFormObj_InsertObject?: (form: number, obj: number) => boolean; + }; + // Re-insert every captured sub-object. + for (const { ptr, containerPtr } of this.cachedPtrs) { + if (!ptr) continue; + try { + if (containerPtr && formMod.FPDFFormObj_InsertObject) { + formMod.FPDFFormObj_InsertObject(containerPtr, ptr); + } else { + m.FPDFPage_InsertObject(page.pagePtr, ptr); + } + } catch { + /* best-effort */ + } + } + // Re-attach the live instance so every field (mergedFrom*, paragraph*, + // coverRectPtr, containerPtr) is restored exactly as before delete. + if (!page.findRun(this.removedRun.id)) { + page.setRuns([...page.runs, this.removedRun]); + } + page.markDirty(); + page.markNeedsGenerate(); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/DuplicateRunCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/DuplicateRunCommand.ts new file mode 100644 index 0000000000..c0486745f2 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/DuplicateRunCommand.ts @@ -0,0 +1,100 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { writeUtf16 } from "@app/services/pdfiumService"; +import { + fallbackFamilyFor, + fallbackFontIdFor, +} from "@app/tools/pdfTextEditor/util/fontCapability"; +import { sanitizeForBase14 } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +// Clone a text run at a fixed offset (default 12pt right + 12pt down) so the +// user can quickly stamp the same text elsewhere on the page. +const OFFSET = 12; + +export class DuplicateRunCommand implements Command { + readonly type = "duplicate-run"; + private readonly pageIndex: number; + private readonly runId: string; + private createdRunId: string | null; + private createdObjPtr: number; + + constructor(opts: { pageIndex: number; runId: string }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.createdRunId = null; + this.createdObjPtr = 0; + } + + get insertedRunId(): string | null { + return this.createdRunId; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const src = page.findRun(this.runId); + if (!src) return; + const m = doc.module; + const fallback = fallbackFamilyFor(src.fontId); + const newPtr = m.FPDFPageObj_NewTextObj( + doc.docPtr, + fallback, + Math.max(4, src.fontSize), + ); + if (!newPtr) return; + // Base-14 (WinAnsi) can't render >U+00FF; sanitize so non-Latin code + // points are dropped rather than persisted as U+00FF ydieresis tofu. + const textPtr = writeUtf16( + m, + sanitizeForBase14(src.text.replace(/\r?\n/g, " ")), + ); + try { + m.FPDFText_SetText(newPtr, textPtr); + } finally { + m.pdfium.wasmExports.free(textPtr); + } + m.FPDFPageObj_SetFillColor( + newPtr, + src.fill.r, + src.fill.g, + src.fill.b, + src.fill.a, + ); + const newX = src.matrix.e + OFFSET; + const newY = src.matrix.f - OFFSET; + m.FPDFPageObj_Transform(newPtr, 1, 0, 0, 1, newX, newY); + m.FPDFPage_InsertObject(page.pagePtr, newPtr); + const id = `p${page.index}-dup-${page.runs.length}-${newPtr}`; + const clone = new TextRun({ + id, + pageIndex: page.index, + pdfiumObjPtr: newPtr, + bounds: { + x: newX, + y: newY, + width: src.bounds.width, + height: src.bounds.height, + }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: newX, f: newY }, + text: src.text, + fontId: fallbackFontIdFor(fallback), + fontSize: src.fontSize, + fill: { ...src.fill }, + fontSubset: false, + }); + page.setRuns([...page.runs, clone]); + page.markDirty(); + page.markNeedsGenerate(); + this.createdRunId = id; + this.createdObjPtr = newPtr; + } + + revert(doc: EditorDocument): void { + if (!this.createdObjPtr || !this.createdRunId) return; + const page = doc.page(this.pageIndex); + doc.module.FPDFPage_RemoveObject(page.pagePtr, this.createdObjPtr); + page.setRuns(page.runs.filter((r) => r.id !== this.createdRunId)); + page.markDirty(); + page.markNeedsGenerate(); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/EditTextCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/EditTextCommand.ts new file mode 100644 index 0000000000..1033176dad --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/EditTextCommand.ts @@ -0,0 +1,1698 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { PdfiumTextWriter } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextWriter"; +import { sampleBackground } from "@app/tools/pdfTextEditor/pdfium/BackgroundSampler"; +import { + charcodesResolveFully, + collectContainersByPtr, + collectMemberPtrs, + emitFillRect, + emitTextLine, + everyCharIn, + inkFromRun, + measureObjSpanPt, + removeMemberPtrs, + rotationFromMatrix, + warmOnPageAdvances, + planLineOrigins, + emitRunLines, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { + bestFontPtrForText, + applyParagraphEditPlan, + applyPartialEditPlan, + planModifiesWhitespace, + planParagraphEdit, + planPartialEdit, + setObjText, + type ParagraphEditPlan, + type PartialEditPlan, +} from "@app/tools/pdfTextEditor/commands/partialEdit"; +import { + fallbackFamilyFor, + fallbackFontIdFor, +} from "@app/tools/pdfTextEditor/util/fontCapability"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { + ParagraphLineSlot, + TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; +import { transformObject } from "@app/tools/pdfTextEditor/util/objectTransform"; + +interface RevertLine { + text: string; + x: number; + y: number; + fill: { r: number; g: number; b: number; a: number }; + fontSize: number; + /** Source run's letter-spacing so an undo re-emit keeps the tracking. */ + charSpacingPt: number; +} + +/** One rebuilt line for {@link EditTextCommand.rebuildAsOverlayModel}. */ +interface RebuildLine { + baselineY: number; + fontSize: number; + subRuns: Array<{ ptr: number; text: string; x: number; removed: boolean }>; +} + +/** Snapshot of a run's paragraph model for the line-edit revert. */ +interface RunModelSnapshot { + text: string; + matrixE: number; + matrixF: number; + bounds: { x: number; y: number; width: number; height: number }; + paragraphLineHeight: number; + paragraphMemberPtrs: number[]; + paragraphMemberContainers: number[]; + paragraphMemberFs: number[]; + paragraphLeafPtrs: number[]; + paragraphLeafContainers: number[]; + paragraphLineSlots: ParagraphLineSlot[]; + mergedFromPtrs: number[]; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + mergedFromCharStarts: number[]; + fontId: string; + fontSubset: boolean; + pdfiumObjPtr: number; +} + +// True when a partial-edit plan only ADDED objects (no original object was +// freed via removePtrs, none mutated in place via a "modify" op). +function planIsPureInsert(plan: PartialEditPlan): boolean { + return ( + plan.removePtrs.length === 0 && plan.ops.every((op) => op.type !== "modify") + ); +} + +/** Edit a text run. */ +export class EditTextCommand implements Command { + readonly type = "edit-text"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly nextText: string; + private prevText: string | null = null; + + private overlaid = false; + private prevObjPtr = 0; + private prevFontId: string | null = null; + /** + * The original object's PDFium font handle, captured before the overlay + * replaces it. Font handles are document-level and outlive the object, so + * the revert can re-emit in the run's OWN face instead of a base-14 + * lookalike. + */ + private prevFontPtr = 0; + private coverRectPtr = 0; + private createdPtrs: number[] = []; + private newTextPtr = 0; + private revertLines: RevertLine[] = []; + /** Rotation of the run when apply() snapshotted it; re-applied on revert. */ + private revertRotation: { cos: number; sin: number } | null = null; + /** Set when the apply path took the partial-edit (LCS) shortcut. */ + private partialPlan: PartialEditPlan | null = null; + private partialInsertedPtrs: number[] = []; + private prevMergedFromPtrs: number[] = []; + private prevMergedFromTexts: string[] = []; + private prevMergedFromBounds: Array<{ x: number; right: number }> = []; + /** Set when the apply path took the paragraph-aware partial shortcut. */ + private paragraphPlan: ParagraphEditPlan | null = null; + private paragraphInsertedPtrs: number[] = []; + private prevParagraphSlots: ParagraphLineSlot[] = []; + // Full pre-edit model snapshot, captured by the partial / paragraph-partial + // apply paths. + private editSnapshot: RunModelSnapshot | null = null; + /** Set when the apply path took the paragraph line add/remove shortcut. */ + private lineEdit: { + /** Matched lines translated to a new baseline (reversed on revert). */ + moves: Array<{ ptr: number; dy: number }>; + /** Fresh objects emitted for new/changed lines (removed on revert). */ + createdPtrs: number[]; + /** Deleted lines, re-emitted as fallback on revert. */ + removed: Array<{ text: string; x: number; y: number; fontSize: number }>; + prev: RunModelSnapshot; + } | null = null; + + constructor(opts: { pageIndex: number; runId: string; nextText: string }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.nextText = opts.nextText; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + if (this.prevText === null) this.prevText = run.text; + // No-op edit: a contentEditable insert can fire several `input` events for + // one keystroke burst, re-dispatching the SAME final text. + if (this.prevText === this.nextText) return; + + const alreadyBase14 = /^base14:/.test(run.fontId); + // A run rotated within the page can't use the surgical partial/paragraph + // paths - those assume horizontal layout. + const isRotated = !!rotationFromMatrix(run.matrix); + + // PARAGRAPH-AWARE PARTIAL PATH: paragraphs (multi-line runs) keep per-line + // sub-run data in `paragraphLineSlots`. + if ( + this.partialPlan === null && + this.paragraphPlan === null && + run.paragraphLineSlots.length > 1 && + !isRotated + ) { + const paraPlan = planParagraphEdit( + run, + this.prevText ?? "", + this.nextText, + ); + if (paraPlan) { + this.paragraphPlan = paraPlan; + this.prevParagraphSlots = paraPlan.prevSlots; + this.editSnapshot = snapshotRunModel(run); + const result = applyParagraphEditPlan(doc, page, run, paraPlan); + this.paragraphInsertedPtrs = result.insertedPtrs; + run.paragraphLineSlots = result.newSlots; + run.bounds = { + ...run.bounds, + x: result.newBoundsX, + width: clampWidthToPage( + result.newBoundsX, + result.newBoundsWidth, + page, + ), + }; + // Keep mergedFrom* synchronized with slot[0] so a later + // single-line partial edit on the rep continues to work. + const firstSlot = result.newSlots[0]; + run.mergedFromPtrs = [...firstSlot.mergedFromPtrs]; + run.mergedFromTexts = [...firstSlot.mergedFromTexts]; + run.mergedFromBounds = firstSlot.mergedFromBounds.map((b) => ({ + ...b, + })); + run.mergedFromCharStarts = [...firstSlot.mergedFromCharStarts]; + if (firstSlot.mergedFromPtrs.length > 0) { + run.pdfiumObjPtr = firstSlot.mergedFromPtrs[0]; + } + run.text = this.nextText; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + } + + // PARAGRAPH LINE ADD/REMOVE PATH. + if ( + this.partialPlan === null && + this.paragraphPlan === null && + this.lineEdit === null && + this.prevText !== null && + this.prevText.length > 0 && + run.paragraphLineSlots.length >= 1 && + !isRotated + ) { + const prevLines = this.prevText.split(/\r?\n/); + const nextLines = this.nextText.split(/\r?\n/); + if (prevLines.length !== nextLines.length) { + if (run.paragraphLineSlots.length === prevLines.length) { + // Slots map 1:1 to lines (a grow-mode paragraph) - diff per line. + this.applyParagraphLineEdit(doc, page, run, prevLines, nextLines); + run.text = this.nextText; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + if ( + this.nextText.startsWith(this.prevText) && + /^\r?\n/.test(this.nextText.slice(this.prevText.length)) + ) { + // Soft-wrapped paragraph: can't diff per line. + this.applyParagraphAppend(doc, page, run); + run.text = this.nextText; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + } + } + + // SURGICAL DIFF PATH (single-line). + if ( + this.partialPlan === null && + run.mergedFromPtrs.length > 0 && + run.paragraphLineSlots.length < 2 && + !/\r?\n/.test(this.nextText) && + !isRotated + ) { + const partial = planPartialEdit(run, this.prevText ?? "", this.nextText); + // An in-place "modify" op that re-SetTexts whitespace paints „ on an + // embedded subset font with no space glyph. + if (partial && !planModifiesWhitespace(partial)) { + this.partialPlan = partial; + this.prevMergedFromPtrs = [...run.mergedFromPtrs]; + this.prevMergedFromTexts = [...run.mergedFromTexts]; + this.prevMergedFromBounds = run.mergedFromBounds.map((b) => ({ ...b })); + this.editSnapshot = snapshotRunModel(run); + const result = applyPartialEditPlan(doc, page, run, partial); + this.partialInsertedPtrs = result.insertedPtrs; + run.mergedFromPtrs = result.newMergedFromPtrs; + run.mergedFromTexts = result.newMergedFromTexts; + run.mergedFromBounds = result.newMergedFromBounds; + run.mergedFromCharStarts = result.newMergedFromCharStarts; + run.bounds = { + ...run.bounds, + x: result.newBoundsX, + width: clampWidthToPage( + result.newBoundsX, + result.newBoundsWidth, + page, + ), + }; + if (result.newMergedFromPtrs.length > 0) { + run.pdfiumObjPtr = result.newMergedFromPtrs[0]; + } + run.text = this.nextText; + run.dirty = true; + page.markDirty(); + return; + } + } + + // Force overlay whenever the in-place SetText path can't keep every PDFium + // object up to date: - paragraphs or newline-containing text. + const needsMultiObjectEmit = + run.paragraphMemberPtrs.length > 1 || + run.paragraphLeafPtrs.length > 1 || + /\r?\n/.test(this.nextText) || + /\s\s/.test(this.nextText); + const needsOverlay = + needsMultiObjectEmit || + (!this.overlaid && + !alreadyBase14 && + (run.mergedFromPtrs.length > 0 || + run.fontSubset || + run.pdfiumObjPtr !== 0)); + + if (!needsOverlay) { + const restoreText = run.text; + const restoreBounds = run.bounds; + run.text = this.nextText; + run.dirty = true; + page.markDirty(); + if (PdfiumTextWriter.commitRunText(doc, page, run)) return; + // The object's font could not encode the new text - `run.fontId` said + // base-14 but `pdfiumObjPtr` still pointed at the original (Type 3 / + // symbolic subset) object, so SetText wrote filler charcodes. Undo and + // take the overlay path, which resolves charcodes and validates the emit. + run.text = restoreText; + run.bounds = restoreBounds; + } + + this.overlaid = true; + this.prevObjPtr = run.pdfiumObjPtr; + if (this.prevFontId === null) this.prevFontId = run.fontId; + if (this.prevFontPtr === 0 && run.containerPtr === 0 && run.pdfiumObjPtr) { + this.prevFontPtr = safeGetFont(doc.module, run.pdfiumObjPtr); + } + const fallbackFamily = fallbackFamilyFor(this.prevFontId); + const m = doc.module; + + const bg = sampleBackground(m, page, run.bounds); + // \r/\n are split into separate output lines, so they must NOT gate font + // reuse. + const safeChars = everyCharIn( + this.nextText.replace(/[\r\n]/g, ""), + this.prevText ?? "", + ); + // Reusing the source font handle works when every nextText char already + // appears in prevText, which guarantees a glyph. That proxy is strict: it + // threw away a fully embedded face the moment a NEW letter was typed. So + // also accept the case where the charcodes provably resolve for the whole + // string, which is exactly what the emit path needs to succeed. + const candidateFontPtr = run.pdfiumObjPtr + ? safeGetFont(m, run.pdfiumObjPtr) + : 0; + const canReuseFont = + run.containerPtr === 0 && + (safeChars || + charcodesResolveFully( + m, + candidateFontPtr, + this.nextText.replace(/[\r\n]/g, ""), + page.pagePtr, + doc.docPtr, + )); + // Borrow the font of the member sharing the most chars with the new text. + const borrowPtrs = collectMemberPtrs(run); + const borrowTexts = + run.mergedFromTexts.length === borrowPtrs.length + ? run.mergedFromTexts + : borrowPtrs.map(() => run.text); + const originalFontPtr = canReuseFont + ? bestFontPtrForText(m, borrowPtrs, borrowTexts, this.nextText) || + (run.pdfiumObjPtr ? safeGetFont(m, run.pdfiumObjPtr) : 0) + : 0; + + this.revertLines = snapshotRevertLines(run, this.prevText ?? ""); + this.revertRotation = rotationFromMatrix(run.matrix) ?? null; + + // Detach any cover rect that a PRIOR overlay edit left on the page. + if (run.coverRectPtr) { + try { + m.FPDFPage_RemoveObject(page.pagePtr, run.coverRectPtr); + } catch { + /* best-effort */ + } + run.coverRectPtr = 0; + } + + // Measure the page's glyph advances BEFORE the source objects go away: + // for a Type 3 face this is the only place a real advance can come from. + warmOnPageAdvances(m, page.pagePtr); + + const memberPtrs = collectMemberPtrs(run); + const containers = collectContainersByPtr(run); + const allRemoved = removeMemberPtrs( + m, + page, + memberPtrs, + containers, + run.containerPtr, + ); + + // Only stamp a cover rect when the sampler is CONFIDENT it found a uniform + // background colour. + if (!allRemoved && bg.confident) { + this.coverRectPtr = emitFillRect(m, page, run.bounds, bg.fill); + if (this.coverRectPtr) { + this.createdPtrs.push(this.coverRectPtr); + run.coverRectPtr = this.coverRectPtr; + } + } + + const outputLines = this.nextText.split(/\r?\n/); + const lineHeight = + run.paragraphLineHeight > 0 + ? run.paragraphLineHeight + : run.fontSize * 1.2; + // One "line anchor" ptr per output line; plus any extra per-word ptrs from + // space preservation, kept for leaf removal on subsequent edits. + const lineAnchorPtrs: number[] = []; + const lineAnchorYs: number[] = []; + const allEmittedPtrs: number[] = []; + // Per-line emit metadata used to rebuild paragraphLineSlots so the NEXT + // edit can route back through paragraph-aware partial-edit instead of. + const perLineEmits: Array<{ + ptrs: number[]; + texts: string[]; + text: string; + x: number; + y: number; + }> = []; + const emitted = emitRunLines({ + doc, + page, + run, + lines: outputLines, + origins: planLineOrigins(run, outputLines.length, lineHeight), + originalFontPtr, + originalFontSubset: run.fontSubset, + fallbackFamily, + }); + for (const line of emitted) { + // Empty lines keep a placeholder slot; a FAILED emit is dropped entirely. + if (line.text.length === 0) { + perLineEmits.push({ + ptrs: [], + texts: [], + text: "", + x: line.x, + y: line.y, + }); + continue; + } + if (line.ptrs.length === 0) { + // A line whose emit produced nothing still owns its character range. + // Skipping it shifts every later slot onto the wrong line of run.text. + perLineEmits.push({ + ptrs: [], + texts: [], + text: line.text, + x: line.x, + y: line.y, + }); + continue; + } + this.createdPtrs.push(...line.ptrs); + allEmittedPtrs.push(...line.ptrs); + lineAnchorPtrs.push(line.ptrs[0]); + lineAnchorYs.push(line.y); + perLineEmits.push({ + ptrs: line.ptrs, + texts: line.texts, + text: line.text, + x: line.x, + y: line.y, + }); + } + + if (lineAnchorPtrs.length > 0) { + this.newTextPtr = lineAnchorPtrs[0]; + run.pdfiumObjPtr = lineAnchorPtrs[0]; + if (originalFontPtr === 0) { + run.fontId = fallbackFontIdFor(fallbackFamily); + run.fontSubset = false; + } else { + // Borrow path: the new objects use the borrowed font handle. + run.fontSubset = false; + } + run.paragraphMemberPtrs = lineAnchorPtrs; + run.paragraphMemberContainers = lineAnchorPtrs.map(() => 0); + run.paragraphMemberFs = [...lineAnchorYs]; + // Every per-word emit becomes a leaf - so the next edit's removal + // pass cleans them up alongside the anchors. + run.paragraphLeafPtrs = allEmittedPtrs; + run.paragraphLeafContainers = allEmittedPtrs.map(() => 0); + if (perLineEmits.length > 1) { + // Remember the line height so paragraph-partial / future overlay + // emits land at the same baselines we just established. + run.paragraphLineHeight = lineHeight; + } + } + + run.mergedFromPtrs = []; + // Clear the parallel arrays too: planPartialEdit bails on length mismatch. + run.mergedFromTexts = []; + run.mergedFromBounds = []; + run.mergedFromCharStarts = []; + // Rebuild paragraphLineSlots from the fresh emit so the next edit on this + // paragraph can re-engage the font-preserving partial path. + if (perLineEmits.length > 1) { + run.paragraphLineSlots = buildSlotsFromOverlayEmit( + m, + run, + perLineEmits, + originalFontPtr === 0 ? fallbackFontIdFor(fallbackFamily) : run.fontId, + ); + } else { + // Single-line emit. + run.paragraphLineSlots = []; + } + // Don't reset paragraphLeafPtrs here - we just set them above to the + // freshly-emitted chunks so the next overlay edit can remove them. + // The emit replaced every object this run owns, so the old bounds can + // describe geometry that is gone - a box narrower than its own glyphs + // leaves the overlay unusable over correctly drawn text. Only ever GROW it + // here: trailing whitespace legitimately extends a box past its ink, and + // shrinking to the ink would erase that. + const span = measureObjSpanPt(m, allEmittedPtrs); + if (span) { + const left = Math.min(run.bounds.x, span.left); + const right = Math.max(run.bounds.x + run.bounds.width, span.right); + run.bounds = { ...run.bounds, x: left, width: Math.max(0, right - left) }; + } + run.text = this.nextText; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + // Exactly one revert strategy member may be set per apply. Enforced only by + // guard ordering, so fail fast in dev if two paths ran or a member leaked. + private assertSingleRevertPath(): void { + const set = + (this.lineEdit !== null ? 1 : 0) + + (this.paragraphPlan !== null ? 1 : 0) + + (this.partialPlan !== null ? 1 : 0) + + (this.overlaid ? 1 : 0); + if (set > 1) { + console.error( + `EditTextCommand revert: ${set} strategy members set, expected <=1`, + ); + } + } + + revert(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run || this.prevText === null) return; + this.assertSingleRevertPath(); + const m = doc.module; + + // Paragraph line add/remove revert: move matched lines back to their + // original baselines, drop the freshly-emitted new/changed lines. + if (this.lineEdit) { + for (let i = this.lineEdit.moves.length - 1; i >= 0; i--) { + const mv = this.lineEdit.moves[i]; + try { + transformObject(m, mv.ptr, 1, 0, 0, 1, 0, -mv.dy); + } catch { + /* best-effort */ + } + } + for (const ptr of this.lineEdit.createdPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + restoreRunModel(run, this.lineEdit.prev); + if (this.lineEdit.removed.length > 0) { + const fallbackFamily = fallbackFamilyFor(this.prevFontId ?? run.fontId); + for (const rem of this.lineEdit.removed) { + const ptrs = emitTextLine({ + doc, + page, + text: rem.text, + x: rem.x, + y: rem.y, + fontSize: rem.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: 0, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + patchSlotPtrsByBaseline(m, run, rem.y, ptrs, rem.text); + } + reflattenLeafArrays(run); + } + run.text = this.prevText; + run.dirty = true; + this.lineEdit = null; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + + // Paragraph-aware partial revert: remove every per-slot insert ptr, re-emit + // fallback chunks at each removed sub-run's original spot. + if (this.paragraphPlan) { + // Remove the chunks the forward apply inserted. + for (const ptr of this.paragraphInsertedPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + this.paragraphInsertedPtrs = []; + // Pure-insert edit (no original object freed/mutated): every original + // object is still alive, so restore the exact pre-edit model. + const pureInsert = this.paragraphPlan.perSlot.every( + (e) => e.plan !== null && planIsPureInsert(e.plan), + ); + if (pureInsert && this.editSnapshot) { + restoreRunModel(run, this.editSnapshot); + run.text = this.prevText; + run.dirty = true; + this.paragraphPlan = null; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + const revertFallback = fallbackFamilyFor(this.prevFontId ?? run.fontId); + // Rebuild every line from the pre-edit slots: kept/modified sub-runs keep + // their live original object. + const lines: RebuildLine[] = []; + for (let s = 0; s < this.prevParagraphSlots.length; s++) { + const prevSlot = this.prevParagraphSlots[s]; + const entry = this.paragraphPlan.perSlot.find((e) => e.slotIdx === s); + if (entry && entry.plan) { + for (const op of entry.plan.ops) { + if (op.type === "modify" && op.subRunIdx !== undefined) { + setObjText( + m, + prevSlot.mergedFromPtrs[op.subRunIdx], + prevSlot.mergedFromTexts[op.subRunIdx] ?? "", + ); + } + } + } + // A fresh-emit slot (plan === null) had ALL its original objects + // removed during apply, so re-emit every one of them on revert. + const removed = new Set( + entry + ? entry.plan + ? entry.plan.removePtrs.map((r) => r.ptr) + : prevSlot.mergedFromPtrs + : [], + ); + lines.push({ + baselineY: prevSlot.baselineY, + fontSize: prevSlot.fontSize, + subRuns: prevSlot.mergedFromPtrs.map((ptr, i) => ({ + ptr, + text: prevSlot.mergedFromTexts[i] ?? "", + x: prevSlot.mergedFromBounds[i]?.x ?? prevSlot.matrixE, + removed: removed.has(ptr), + })), + }); + } + this.rebuildAsOverlayModel(doc, page, run, lines, revertFallback); + run.text = this.prevText; + run.dirty = true; + this.paragraphPlan = null; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + + // Partial-edit fast path revert: the removed sub-objects are gone from + // PDFium permanently. + if (this.partialPlan) { + for (const ptr of this.partialInsertedPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + this.partialInsertedPtrs = []; + // In-place "modify" sub-runs kept their object (and font); restore + // their original text so undo shows the pre-edit characters. + for (const op of this.partialPlan.ops) { + if (op.type === "modify" && op.subRunIdx !== undefined) { + setObjText( + m, + this.prevMergedFromPtrs[op.subRunIdx], + this.prevMergedFromTexts[op.subRunIdx] ?? "", + ); + } + } + // No original objects were destroyed: restore the EXACT pre-edit model so + // undo keeps the original embedded fonts AND redo re-engages the. + if (this.partialPlan.removePtrs.length === 0 && this.editSnapshot) { + restoreRunModel(run, this.editSnapshot); + run.text = this.prevText; + run.dirty = true; + this.partialPlan = null; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + const revertFallback = fallbackFamilyFor(this.prevFontId ?? run.fontId); + const removed = new Set(this.partialPlan.removePtrs.map((r) => r.ptr)); + this.rebuildAsOverlayModel( + doc, + page, + run, + [ + { + baselineY: run.matrix.f, + fontSize: run.fontSize, + subRuns: this.prevMergedFromPtrs.map((ptr, i) => ({ + ptr, + text: this.prevMergedFromTexts[i] ?? "", + x: this.prevMergedFromBounds[i]?.x ?? run.matrix.e, + removed: removed.has(ptr), + })), + }, + ], + revertFallback, + ); + run.text = this.prevText; + run.dirty = true; + this.partialPlan = null; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + + if (!this.overlaid) { + run.text = this.prevText; + run.dirty = true; + page.markDirty(); + PdfiumTextWriter.commitRunText(doc, page, run); + return; + } + + for (const ptr of this.createdPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + this.coverRectPtr = 0; + this.newTextPtr = 0; + this.createdPtrs = []; + + // Everything else the run still owns goes too, because the re-emit below + // rebuilds the run whole. + // + // A typed burst coalesces into ONE undo step covering several commands. + // The first revert removes its own createdPtrs and re-emits; the second + // then finds ITS createdPtrs already gone, removes nothing, and re-emits + // again - leaving the first revert's objects orphaned on the page. Two + // characters typed mid-word undid to "Heading in a Qbigger bigger + // sizesize": doubled, overlapping glyphs that read as a changed font. + for (const ptr of run.paragraphLeafPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort - the ptr may already be gone */ + } + } + + // PDFium has no insert-into-form-xobject API, so the truly-original + // pointers (if they lived in a form) are gone forever. + const revertFallback = fallbackFamilyFor(this.prevFontId ?? ""); + const lineAnchorPtrs: number[] = []; + const allRestoredPtrs: number[] = []; + for (const line of this.revertLines) { + const ptrs = emitTextLine({ + doc, + page, + text: line.text, + x: line.x, + y: line.y, + fontSize: line.fontSize, + fill: line.fill, + // The run's own font, not a base-14 stand-in: re-emitting an embedded + // face as Helvetica is what made undo look like it changed the font. + originalFontPtr: this.prevFontPtr, + charSpacingPt: line.charSpacingPt, + fallbackFamily: revertFallback, + // Keep the run's original orientation - without this, undoing an + // edit on a rotated run scattered its text axis-aligned. + rotation: this.revertRotation ?? undefined, + // ...and its ink. applyInkState writes the mode unconditionally, so + // omitting this forced every restored object back to fill: undo on + // invisible OCR text stamped visible glyphs over the scan. + ...inkFromRun(run), + }); + if (ptrs.length === 0) continue; + lineAnchorPtrs.push(ptrs[0]); + allRestoredPtrs.push(...ptrs); + } + + run.pdfiumObjPtr = lineAnchorPtrs[0] ?? this.prevObjPtr; + // Only claim the fallback when we actually emitted in it. + if (this.prevFontPtr === 0) { + run.fontId = fallbackFontIdFor(revertFallback); + run.fontSubset = false; + } else if (this.prevFontId !== null) { + run.fontId = this.prevFontId; + } + run.text = this.prevText; + run.mergedFromPtrs = []; + run.paragraphMemberPtrs = lineAnchorPtrs; + run.paragraphMemberContainers = lineAnchorPtrs.map(() => 0); + run.paragraphMemberFs = this.revertLines.map((l) => l.y); + run.paragraphLeafPtrs = allRestoredPtrs; + run.paragraphLeafContainers = allRestoredPtrs.map(() => 0); + run.containerPtr = 0; + run.dirty = true; + this.overlaid = false; + page.markDirty(); + page.markNeedsGenerate(); + } + + // Apply a paragraph edit that changed the LINE COUNT (Enter typed or a + // newline deleted) where slots map 1:1 to lines. + private applyParagraphLineEdit( + doc: EditorDocument, + page: Page, + run: TextRun, + prevLines: string[], + nextLines: string[], + ): void { + const m = doc.module; + const slots = run.paragraphLineSlots; + const lineHeight = + run.paragraphLineHeight > 0 + ? run.paragraphLineHeight + : run.fontSize * 1.2; + const topBaseline = slots[0]?.baselineY ?? run.matrix.f; + const leftX = slots[0]?.matrixE ?? run.matrix.e; + const fallbackFamily = fallbackFamilyFor(this.prevFontId ?? run.fontId); + // Re-emitted lines keep the run's embedded face. Joining two lines with + // Delete/Backspace only REMOVES characters, so every glyph the joined line + // needs already rendered in this font; emitting at base-14 turned the whole + // line a different typeface. Read before the loop starts mutating. + const memberPtrs = collectMemberPtrs(run); + const memberTexts = + run.mergedFromTexts.length === memberPtrs.length + ? run.mergedFromTexts + : memberPtrs.map(() => run.text); + const reuseFontPtr = + run.containerPtr === 0 + ? bestFontPtrForText(m, memberPtrs, memberTexts, run.text) || + (run.pdfiumObjPtr ? safeGetFont(m, run.pdfiumObjPtr) : 0) + : 0; + const match = lineLCS(prevLines, nextLines); + + this.lineEdit = { + moves: [], + createdPtrs: [], + removed: [], + prev: snapshotRunModel(run), + }; + + const newSlots: ParagraphLineSlot[] = []; + const newLeaf: number[] = []; + const newLeafContainers: number[] = []; + const newMemberPtrs: number[] = []; + const newMemberFs: number[] = []; + const usedPrev = new Set(); + let cursor = 0; + const baselines = keptLeadingBaselines( + nextLines.length, + match, + slots, + topBaseline, + lineHeight, + ); + for (let i = 0; i < nextLines.length; i++) { + const text = nextLines[i]; + const y = baselines[i]; + const prevIdx = match.get(i); + let slot: ParagraphLineSlot; + if (prevIdx !== undefined && slots[prevIdx]) { + // Unchanged line: keep its objects, translate to the new baseline. + usedPrev.add(prevIdx); + const src = slots[prevIdx]; + const dy = y - src.baselineY; + if (Math.abs(dy) > 0.001) { + for (const ptr of src.mergedFromPtrs) { + if (!ptr) continue; + try { + transformObject(m, ptr, 1, 0, 0, 1, 0, dy); + } catch { + /* best-effort - stale ptr */ + } + this.lineEdit.moves.push({ ptr, dy }); + } + } + slot = cloneSlot(src); + slot.baselineY = y; + for (const ptr of src.mergedFromPtrs) { + if (ptr) { + newLeaf.push(ptr); + newLeafContainers.push(src.containerPtr); + } + } + newMemberPtrs.push(src.mergedFromPtrs[0] ?? 0); + newMemberFs.push(y); + } else if (text.length === 0) { + // Seeded from the line this one was split off, so the blank line keeps + // the paragraph's font instead of being stamped base-14 before the + // user has typed a character into it. + slot = emptySlot( + y, + leftX, + run, + fallbackFamily, + newSlots[newSlots.length - 1] ?? slots[0], + ); + newMemberPtrs.push(0); + newMemberFs.push(y); + } else { + // New / changed line: re-emit it reusing the run's embedded face. + // Keep the line's OWN left edge - a table row grouped as a paragraph + // has a different x per line, and slot 0's x drops it into the + // neighbouring column. + const lineX = slots[i]?.matrixE ?? leftX; + const emittedTexts: string[] = []; + const ptrs = emitTextLine({ + outTexts: emittedTexts, + doc, + page, + text, + x: lineX, + y, + fontSize: run.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: reuseFontPtr, + originalFontSubset: run.fontSubset, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + this.lineEdit.createdPtrs.push(...ptrs); + for (const p of ptrs) { + newLeaf.push(p); + newLeafContainers.push(0); + } + newMemberPtrs.push(ptrs[0] ?? 0); + newMemberFs.push(y); + slot = buildSlotForLine( + m, + ptrs, + text, + y, + lineX, + run, + reuseFontPtr ? run.fontId : fallbackFontIdFor(fallbackFamily), + emittedTexts, + ); + } + slot.startChar = cursor; + slot.endChar = cursor + text.length; + cursor += text.length + 1; + newSlots.push(slot); + } + + // Remove objects of any prev line no next line reused. + for (let j = 0; j < slots.length; j++) { + if (usedPrev.has(j)) continue; + const src = slots[j]; + if (prevLines[j]) { + this.lineEdit.removed.push({ + text: prevLines[j], + x: src.mergedFromBounds[0]?.x ?? src.matrixE, + y: src.baselineY, + fontSize: src.fontSize, + }); + } + for (const ptr of src.mergedFromPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + } + + // Write the model, PRESERVING matched lines' original objects. + run.paragraphLineSlots = newSlots; + run.paragraphLeafPtrs = newLeaf; + run.paragraphLeafContainers = newLeafContainers; + run.paragraphMemberPtrs = newMemberPtrs; + run.paragraphMemberContainers = newMemberPtrs.map(() => 0); + run.paragraphMemberFs = newMemberFs; + run.paragraphLineHeight = lineHeight; + run.matrix = { ...run.matrix, e: leftX, f: topBaseline }; + if (newLeaf[0]) run.pdfiumObjPtr = newLeaf[0]; + const s0 = newSlots[0]; + if (s0) { + run.mergedFromPtrs = [...s0.mergedFromPtrs]; + run.mergedFromTexts = [...s0.mergedFromTexts]; + run.mergedFromBounds = s0.mergedFromBounds.map((b) => ({ ...b })); + run.mergedFromCharStarts = [...s0.mergedFromCharStarts]; + } + let maxRight = leftX; + for (const s of newSlots) { + for (const b of s.mergedFromBounds) { + if (b.right > maxRight) maxRight = b.right; + } + } + run.bounds = { + x: leftX, + y: topBaseline - (newSlots.length - 1) * lineHeight - run.fontSize * 0.25, + width: Math.max(0, maxRight - leftX), + height: newSlots.length * lineHeight + run.fontSize * 0.25, + }; + } + + /** Apply a paragraph edit that APPENDED lines (Enter + text at the end). */ + private applyParagraphAppend( + doc: EditorDocument, + page: Page, + run: TextRun, + ): void { + const m = doc.module; + const slots = run.paragraphLineSlots; + const lineHeight = + run.paragraphLineHeight > 0 + ? run.paragraphLineHeight + : run.fontSize * 1.2; + const leftX = slots[0]?.matrixE ?? run.matrix.e; + const bottomBaseline = Math.min( + run.matrix.f, + ...slots.map((s) => s.baselineY), + ); + const fallbackFamily = fallbackFamilyFor(this.prevFontId ?? run.fontId); + + this.lineEdit = { + moves: [], + createdPtrs: [], + removed: [], + prev: snapshotRunModel(run), + }; + + // The caller only routes here when the suffix is a pure newline-prefixed + // append, so split keeps a leading "" entry for that first break, skipped. + const appendedLines = this.nextText + .slice(this.prevText!.length) + .split(/\r?\n/); + const newSlots: ParagraphLineSlot[] = []; + const newLeaf: number[] = []; + const newMemberPtrs: number[] = []; + const newMemberFs: number[] = []; + let cursor = this.prevText!.length; + let below = 0; + for (let li = 1; li < appendedLines.length; li++) { + const text = appendedLines[li]; + cursor += 1; // the "\n" separator before this line + below += 1; + const y = bottomBaseline - below * lineHeight; + let slot: ParagraphLineSlot; + if (text.length === 0) { + slot = emptySlot(y, leftX, run, fallbackFamily); + newMemberPtrs.push(0); + newMemberFs.push(y); + } else { + const emittedTexts: string[] = []; + const ptrs = emitTextLine({ + doc, + page, + text, + x: leftX, + y, + fontSize: run.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: 0, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + outTexts: emittedTexts, + }); + this.lineEdit.createdPtrs.push(...ptrs); + newLeaf.push(...ptrs); + newMemberPtrs.push(ptrs[0] ?? 0); + newMemberFs.push(y); + slot = buildSlotForLine( + m, + ptrs, + text, + y, + leftX, + run, + fallbackFontIdFor(fallbackFamily), + emittedTexts, + ); + } + slot.startChar = cursor; + slot.endChar = cursor + text.length; + cursor += text.length; + newSlots.push(slot); + } + + // Preserve EVERY original object (fonts + layout intact); only append the + // new lines. ReflowWrapCommand re-lines the whole paragraph on blur. + run.paragraphLineSlots = [...slots.map(cloneSlot), ...newSlots]; + run.paragraphLeafPtrs = [...run.paragraphLeafPtrs, ...newLeaf]; + run.paragraphLeafContainers = [ + ...run.paragraphLeafContainers, + ...newLeaf.map(() => 0), + ]; + run.paragraphMemberPtrs = [...run.paragraphMemberPtrs, ...newMemberPtrs]; + run.paragraphMemberContainers = [ + ...run.paragraphMemberContainers, + ...newMemberPtrs.map(() => 0), + ]; + run.paragraphMemberFs = [...run.paragraphMemberFs, ...newMemberFs]; + run.paragraphLineHeight = lineHeight; + run.bounds = { + ...run.bounds, + y: bottomBaseline - below * lineHeight - run.fontSize * 0.25, + height: run.bounds.height + below * lineHeight, + }; + } + + // After an undo of a partial/paragraph edit, re-register the run's live + // PDFium objects as a flat overlay model. + private rebuildAsOverlayModel( + doc: EditorDocument, + page: Page, + run: TextRun, + lines: RebuildLine[], + fallbackFamily: string, + ): void { + const m = doc.module; + // Drop everything the run still owns that this rebuild is not keeping. + // A coalesced burst reverts several commands in a row, each re-emitting + // the whole run, so the earlier reverts' objects would stay painted under + // the later ones (5 objects -> 15 -> 48 on four characters). + // + // Both lists: the paragraph path tracks paragraphLeafPtrs, the partial + // (split) path repoints mergedFromPtrs at what it emitted. + const keep = new Set(); + for (const line of lines) { + for (const sr of line.subRuns) { + if (!sr.removed && sr.ptr) keep.add(sr.ptr); + } + } + for (const ptr of [...run.paragraphLeafPtrs, ...run.mergedFromPtrs]) { + if (!ptr || keep.has(ptr)) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort - the ptr may already be gone */ + } + } + + const orderedLive: number[] = []; + const lineAnchors: number[] = []; + const anchorFs: number[] = []; + for (const line of lines) { + const slotLive: number[] = []; + for (const sr of line.subRuns) { + if (sr.removed) { + if (!sr.text) continue; + const ptrs = emitTextLine({ + doc, + page, + text: sr.text, + x: sr.x, + y: line.baselineY, + fontSize: line.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: 0, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + slotLive.push(...ptrs); + } else if (sr.ptr) { + slotLive.push(sr.ptr); + } + } + if (slotLive.length === 0) continue; + lineAnchors.push(slotLive[0]); + anchorFs.push(line.baselineY); + orderedLive.push(...slotLive); + } + run.mergedFromPtrs = []; + run.mergedFromTexts = []; + run.mergedFromBounds = []; + run.mergedFromCharStarts = []; + run.paragraphLineSlots = []; + run.paragraphLeafPtrs = orderedLive; + run.paragraphLeafContainers = orderedLive.map(() => 0); + run.paragraphMemberPtrs = lineAnchors; + run.paragraphMemberContainers = lineAnchors.map(() => 0); + run.paragraphMemberFs = anchorFs; + if (orderedLive.length > 0) run.pdfiumObjPtr = orderedLive[0]; + } + + describe(): string { + return `Type into ${this.runId}`; + } + + /** Consecutive typing on the SAME run coalesces into one undo step. */ + coalesceKey(): string { + return `edit-text:${this.pageIndex}:${this.runId}`; + } + + /** The text this edit produced - lets the history compare adjacent edits. */ + get resultText(): string { + return this.nextText; + } + + // True when this edit's ENTIRE delta was one or more line breaks, i.e. the + // user pressed Enter and changed nothing else. + private isLineBreakOnlyInsertion(): boolean { + if (this.prevText === null) return false; + const inserted = insertedChunk(this.prevText, this.nextText); + return inserted !== null && /^(?:\r?\n)+$/.test(inserted); + } + + // "Press Enter, then type" is ONE logical action, so it must cost one undo - + // which is what makes a bare line break merge forward here. + coalesceIgnoresTimeWindow(previous: Command | null): boolean { + if (!(previous instanceof EditTextCommand)) return false; + if (this.prevText === null) return false; + // Contiguity: this edit must start from exactly what that one produced. + if (previous.resultText !== this.prevText) return false; + return previous.isLineBreakOnlyInsertion(); + } +} + +// The text `next` adds to `prev` when the change is a pure insertion at a +// single point, or null when it is anything else. +function insertedChunk(prev: string, next: string): string | null { + if (next.length <= prev.length) return null; + let head = 0; + while (head < prev.length && prev[head] === next[head]) head++; + let tail = 0; + while ( + tail < prev.length - head && + prev[prev.length - 1 - tail] === next[next.length - 1 - tail] + ) { + tail++; + } + // Everything outside the inserted chunk must be untouched original text. + if (head + tail !== prev.length) return null; + return next.slice(head, next.length - tail); +} + +/** Keep a run's model width from claiming space past the page's right edge. */ +function clampWidthToPage(x: number, width: number, page: Page): number { + // x/width are RAW PDF space, so the right edge is the CropBox right edge in + // raw space. + const rawRightEdge = page.display.cropLeft + page.display.cropWidth; + const maxWidth = Math.max(0, rawRightEdge - x); + return Math.min(width, maxWidth); +} + +function safeGetFont( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + objPtr: number, +): number { + const fn = (m as unknown as { FPDFTextObj_GetFont?: (p: number) => number }) + .FPDFTextObj_GetFont; + if (!fn) return 0; + try { + return fn(objPtr); + } catch { + return 0; + } +} + +function snapshotRevertLines( + run: import("@app/tools/pdfTextEditor/model/TextRun").TextRun, + prevText: string, +): RevertLine[] { + const lines = prevText.split(/\r?\n/); + const lineHeight = + run.paragraphLineHeight > 0 ? run.paragraphLineHeight : run.fontSize * 1.2; + return lines.map((text, idx) => ({ + text, + x: run.matrix.e, + y: run.matrix.f - idx * lineHeight, + fill: { ...run.fill }, + fontSize: Math.max(4, run.fontSize), + charSpacingPt: run.charSpacingPt, + })); +} + +// Reconstruct `paragraphLineSlots` from the data the overlay loop just emitted. +function buildSlotsFromOverlayEmit( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + run: import("@app/tools/pdfTextEditor/model/TextRun").TextRun, + perLineEmits: Array<{ + ptrs: number[]; + texts: string[]; + text: string; + x: number; + y: number; + }>, + fontId: string, +): import("@app/tools/pdfTextEditor/model/TextRun").ParagraphLineSlot[] { + const slots = []; + let cursor = 0; + for (const emit of perLineEmits) { + const text = emit.text; + const startChar = cursor; + const endChar = startChar + text.length; + // Empty-line slot: no PDFium sub-objects, no bounds. matrixE + baselineY + // carry the expected anchor for the next edit. + if (emit.ptrs.length === 0 || text.length === 0) { + slots.push({ + startChar, + endChar, + baselineY: emit.y, + matrixE: emit.x, + containerPtr: 0, + fontId, + fontSize: run.fontSize, + fontSubset: false, + mergedFromPtrs: [], + mergedFromTexts: [], + mergedFromBounds: [], + mergedFromCharStarts: [], + }); + cursor = endChar + 1; + continue; + } + const mergedFromTexts: string[] = []; + const mergedFromPtrs: number[] = []; + const mergedFromBounds: Array<{ x: number; right: number }> = []; + const mergedFromCharStarts: number[] = []; + if (emit.texts.length === emit.ptrs.length) { + // The emitter told us what each ptr carries. Never re-derive it: it emits + // per word OR per character, and the word guess below silently dropped + // every ptr past the word count, leaving those glyphs painted forever. + let at = 0; + for (let i = 0; i < emit.ptrs.length; i++) { + const piece = emit.texts[i]; + const found = text.indexOf(piece, at); + const start = found >= 0 ? found : at; + mergedFromPtrs.push(emit.ptrs[i]); + mergedFromTexts.push(piece); + mergedFromBounds.push(boundsFromPtr(m, emit.ptrs[i], run.matrix.e)); + mergedFromCharStarts.push(start); + at = start + piece.length; + } + } else if (emit.ptrs.length === 1) { + mergedFromPtrs.push(emit.ptrs[0]); + mergedFromTexts.push(text); + mergedFromBounds.push(boundsFromPtr(m, emit.ptrs[0], run.matrix.e)); + mergedFromCharStarts.push(0); + } else { + const words = text.split(/(\s+)/).filter((w) => w.length > 0); + const nonGapWords = words.filter((w) => !/^\s+$/.test(w)); + const used = Math.min(emit.ptrs.length, nonGapWords.length); + let cur = 0; + let wordIdx = 0; + for (let i = 0; i < words.length; i++) { + const w = words[i]; + if (/^\s+$/.test(w)) { + cur += w.length; + continue; + } + if (wordIdx >= used) { + cur += w.length; + wordIdx += 1; + continue; + } + const ptr = emit.ptrs[wordIdx]; + mergedFromPtrs.push(ptr); + mergedFromTexts.push(w); + mergedFromBounds.push(boundsFromPtr(m, ptr, run.matrix.e)); + mergedFromCharStarts.push(cur); + cur += w.length; + wordIdx += 1; + } + } + slots.push({ + startChar, + endChar, + baselineY: emit.y, + matrixE: run.matrix.e, + containerPtr: 0, + fontId, + fontSize: run.fontSize, + fontSubset: false, + mergedFromPtrs, + mergedFromTexts, + mergedFromBounds, + mergedFromCharStarts, + }); + cursor = endChar + 1; // +1 for the "\n" separator + } + return slots; +} + +function boundsFromPtr( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptr: number, + fallbackX: number, +): { x: number; right: number } { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(ptr, l, b, r, t)) { + return { x: fallbackX, right: fallbackX }; + } + return { + x: m.pdfium.getValue(l, "float"), + right: m.pdfium.getValue(r, "float"), + }; + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +function keptLeadingBaselines( + lineCount: number, + match: Map, + slots: ParagraphLineSlot[], + topBaseline: number, + lineHeight: number, +): number[] { + const out: number[] = []; + let y = topBaseline; + for (let i = 0; i < lineCount; i++) { + if (i > 0) y -= stepBetween(i, match, slots, lineHeight); + out.push(y); + } + return out; +} + +const MIN_REAL_LEADING = 0.5; + +function stepBetween( + i: number, + match: Map, + slots: ParagraphLineSlot[], + lineHeight: number, +): number { + const above = match.get(i - 1); + const here = match.get(i); + if (above === undefined || here === undefined) return lineHeight; + if (here !== above + 1) return lineHeight; + const delta = slots[above]?.baselineY - slots[here]?.baselineY; + if (!Number.isFinite(delta)) return lineHeight; + return delta >= MIN_REAL_LEADING * lineHeight ? delta : lineHeight; +} + +function cloneSlot(s: ParagraphLineSlot): ParagraphLineSlot { + return { + startChar: s.startChar, + endChar: s.endChar, + baselineY: s.baselineY, + matrixE: s.matrixE, + containerPtr: s.containerPtr, + fontId: s.fontId, + fontSize: s.fontSize, + fontSubset: s.fontSubset, + mergedFromPtrs: [...s.mergedFromPtrs], + mergedFromTexts: [...s.mergedFromTexts], + mergedFromBounds: s.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...s.mergedFromCharStarts], + }; +} + +/** + * A blank line, inheriting its font from the line it was split off. + * + * Without `seed` the slot is stamped base-14 the moment Enter is pressed, and + * everything typed into it afterwards re-emits against that - so a new line + * came out in Helvetica while the paragraph around it kept the document's own + * face. The blank line has no glyphs of its own to judge by, so the only honest + * default is the font of the line it came from. + */ +export function emptySlot( + baselineY: number, + leftX: number, + run: TextRun, + fallbackFamily: string, + seed?: ParagraphLineSlot, +): ParagraphLineSlot { + return { + startChar: 0, + endChar: 0, + baselineY, + matrixE: leftX, + containerPtr: 0, + fontId: seed ? seed.fontId : fallbackFontIdFor(fallbackFamily), + fontSize: run.fontSize, + fontSubset: seed ? seed.fontSubset : false, + mergedFromPtrs: [], + mergedFromTexts: [], + mergedFromBounds: [], + mergedFromCharStarts: [], + }; +} + +/** Build a slot for a freshly-emitted line, mapping each ptr to its word. */ +// Map the objects an emit produced back onto the line's text. +// +// One object per WORD is only what the base-14 path happens to produce; reusing +// an embedded font can route through the per-character branch instead, and +// assuming word alignment then filled the slot with empty sub-run texts and +// out-of-range char starts, which the NEXT edit's diff silently mis-sliced. +// `emitTextLine` reports what it wrote via outTexts; falling back to a text-page +// read would cost a full page extraction per line. +function sliceLineAcrossPtrs( + ptrs: number[], + text: string, + emitted?: string[], +): Array<{ text: string; start: number }> { + const out: Array<{ text: string; start: number }> = []; + if (emitted && emitted.length === ptrs.length) { + let cursor = 0; + for (const chunk of emitted) { + const at = chunk.length > 0 ? text.indexOf(chunk, cursor) : -1; + const start = at >= 0 ? at : cursor; + out.push({ text: chunk, start }); + cursor = start + chunk.length; + } + return out; + } + // No report from the emit: assume the base-14 shape, one object per word. + const words: Array<{ text: string; start: number }> = []; + const re = /\S+/g; + let wm: RegExpExecArray | null; + while ((wm = re.exec(text)) !== null) { + words.push({ text: wm[0], start: wm.index }); + } + for (let i = 0; i < ptrs.length; i += 1) { + const w = words[i]; + out.push(w ? { ...w } : { text: "", start: text.length }); + } + return out; +} + +function buildSlotForLine( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptrs: number[], + text: string, + baselineY: number, + leftX: number, + run: TextRun, + fontId: string, + emitted?: string[], +): ParagraphLineSlot { + const mergedFromPtrs: number[] = []; + const mergedFromTexts: string[] = []; + const mergedFromBounds: Array<{ x: number; right: number }> = []; + const mergedFromCharStarts: number[] = []; + const words = sliceLineAcrossPtrs(ptrs, text, emitted); + for (let i = 0; i < ptrs.length; i++) { + const w = words[i]; + const b = boundsFromPtr(m, ptrs[i], leftX); + mergedFromPtrs.push(ptrs[i]); + mergedFromTexts.push(w ? w.text : ""); + mergedFromBounds.push({ x: b.x, right: b.right }); + mergedFromCharStarts.push(w ? w.start : text.length); + } + return { + startChar: 0, + endChar: text.length, + baselineY, + matrixE: leftX, + containerPtr: 0, + fontId, + fontSize: run.fontSize, + fontSubset: false, + mergedFromPtrs, + mergedFromTexts, + mergedFromBounds, + mergedFromCharStarts, + }; +} + +function snapshotRunModel(run: TextRun): RunModelSnapshot { + return { + text: run.text, + matrixE: run.matrix.e, + matrixF: run.matrix.f, + bounds: { ...run.bounds }, + paragraphLineHeight: run.paragraphLineHeight, + paragraphMemberPtrs: [...run.paragraphMemberPtrs], + paragraphMemberContainers: [...run.paragraphMemberContainers], + paragraphMemberFs: [...run.paragraphMemberFs], + paragraphLeafPtrs: [...run.paragraphLeafPtrs], + paragraphLeafContainers: [...run.paragraphLeafContainers], + paragraphLineSlots: run.paragraphLineSlots.map(cloneSlot), + mergedFromPtrs: [...run.mergedFromPtrs], + mergedFromTexts: [...run.mergedFromTexts], + mergedFromBounds: run.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...run.mergedFromCharStarts], + fontId: run.fontId, + fontSubset: run.fontSubset, + pdfiumObjPtr: run.pdfiumObjPtr, + }; +} + +function restoreRunModel(run: TextRun, snap: RunModelSnapshot): void { + run.matrix = { ...run.matrix, e: snap.matrixE, f: snap.matrixF }; + run.bounds = { ...snap.bounds }; + run.paragraphLineHeight = snap.paragraphLineHeight; + run.paragraphMemberPtrs = [...snap.paragraphMemberPtrs]; + run.paragraphMemberContainers = [...snap.paragraphMemberContainers]; + run.paragraphMemberFs = [...snap.paragraphMemberFs]; + run.paragraphLeafPtrs = [...snap.paragraphLeafPtrs]; + run.paragraphLeafContainers = [...snap.paragraphLeafContainers]; + run.paragraphLineSlots = snap.paragraphLineSlots.map(cloneSlot); + run.mergedFromPtrs = [...snap.mergedFromPtrs]; + run.mergedFromTexts = [...snap.mergedFromTexts]; + run.mergedFromBounds = snap.mergedFromBounds.map((b) => ({ ...b })); + run.mergedFromCharStarts = [...snap.mergedFromCharStarts]; + run.fontId = snap.fontId; + run.fontSubset = snap.fontSubset; + run.pdfiumObjPtr = snap.pdfiumObjPtr; +} + +/** LCS over lines: maps next-line index -> matched prev-line index. */ +function lineLCS(a: string[], b: string[]): Map { + const m = a.length; + const n = b.length; + const dp: Int32Array[] = new Array(m + 1); + for (let i = 0; i <= m; i++) dp[i] = new Int32Array(n + 1); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + const map = new Map(); + let i = m; + let j = n; + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + map.set(j - 1, i - 1); + i--; + j--; + } else if (dp[i - 1][j] >= dp[i][j - 1]) { + i--; + } else { + j--; + } + } + return map; +} + +/** Rebuild the flat leaf arrays from the run's slots. */ +function reflattenLeafArrays(run: TextRun): void { + const leaf: number[] = []; + const leafContainers: number[] = []; + for (const s of run.paragraphLineSlots) { + for (const p of s.mergedFromPtrs) { + leaf.push(p); + leafContainers.push(s.containerPtr); + } + } + run.paragraphLeafPtrs = leaf; + run.paragraphLeafContainers = leafContainers; +} + +/** Replace a restored slot (matched by baseline) with re-emitted objects. */ +function patchSlotPtrsByBaseline( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + run: TextRun, + baselineY: number, + ptrs: number[], + text: string, +): void { + const idx = run.paragraphLineSlots.findIndex( + (s) => Math.abs(s.baselineY - baselineY) < 1, + ); + if (idx < 0) return; + const old = run.paragraphLineSlots[idx]; + const rebuilt = buildSlotForLine( + m, + ptrs, + text, + baselineY, + old.matrixE, + run, + old.fontId, + ); + rebuilt.startChar = old.startChar; + rebuilt.endChar = old.endChar; + run.paragraphLineSlots[idx] = rebuilt; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/InsertImageCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/InsertImageCommand.ts new file mode 100644 index 0000000000..babe778bdb --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/InsertImageCommand.ts @@ -0,0 +1,203 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import type { Affine } from "@app/tools/pdfTextEditor/types"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { + counterPageRotation, + rotateObjectAbout, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { imageMatrixBounds } from "@app/tools/pdfTextEditor/model/affine"; +import { + embedBitmapImageOnPage, + embedJpegImageOnPage, +} from "@app/utils/pdfiumBitmapUtils"; + +// Insert a decoded raster image onto a page at the given lower-left coordinate, +// scaled to `(width, height)` PDF points. +export class InsertImageCommand implements Command { + readonly type = "insert-image"; + private readonly pageIndex: number; + private readonly rgba: Uint8ClampedArray; + private readonly pixelWidth: number; + private readonly pixelHeight: number; + private readonly x: number; + private readonly y: number; + private readonly width: number; + private readonly height: number; + /** Original JPEG bytes; when present, embedded as-is (DCTDecode) to keep the file small. */ + private readonly jpegBytes?: Uint8Array; + private createdImageId: string | null; + private createdObjPtr: number; + /** Matrix written on first embed; reused so redo re-inserts the same object. */ + private appliedMatrix: Affine | null; + + constructor(opts: { + pageIndex: number; + rgba: Uint8ClampedArray; + pixelWidth: number; + pixelHeight: number; + x: number; + y: number; + width: number; + height: number; + jpegBytes?: Uint8Array; + }) { + this.pageIndex = opts.pageIndex; + this.rgba = opts.rgba; + this.pixelWidth = opts.pixelWidth; + this.pixelHeight = opts.pixelHeight; + this.x = opts.x; + this.y = opts.y; + this.width = opts.width; + this.height = opts.height; + this.jpegBytes = opts.jpegBytes; + this.createdImageId = null; + this.createdObjPtr = 0; + this.appliedMatrix = null; + } + + get insertedImageId(): string | null { + return this.createdImageId; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const m = doc.module; + // Redo: re-insert the SAME object detached by revert instead of re-embedding. + // The object was only detached (not destroyed), so this is safe and leak-free. + if (this.createdObjPtr) { + m.FPDFPage_InsertObject(page.pagePtr, this.createdObjPtr); + if (this.createdImageId) { + const restored = new ImageObject({ + id: this.createdImageId, + pageIndex: page.index, + pdfiumObjPtr: this.createdObjPtr, + bounds: { + x: this.x, + y: this.y, + width: this.width, + height: this.height, + }, + matrix: this.appliedMatrix ?? { + a: this.width, + b: 0, + c: 0, + d: this.height, + e: this.x, + f: this.y, + }, + }); + page.setImages([...page.images, restored]); + } + page.markDirty(); + page.markNeedsGenerate(); + return; + } + // JPEG sources embed as-is (DCTDecode) to keep the output small; fall back + // to the RGBA bitmap path if the JPEG API is unavailable or the load fails. + let newObjPtr = this.jpegBytes + ? embedJpegImageOnPage( + m, + doc.docPtr, + page.pagePtr, + this.jpegBytes, + this.x, + this.y, + this.width, + this.height, + ) + : 0; + if (!newObjPtr) { + newObjPtr = embedBitmapImageOnPage( + m, + doc.docPtr, + page.pagePtr, + { + rgba: new Uint8Array( + this.rgba.buffer, + this.rgba.byteOffset, + this.rgba.byteLength, + ), + width: this.pixelWidth, + height: this.pixelHeight, + }, + this.x, + this.y, + this.width, + this.height, + ); + } + if (!newObjPtr) return; + // On a /Rotate page, counter-rotate about the centre so the image reads + // upright (mirrors InsertTextCommand); no-op on an unrotated page. + const rot = counterPageRotation(page.display.rotate); + const cx = this.x + this.width / 2; + const cy = this.y + this.height / 2; + if (rot) rotateObjectAbout(m, newObjPtr, cx, cy, rot.cos, rot.sin); + const matrix: Affine = rot + ? readMatrix(m, newObjPtr) + : { + a: this.width, + b: 0, + c: 0, + d: this.height, + e: this.x, + f: this.y, + }; + this.appliedMatrix = matrix; + const imageId = `p${page.index}-new-img-${page.images.length}-${newObjPtr}`; + const created = new ImageObject({ + id: imageId, + pageIndex: page.index, + pdfiumObjPtr: newObjPtr, + // On a /Rotate page the counter-rotated object's real AABB has swapped + // width/height vs the pre-rotation rect. + bounds: rot + ? imageMatrixBounds(matrix) + : { + x: this.x, + y: this.y, + width: this.width, + height: this.height, + }, + matrix, + }); + page.setImages([...page.images, created]); + page.markDirty(); + page.markNeedsGenerate(); + this.createdImageId = imageId; + this.createdObjPtr = newObjPtr; + } + + revert(doc: EditorDocument): void { + if (!this.createdObjPtr) return; + const page = doc.page(this.pageIndex); + doc.module.FPDFPage_RemoveObject(page.pagePtr, this.createdObjPtr); + if (this.createdImageId) { + page.setImages(page.images.filter((i) => i.id !== this.createdImageId)); + } + page.markDirty(); + page.markNeedsGenerate(); + } +} + +/** Read an object's current matrix so the model stays in lock-step with PDFium. */ +function readMatrix(m: WrappedPdfiumModule, objPtr: number): Affine { + // FS_MATRIX: { a, b, c, d, e, f } as floats. + const buf = m.pdfium.wasmExports.malloc(6 * 4); + try { + const ok = m.FPDFPageObj_GetMatrix(objPtr, buf); + if (!ok) return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + return { + a: m.pdfium.getValue(buf, "float"), + b: m.pdfium.getValue(buf + 4, "float"), + c: m.pdfium.getValue(buf + 8, "float"), + d: m.pdfium.getValue(buf + 12, "float"), + e: m.pdfium.getValue(buf + 16, "float"), + f: m.pdfium.getValue(buf + 20, "float"), + }; + } finally { + m.pdfium.wasmExports.free(buf); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/InsertTextCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/InsertTextCommand.ts new file mode 100644 index 0000000000..1756fc6565 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/InsertTextCommand.ts @@ -0,0 +1,133 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { BLACK } from "@app/tools/pdfTextEditor/model/Color"; +import { writeUtf16 } from "@app/services/pdfiumService"; +import { + counterPageRotation, + rotateObjectAbout, + sanitizeForBase14, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { emitFallbackTextObject } from "@app/tools/pdfTextEditor/util/fallbackFont"; + +const DEFAULT_FAMILY = "Helvetica"; +const DEFAULT_SIZE = 12; + +// Create a brand-new text object on the given page at the given page-space +// point. +export class InsertTextCommand implements Command { + readonly type = "insert-text"; + private readonly pageIndex: number; + private readonly x: number; + private readonly y: number; + private readonly text: string; + private createdRunId: string | null; + private createdObjPtr: number; + + constructor(opts: { + pageIndex: number; + x: number; + y: number; + text?: string; + }) { + this.pageIndex = opts.pageIndex; + this.x = opts.x; + this.y = opts.y; + this.text = opts.text ?? "Text"; + this.createdRunId = null; + this.createdObjPtr = 0; + } + + /** Returns the id of the run this command created, after apply. */ + get insertedRunId(): string | null { + return this.createdRunId; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const m = doc.module; + + // Base-14 (WinAnsi) can't render >U+00FF. + const sanitized = sanitizeForBase14(this.text); + let objPtr = 0; + if ([...this.text].length > [...sanitized].length) { + objPtr = emitFallbackTextObject( + doc, + page, + this.text, + DEFAULT_SIZE, + BLACK, + this.x, + this.y, + ); + } + if (!objPtr) { + objPtr = m.FPDFPageObj_NewTextObj( + doc.docPtr, + DEFAULT_FAMILY, + DEFAULT_SIZE, + ); + if (!objPtr) return; + const textPtr = writeUtf16(m, sanitized); + try { + m.FPDFText_SetText(objPtr, textPtr); + } finally { + m.pdfium.wasmExports.free(textPtr); + } + m.FPDFPageObj_SetFillColor(objPtr, BLACK.r, BLACK.g, BLACK.b, BLACK.a); + m.FPDFPageObj_Transform(objPtr, 1, 0, 0, 1, this.x, this.y); + m.FPDFPage_InsertObject(page.pagePtr, objPtr); + } + + // On a /Rotate page, counter-rotate the new object about its anchor so it + // reads upright in the displayed orientation rather than landing sideways. + const rot = counterPageRotation(page.display.rotate); + if (rot) rotateObjectAbout(m, objPtr, this.x, this.y, rot.cos, rot.sin); + const matrix = rot + ? { + a: rot.cos, + b: rot.sin, + c: -rot.sin, + d: rot.cos, + e: this.x, + f: this.y, + } + : { a: 1, b: 0, c: 0, d: 1, e: this.x, f: this.y }; + + const runId = `p${page.index}-new-${page.runs.length}-${objPtr}`; + const run = new TextRun({ + id: runId, + pageIndex: page.index, + pdfiumObjPtr: objPtr, + bounds: { + x: this.x, + y: this.y, + width: this.text.length * DEFAULT_SIZE * 0.6, + height: DEFAULT_SIZE * 1.2, + }, + matrix, + text: this.text, + fontId: `base14:${DEFAULT_FAMILY}`, + fontSize: DEFAULT_SIZE, + fill: { ...BLACK }, + fontSubset: false, + }); + page.setRuns([...page.runs, run]); + page.markDirty(); + page.markNeedsGenerate(); + + this.createdRunId = runId; + this.createdObjPtr = objPtr; + } + + revert(doc: EditorDocument): void { + if (!this.createdObjPtr) return; + const page = doc.page(this.pageIndex); + doc.module.FPDFPage_RemoveObject(page.pagePtr, this.createdObjPtr); + if (this.createdRunId) { + page.setRuns(page.runs.filter((r) => r.id !== this.createdRunId)); + } + page.markDirty(); + page.markNeedsGenerate(); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/MergeRunsCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/MergeRunsCommand.ts new file mode 100644 index 0000000000..d0278233a5 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/MergeRunsCommand.ts @@ -0,0 +1,249 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { + cloneParagraphLineSlot, + type ParagraphLineSlot, + type TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; +import { + buildLineSlotsFromDescriptors, + type LineSlotDescriptor, + medianLineHeightFromBaselines, +} from "@app/tools/pdfTextEditor/pdfium/ParagraphGrouper"; + +/** Merge the selected runs on a single page into one virtual paragraph. */ +interface RunSnapshot { + id: string; + pdfiumObjPtr: number; + matrixF: number; + containerPtr: number; + text: string; + paragraphLineHeight: number; + paragraphMemberPtrs: number[]; + paragraphMemberContainers: number[]; + paragraphMemberFs: number[]; + paragraphLeafPtrs: number[]; + paragraphLeafContainers: number[]; + paragraphLineSlots: ParagraphLineSlot[]; + bounds: { x: number; y: number; width: number; height: number }; +} + +export class MergeRunsCommand implements Command { + readonly type = "merge-runs"; + private readonly pageIndex: number; + private readonly runIds: string[]; + private removedRunSnapshots: RunSnapshot[] = []; + // The TextRun instances we removed from page.runs at apply time. + private removedRunInstances: TextRun[] = []; + // Original `page.runs` order at apply time so revert restores the + // ordering callers depend on (z-order, find-bar iteration order). + private prevRunOrder: string[] = []; + private repPrev: RunSnapshot | null = null; + private repId: string | null = null; + + constructor(opts: { pageIndex: number; runIds: string[] }) { + this.pageIndex = opts.pageIndex; + this.runIds = [...opts.runIds]; + } + + get representativeRunId(): string | null { + return this.repId; + } + + apply(doc: EditorDocument): void { + if (this.runIds.length < 2) return; + const page = doc.page(this.pageIndex); + const runs = this.runIds + .map((id) => page.findRun(id)) + .filter((r): r is TextRun => !!r); + if (runs.length < 2) return; + + runs.sort((a, b) => b.matrix.f - a.matrix.f); + const rep = runs[0]; + const members = runs.slice(1); + this.repId = rep.id; + this.repPrev = snapshotRun(rep); + this.removedRunSnapshots = members.map(snapshotRun); + this.removedRunInstances = members; + this.prevRunOrder = page.runs.map((r) => r.id); + + // A selected run may itself be a multi-line paragraph rep, so flatten the + // runs into ONE descriptor per visual line before building slots/members. + const descs: LineDescriptor[] = []; + for (const r of runs) descs.push(...flattenRunToLines(r)); + + const minX = Math.min(...runs.map((r) => r.bounds.x)); + const maxRight = Math.max(...runs.map((r) => r.bounds.x + r.bounds.width)); + const topY = Math.max(...runs.map((r) => r.bounds.y + r.bounds.height)); + const bottomY = Math.min(...runs.map((r) => r.bounds.y)); + + rep.text = descs.map((d) => d.text).join("\n"); + rep.bounds = { + x: minX, + y: bottomY, + width: maxRight - minX, + height: topY - bottomY, + }; + // Median of consecutive per-line baseline deltas, not the rep-top-only + // formula, so multi-line reps keep correct spacing. + rep.paragraphLineHeight = + descs.length > 1 + ? medianLineHeightFromBaselines( + descs.map((d) => d.baselineY), + rep.fontSize, + ) + : rep.paragraphLineHeight || rep.fontSize * 1.2; + rep.paragraphMemberPtrs = descs.map((d) => d.leafPtrs[0] ?? 0); + rep.paragraphMemberContainers = descs.map((d) => d.containerPtr); + rep.paragraphMemberFs = descs.map((d) => d.baselineY); + // Flatten each line's own merged sub-ptrs so EditTextCommand removes + // every original sub-word, not just the first ptr of each line. + const leafPtrs: number[] = []; + const leafContainers: number[] = []; + for (const d of descs) { + for (const p of d.leafPtrs) { + leafPtrs.push(p); + leafContainers.push(d.containerPtr); + } + } + rep.paragraphLeafPtrs = leafPtrs; + rep.paragraphLeafContainers = leafContainers; + // Per-line slots so a later partial edit keeps each line's source font + // (planParagraphEdit bails without them, falling back to Helvetica). + rep.paragraphLineSlots = buildLineSlotsFromDescriptors(descs); + + const removedIds = new Set(members.map((r) => r.id)); + page.setRuns(page.runs.filter((r) => !removedIds.has(r.id))); + // Bump the page revision so the dirty-only resnapshot in EditorStore + // republishes this page. + page.markDirty(); + } + + revert(doc: EditorDocument): void { + if (!this.repId || !this.repPrev) return; + const page = doc.page(this.pageIndex); + const rep = page.findRun(this.repId); + if (rep) restoreRun(rep, this.repPrev); + + // Re-attach the member TextRun instances we held aside at apply time. + const byId = new Map(); + for (const r of page.runs) byId.set(r.id, r); + for (const r of this.removedRunInstances) { + if (!byId.has(r.id)) byId.set(r.id, r); + } + const ordered: TextRun[] = []; + const seen = new Set(); + for (const id of this.prevRunOrder) { + const r = byId.get(id); + if (r) { + ordered.push(r); + seen.add(id); + } + } + for (const r of page.runs) { + if (!seen.has(r.id)) { + ordered.push(r); + seen.add(r.id); + } + } + page.setRuns(ordered); + page.markDirty(); + } + + describe(): string { + return `Merge ${this.runIds.length} runs into a paragraph`; + } +} + +// A descriptor is a slot source (mergedFrom* for the slot) plus the line's +// real leaf ptrs (which can differ from the slot fallback for single-line runs). +interface LineDescriptor extends LineSlotDescriptor { + leafPtrs: number[]; +} + +/** Expand a run into one descriptor per visual line. */ +function flattenRunToLines(r: TextRun): LineDescriptor[] { + if (r.paragraphLineSlots.length >= 2) { + return r.paragraphLineSlots.map((slot) => ({ + text: r.text.slice(slot.startChar, slot.endChar), + baselineY: slot.baselineY, + matrixE: slot.matrixE, + containerPtr: slot.containerPtr, + fontId: slot.fontId, + fontSize: slot.fontSize, + fontSubset: slot.fontSubset, + mergedFromPtrs: [...slot.mergedFromPtrs], + mergedFromTexts: [...slot.mergedFromTexts], + mergedFromBounds: slot.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...slot.mergedFromCharStarts], + leafPtrs: [...slot.mergedFromPtrs], + })); + } + const leafPtrs = + r.paragraphLeafPtrs.length > 0 + ? [...r.paragraphLeafPtrs] + : r.mergedFromPtrs.length > 0 + ? [...r.mergedFromPtrs] + : r.pdfiumObjPtr + ? [r.pdfiumObjPtr] + : []; + // Slot sub-runs mirror buildLineSlots' single-line fallback so partial edits + // keep the source font instead of bailing to the overlay path. + const hasSubRuns = r.mergedFromPtrs.length > 0; + const mergedFromPtrs = hasSubRuns + ? [...r.mergedFromPtrs] + : r.pdfiumObjPtr + ? [r.pdfiumObjPtr] + : []; + const mergedFromTexts = hasSubRuns ? [...r.mergedFromTexts] : [r.text]; + const mergedFromBounds = hasSubRuns + ? r.mergedFromBounds.map((b) => ({ ...b })) + : [{ x: r.bounds.x, right: r.bounds.x + r.bounds.width }]; + const mergedFromCharStarts = hasSubRuns ? [...r.mergedFromCharStarts] : [0]; + return [ + { + text: r.text, + baselineY: r.matrix.f, + matrixE: r.matrix.e, + containerPtr: r.containerPtr, + fontId: r.fontId, + fontSize: r.fontSize, + fontSubset: r.fontSubset, + mergedFromPtrs, + mergedFromTexts, + mergedFromBounds, + mergedFromCharStarts, + leafPtrs, + }, + ]; +} + +function snapshotRun(r: TextRun): RunSnapshot { + return { + id: r.id, + pdfiumObjPtr: r.pdfiumObjPtr, + matrixF: r.matrix.f, + containerPtr: r.containerPtr, + text: r.text, + paragraphLineHeight: r.paragraphLineHeight, + paragraphMemberPtrs: [...r.paragraphMemberPtrs], + paragraphMemberContainers: [...r.paragraphMemberContainers], + paragraphMemberFs: [...r.paragraphMemberFs], + paragraphLeafPtrs: [...r.paragraphLeafPtrs], + paragraphLeafContainers: [...r.paragraphLeafContainers], + paragraphLineSlots: r.paragraphLineSlots.map(cloneParagraphLineSlot), + bounds: { ...r.bounds }, + }; +} + +function restoreRun(r: TextRun, snap: RunSnapshot): void { + r.text = snap.text; + r.bounds = { ...snap.bounds }; + r.paragraphLineHeight = snap.paragraphLineHeight; + r.paragraphMemberPtrs = [...snap.paragraphMemberPtrs]; + r.paragraphMemberContainers = [...snap.paragraphMemberContainers]; + r.paragraphMemberFs = [...snap.paragraphMemberFs]; + r.paragraphLeafPtrs = [...snap.paragraphLeafPtrs]; + r.paragraphLeafContainers = [...snap.paragraphLeafContainers]; + r.paragraphLineSlots = snap.paragraphLineSlots.map(cloneParagraphLineSlot); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/MoveTextRunCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/MoveTextRunCommand.ts new file mode 100644 index 0000000000..2cf54ba7a9 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/MoveTextRunCommand.ts @@ -0,0 +1,100 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { collectMemberPtrs } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { transformObject } from "@app/tools/pdfTextEditor/util/objectTransform"; + +/** Translate a text run by (dx, dy) in PDF page-space points. */ +export class MoveTextRunCommand implements Command { + readonly type = "move-text-run"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly dx: number; + private readonly dy: number; + private appliedPtrs: number[]; + + constructor(opts: { + pageIndex: number; + runId: string; + dx: number; + dy: number; + }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.dx = opts.dx; + this.dy = opts.dy; + this.appliedPtrs = []; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + const m = doc.module; + const seen = new Set(); + for (const ptr of collectMemberPtrs(run)) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + try { + transformObject(m, ptr, 1, 0, 0, 1, this.dx, this.dy); + this.appliedPtrs.push(ptr); + } catch { + /* skip leaks; revert only undoes the ptrs we actually moved */ + } + } + this.shiftModel(run, this.dx, this.dy); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (this.appliedPtrs.length === 0) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + const m = doc.module; + for (const ptr of this.appliedPtrs) { + if (!ptr) continue; + try { + transformObject(m, ptr, 1, 0, 0, 1, -this.dx, -this.dy); + } catch { + /* best-effort */ + } + } + this.shiftModel(run, -this.dx, -this.dy); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + this.appliedPtrs = []; + } + + /** Shift the run's matrix/bounds + per-line + sub-run model by (dx, dy). */ + private shiftModel( + run: import("@app/tools/pdfTextEditor/model/TextRun").TextRun, + dx: number, + dy: number, + ): void { + run.matrix = { ...run.matrix, e: run.matrix.e + dx, f: run.matrix.f + dy }; + run.bounds = { ...run.bounds, x: run.bounds.x + dx, y: run.bounds.y + dy }; + if (run.paragraphMemberFs.length > 0) { + run.paragraphMemberFs = run.paragraphMemberFs.map((f) => f + dy); + } + if (run.paragraphLineSlots.length > 0) { + run.paragraphLineSlots = run.paragraphLineSlots.map((s) => ({ + ...s, + baselineY: s.baselineY + dy, + matrixE: s.matrixE + dx, + mergedFromBounds: s.mergedFromBounds.map((b) => ({ + x: b.x + dx, + right: b.right + dx, + })), + })); + } + if (run.mergedFromBounds.length > 0) { + run.mergedFromBounds = run.mergedFromBounds.map((b) => ({ + x: b.x + dx, + right: b.right + dx, + })); + } + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/ReflowWrapCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/ReflowWrapCommand.ts new file mode 100644 index 0000000000..db18820d8d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/ReflowWrapCommand.ts @@ -0,0 +1,660 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { + ParagraphLineSlot, + TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { readUtf16 } from "@app/services/pdfiumService"; +import { rotationFromMatrix } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { transformObject } from "@app/tools/pdfTextEditor/util/objectTransform"; + +/** Reflow a text run's EXISTING glyph objects to fit within `maxWidthPt`. */ + +interface Leaf { + ptr: number; + container: number; + text: string; + x: number; + right: number; + baseline: number; +} + +interface Word { + glyphs: Leaf[]; + x: number; + right: number; + baseline: number; +} + +interface RunSnapshot { + text: string; + matrixE: number; + matrixF: number; + bounds: { x: number; y: number; width: number; height: number }; + paragraphLineHeight: number; + paragraphMemberPtrs: number[]; + paragraphMemberContainers: number[]; + paragraphMemberFs: number[]; + paragraphLeafPtrs: number[]; + paragraphLeafContainers: number[]; + paragraphLineSlots: ParagraphLineSlot[]; + paragraphSoftStarts: boolean[]; + mergedFromPtrs: number[]; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + mergedFromCharStarts: number[]; + pdfiumObjPtr: number; +} + +export class ReflowWrapCommand implements Command { + readonly type = "reflow-wrap"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly maxWidthPt: number; + private applied = false; + /** Per-object translation applied, so revert can undo it exactly. */ + private moves: Array<{ ptr: number; dx: number; dy: number }> = []; + private prev: RunSnapshot | null = null; + + constructor(opts: { pageIndex: number; runId: string; maxWidthPt: number }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.maxWidthPt = opts.maxWidthPt; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + if (this.maxWidthPt <= 0) return; + // Reflow math is axis-aligned (advance +x, step -y); a rotated run reads + // along a rotated axis, so skip rather than scatter glyphs. + if (rotationFromMatrix(run.matrix)) return; + + const m = doc.module; + // Geometry + text must reflect the latest edits, and FPDFTextObj_GetText + // reads the content stream, so flush then load a text page. + page.flushGenerate(m); + const textPage = m.FPDFText_LoadPage(page.pagePtr); + let leaves: Leaf[]; + try { + leaves = extractLeaves(m, textPage, run); + } finally { + m.FPDFText_ClosePage(textPage); + } + if (leaves.length === 0) return; + + const fontSize = run.fontSize > 0 ? run.fontSize : 12; + const lineHeight = + run.paragraphLineHeight > 0 ? run.paragraphLineHeight : fontSize * 1.2; + const startX = Math.min(...leaves.map((l) => l.x)); + const topBaseline = Math.max(...leaves.map((l) => l.baseline)); + // Clamp the wrap width to the page measured from OUR OWN left edge - but + // to the edge itself, with no margin held back. The caller's width is the + // box the paragraph was already laid out in, so shaving a font-size margin + // off it wraps at LESS than the document's own measure and every line + // loses its last word: "...carry out various" drops "various" onto a line + // of its own, on lines the user never touched. + const rawRightEdge = page.display.cropLeft + page.display.cropWidth; + const maxWidth = Math.min( + this.maxWidthPt, + Math.max(fontSize * 4, rawRightEdge - startX), + ); + + // Reflow is only NEEDED when some line actually overflows the wrap width. + { + const rightByLine = new Map(); + for (const l of leaves) { + const key = Math.round(l.baseline / 2); + const prev = rightByLine.get(key); + if (prev === undefined || l.right > prev) rightByLine.set(key, l.right); + } + let overflows = false; + for (const right of rightByLine.values()) { + if (right - startX > maxWidth + 0.5) { + overflows = true; + break; + } + } + if (!overflows) return; + } + + const words = groupWords(leaves, fontSize * 0.18); + const spaceWidth = estimateSpaceWidth(words, fontSize); + // The gap that FOLLOWED this word in the document, when the next word was + // beside it on the same line. Justified text stretches its spaces line by + // line, so rebuilding every line on one median width makes the lines that + // were set tighter than the median come out wider than they were authored + // - and each one then drops its last word onto a line of its own, on lines + // the user never edited. Only a pair the reflow is genuinely joining for + // the first time needs the estimate. + const gapAfter = (index: number): number => { + const a = words[index]; + const b = words[index + 1]; + if (!a || !b) return spaceWidth; + if (Math.abs(a.baseline - b.baseline) > 2) return spaceWidth; + const gap = b.x - a.right; + return gap > 0 ? gap : spaceWidth; + }; + // Manual line breaks the user typed (Enter) live in run.text as "\n". + const hardBreaks = hardBreakNonWsCounts(run.text, run.paragraphSoftStarts); + + this.prev = snapshotRun(run); + + // Blank lines BEFORE the first word have no glyphs, so topBaseline (the + // highest glyph) is already the first CONTENT line. + const leadingBreaks = hardBreaks.get(0) ?? 0; + if (leadingBreaks > 0) hardBreaks.delete(0); + const virtualTop = topBaseline + leadingBreaks * lineHeight; + const lines: Word[][] = []; + const lineIsHardStart: boolean[] = []; + for (let k = 0; k < leadingBreaks; k++) { + lines.push([]); + lineIsHardStart.push(true); + } + lines.push([]); + // After a leading blank the content line starts at a HARD break, or the + // rebuilt text would join the blank and the content with a space. + lineIsHardStart.push(leadingBreaks > 0); + let cursorX = startX; + let lineIdx = lines.length - 1; + let cumNonWs = 0; + for (let wordIndex = 0; wordIndex < words.length; wordIndex++) { + const w = words[wordIndex]; + const width = w.right - w.x; + const wordNonWs = w.glyphs.reduce( + (n, g) => n + g.text.replace(/\s+/g, "").length, + 0, + ); + const breakCount = hardBreaks.get(cumNonWs) ?? 0; + const hardBreakHere = breakCount > 0; + // Consume the entry: a following word contributing zero non-ws chars + // (a standalone space object) must not re-apply the same break. + if (hardBreakHere) hardBreaks.delete(cumNonWs); + const widthBreak = + cursorX > startX && cursorX + width > startX + maxWidth; + if (hardBreakHere || widthBreak) { + // k consecutive newlines = k-1 blank lines + 1 content line; emit + // empties so an intentional blank line survives reflow. + for (let k = 1; k < breakCount; k++) { + lineIdx += 1; + lines.push([]); + lineIsHardStart.push(true); + } + lineIdx += 1; + lines.push([]); + lineIsHardStart.push(hardBreakHere); + cursorX = startX; + } + const targetX = cursorX; + const targetBaseline = virtualTop - lineIdx * lineHeight; + const dx = targetX - w.x; + const dy = targetBaseline - w.baseline; + if (Math.abs(dx) > 0.001 || Math.abs(dy) > 0.001) { + for (const g of w.glyphs) { + try { + transformObject(m, g.ptr, 1, 0, 0, 1, dx, dy); + } catch { + /* best-effort - stale ptr */ + } + this.moves.push({ ptr: g.ptr, dx, dy }); + g.x += dx; + g.right += dx; + g.baseline += dy; + } + } + lines[lineIdx].push(w); + cursorX = targetX + width + gapAfter(wordIndex); + cumNonWs += wordNonWs; + } + // Hard breaks AFTER the last word (Enter at paragraph end) were never + // reached by the loop, so blur silently deleted the trailing blank lines. + const trailingBreaks = hardBreaks.get(cumNonWs) ?? 0; + for (let k = 0; k < trailingBreaks; k++) { + lineIdx += 1; + lines.push([]); + lineIsHardStart.push(true); + } + + rebuildRunFromLines( + run, + lines, + lineIsHardStart, + startX, + virtualTop, + lineHeight, + fontSize, + this.prev.text, + ); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + this.applied = true; + } + + revert(doc: EditorDocument): void { + if (!this.applied || !this.prev) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + const m = doc.module; + for (let i = this.moves.length - 1; i >= 0; i--) { + const mv = this.moves[i]; + try { + transformObject(m, mv.ptr, 1, 0, 0, 1, -mv.dx, -mv.dy); + } catch { + /* best-effort */ + } + } + this.moves = []; + restoreRun(run, this.prev); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + this.applied = false; + } + + describe(): string { + return `Wrap ${this.runId}`; + } + + // Share the edit coalesce key for this run so the auto-reflow that fires on + // blur merges into the preceding typing burst's single undo step. + coalesceKey(): string { + return `edit-text:${this.pageIndex}:${this.runId}`; + } + + // The gap between the last keystroke and the blur is the user's think-time, + // so the 600ms coalesce window must not apply here. + coalesceIgnoresTimeWindow(): boolean { + return true; + } +} + +/** Read every leaf object's ACTUAL geometry + text straight from PDFium. */ +function extractLeaves( + m: WrappedPdfiumModule, + textPage: number, + run: TextRun, +): Leaf[] { + let ptrs: number[]; + let containers: number[]; + if (run.paragraphLeafPtrs.length > 0) { + ptrs = run.paragraphLeafPtrs; + containers = run.paragraphLeafContainers; + } else { + ptrs = run.mergedFromPtrs; + containers = ptrs.map(() => run.containerPtr); + } + const leaves: Leaf[] = []; + const seen = new Set(); + for (let i = 0; i < ptrs.length; i++) { + const ptr = ptrs[i]; + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + const b = readObjBounds(m, ptr); + if (!b) continue; + leaves.push({ + ptr, + container: containers[i] ?? 0, + text: readObjText(m, textPage, ptr), + x: b.x, + right: b.right, + baseline: readObjBaseline(m, ptr), + }); + } + // Reading order: top line first (higher baseline), then left-to-right. + leaves.sort((a, b) => { + if (Math.abs(a.baseline - b.baseline) > 2) return b.baseline - a.baseline; + return a.x - b.x; + }); + return leaves; +} + +/** Group consecutive same-baseline leaves into words. */ +function groupWords(leaves: Leaf[], gapThreshold: number): Word[] { + const words: Word[] = []; + let cur: Leaf[] = []; + let prev: Leaf | null = null; + const flush = () => { + if (cur.length === 0) return; + words.push({ + glyphs: cur, + x: Math.min(...cur.map((g) => g.x)), + right: Math.max(...cur.map((g) => g.right)), + baseline: cur[0].baseline, + }); + cur = []; + }; + for (const g of leaves) { + if (prev) { + const sameLine = Math.abs(g.baseline - prev.baseline) <= 2; + const gap = g.x - prev.right; + if (!sameLine || gap > gapThreshold) flush(); + } + cur.push(g); + prev = g; + } + flush(); + return words; +} + +/** The non-whitespace char counts at which `text` has a hard "\n" break. */ +function hardBreakNonWsCounts( + text: string, + softStarts?: boolean[], +): Map { + const out = new Map(); + let nonWs = 0; + let lineIndex = 0; + for (const ch of text) { + if (ch === "\n") { + lineIndex += 1; + // A break this command inserted to make the text fit is not the user's, + // so it must stay re-flowable. Reading it back as forced would freeze the + // paragraph at whatever width it happened to be wrapped to. + if (!softStarts?.[lineIndex]) out.set(nonWs, (out.get(nonWs) ?? 0) + 1); + } else if (!/\s/.test(ch)) nonWs += 1; + } + return out; +} + +/** Median inter-word gap on the original lines; falls back to ~0.3em. */ +function estimateSpaceWidth(words: Word[], fontSize: number): number { + const gaps: number[] = []; + for (let i = 1; i < words.length; i++) { + const a = words[i - 1]; + const b = words[i]; + if (Math.abs(a.baseline - b.baseline) <= 2) { + const gap = b.x - a.right; + if (gap > 0) gaps.push(gap); + } + } + if (gaps.length === 0) return fontSize * 0.3; + gaps.sort((x, y) => x - y); + return gaps[Math.floor(gaps.length / 2)]; +} + +function rebuildRunFromLines( + run: TextRun, + lines: Word[][], + lineIsHardStart: boolean[], + startX: number, + topBaseline: number, + lineHeight: number, + fontSize: number, + preReflowText: string, +): void { + const slots: ParagraphLineSlot[] = []; + const lineTexts: string[] = []; + const leafPtrs: number[] = []; + const leafContainers: number[] = []; + const memberPtrs: number[] = []; + const memberContainers: number[] = []; + const memberFs: number[] = []; + let cursorChar = 0; + let maxRight = startX; + + for (let li = 0; li < lines.length; li++) { + const lineWords = lines[li]; + const baseline = topBaseline - li * lineHeight; + const mergedFromPtrs: number[] = []; + const mergedFromTexts: string[] = []; + const mergedFromBounds: Array<{ x: number; right: number }> = []; + const mergedFromCharStarts: number[] = []; + let lineText = ""; + for (let wi = 0; wi < lineWords.length; wi++) { + const w = lineWords[wi]; + // Separate words on a line with a single space when neither side + // already carries one (per-glyph runs often embed trailing spaces). + const wText = w.glyphs.map((g) => g.text).join(""); + if (wi > 0 && !/\s$/.test(lineText) && !/^\s/.test(wText)) { + lineText += " "; + } + for (const g of w.glyphs) { + mergedFromPtrs.push(g.ptr); + mergedFromTexts.push(g.text); + mergedFromBounds.push({ x: g.x, right: g.right }); + mergedFromCharStarts.push(lineText.length); + lineText += g.text; + leafPtrs.push(g.ptr); + leafContainers.push(g.container); + if (g.right > maxRight) maxRight = g.right; + } + } + slots.push({ + startChar: cursorChar, + endChar: cursorChar + lineText.length, + baselineY: baseline, + matrixE: startX, + containerPtr: lineWords[0]?.glyphs[0]?.container ?? run.containerPtr, + fontId: run.fontId, + fontSize: run.fontSize, + fontSubset: run.fontSubset, + mergedFromPtrs, + mergedFromTexts, + mergedFromBounds, + mergedFromCharStarts, + }); + lineTexts.push(lineText); + cursorChar += lineText.length + 1; // +1 for "\n" + memberPtrs.push(lineWords[0]?.glyphs[0]?.ptr ?? 0); + memberContainers.push( + lineWords[0]?.glyphs[0]?.container ?? run.containerPtr, + ); + memberFs.push(baseline); + } + + run.paragraphLineSlots = slots; + run.paragraphLineHeight = lineHeight; + run.paragraphMemberPtrs = memberPtrs; + run.paragraphMemberContainers = memberContainers; + run.paragraphMemberFs = memberFs; + run.paragraphLeafPtrs = leafPtrs; + run.paragraphLeafContainers = leafContainers; + run.paragraphSoftStarts = lineIsHardStart.map((hard) => !hard); + // ONE "\n" per visual line, wrap-created breaks included. A soft break used + // to join with " ", which left run.text holding fewer lines than the page had + // ink for: buildExactLines then failed at the seam (the engine trims a + // wrapped line's trailing space, so the pen jumps backwards and the span + // reads NaN), `exact` came back null, and the box kept its pre-edit line + // count while the stale painted blocks were never replaced. Which breaks the + // WRAP owns is recorded in paragraphSoftStarts instead. + const glyphDerived = lineTexts + .map((t, i) => (i === 0 ? t : "\n" + t)) + .join(""); + // PDFium collapses runs of intra-line spaces in the glyph stream. + const stripWs = (s: string): string => s.replace(/\s+/g, ""); + if ( + preReflowText.length > 0 && + stripWs(glyphDerived) === stripWs(preReflowText) + ) { + const preLines = resegmentByLines(lineTexts, preReflowText); + let cursor = 0; + for (let i = 0; i < slots.length; i++) { + const preLine = preLines[i] ?? ""; + slots[i].mergedFromCharStarts = slots[i].mergedFromCharStarts.map((cs) => + posAtNonWsIndex(preLine, nonWsLen(lineTexts[i].slice(0, cs))), + ); + slots[i].startChar = cursor; + slots[i].endChar = cursor + preLine.length; + cursor += preLine.length + (i < slots.length - 1 ? 1 : 0); + } + run.text = preLines.map((t, i) => (i === 0 ? t : "\n" + t)).join(""); + } else { + run.text = glyphDerived; + } + + const s0 = slots[0]; + if (s0) { + run.mergedFromPtrs = [...s0.mergedFromPtrs]; + run.mergedFromTexts = [...s0.mergedFromTexts]; + run.mergedFromBounds = s0.mergedFromBounds.map((b) => ({ ...b })); + run.mergedFromCharStarts = [...s0.mergedFromCharStarts]; + if (s0.mergedFromPtrs.length > 0) run.pdfiumObjPtr = s0.mergedFromPtrs[0]; + } + + run.matrix = { ...run.matrix, e: startX, f: topBaseline }; + run.bounds = { + x: startX, + y: topBaseline - (lines.length - 1) * lineHeight - fontSize * 0.25, + width: Math.max(0, maxRight - startX), + height: lines.length * lineHeight + fontSize * 0.25, + }; +} + +function readObjBounds( + m: WrappedPdfiumModule, + ptr: number, +): { x: number; right: number } | null { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(ptr, l, b, r, t)) return null; + return { + x: m.pdfium.getValue(l, "float"), + right: m.pdfium.getValue(r, "float"), + }; + } catch { + return null; + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +/** The text-matrix baseline (translation `f`) - consistent across a line. */ +function readObjBaseline(m: WrappedPdfiumModule, ptr: number): number { + const buf = m.pdfium.wasmExports.malloc(6 * 4); + try { + if (!m.FPDFPageObj_GetMatrix(ptr, buf)) return 0; + return m.pdfium.getValue(buf + 20, "float"); + } catch { + return 0; + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +function readObjText( + m: WrappedPdfiumModule, + textPage: number, + ptr: number, +): string { + try { + const len = m.FPDFTextObj_GetText(ptr, textPage, 0, 0); + if (len <= 2) return ""; + const buf = m.pdfium.wasmExports.malloc(len); + try { + m.FPDFTextObj_GetText(ptr, textPage, buf, len); + return readUtf16(m, buf, len); + } finally { + m.pdfium.wasmExports.free(buf); + } + } catch { + return ""; + } +} + +function snapshotRun(run: TextRun): RunSnapshot { + return { + text: run.text, + matrixE: run.matrix.e, + matrixF: run.matrix.f, + bounds: { ...run.bounds }, + paragraphLineHeight: run.paragraphLineHeight, + paragraphMemberPtrs: [...run.paragraphMemberPtrs], + paragraphMemberContainers: [...run.paragraphMemberContainers], + paragraphMemberFs: [...run.paragraphMemberFs], + paragraphLeafPtrs: [...run.paragraphLeafPtrs], + paragraphLeafContainers: [...run.paragraphLeafContainers], + paragraphLineSlots: run.paragraphLineSlots.map(cloneSlot), + paragraphSoftStarts: [...run.paragraphSoftStarts], + mergedFromPtrs: [...run.mergedFromPtrs], + mergedFromTexts: [...run.mergedFromTexts], + mergedFromBounds: run.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...run.mergedFromCharStarts], + pdfiumObjPtr: run.pdfiumObjPtr, + }; +} + +function restoreRun(run: TextRun, prev: RunSnapshot): void { + run.text = prev.text; + run.matrix = { ...run.matrix, e: prev.matrixE, f: prev.matrixF }; + run.bounds = { ...prev.bounds }; + run.paragraphLineHeight = prev.paragraphLineHeight; + run.paragraphMemberPtrs = [...prev.paragraphMemberPtrs]; + run.paragraphMemberContainers = [...prev.paragraphMemberContainers]; + run.paragraphMemberFs = [...prev.paragraphMemberFs]; + run.paragraphLeafPtrs = [...prev.paragraphLeafPtrs]; + run.paragraphLeafContainers = [...prev.paragraphLeafContainers]; + run.paragraphLineSlots = prev.paragraphLineSlots.map(cloneSlot); + run.paragraphSoftStarts = [...prev.paragraphSoftStarts]; + run.mergedFromPtrs = [...prev.mergedFromPtrs]; + run.mergedFromTexts = [...prev.mergedFromTexts]; + run.mergedFromBounds = prev.mergedFromBounds.map((b) => ({ ...b })); + run.mergedFromCharStarts = [...prev.mergedFromCharStarts]; + run.pdfiumObjPtr = prev.pdfiumObjPtr; +} + +function cloneSlot(s: ParagraphLineSlot): ParagraphLineSlot { + return { + startChar: s.startChar, + endChar: s.endChar, + baselineY: s.baselineY, + matrixE: s.matrixE, + containerPtr: s.containerPtr, + fontId: s.fontId, + fontSize: s.fontSize, + fontSubset: s.fontSubset, + mergedFromPtrs: [...s.mergedFromPtrs], + mergedFromTexts: [...s.mergedFromTexts], + mergedFromBounds: s.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...s.mergedFromCharStarts], + }; +} + +/** Count of non-whitespace characters in a string. */ +function nonWsLen(s: string): number { + return s.replace(/\s+/g, "").length; +} + +// Position in `text` of the `idx`-th (0-based) non-whitespace char; +// `text.length` when `idx` is past the end. +function posAtNonWsIndex(text: string, idx: number): number { + let n = 0; + for (let i = 0; i < text.length; i++) { + if (!/\s/.test(text[i])) { + if (n === idx) return i; + n++; + } + } + return text.length; +} + +// Re-segment `preReflowText` into per-visual-line texts that share the same +// non-whitespace content as `lineTexts`. +function resegmentByLines( + lineTexts: string[], + preReflowText: string, +): string[] { + const out: string[] = []; + let cumNw = 0; + for (const lt of lineTexts) { + const nw = nonWsLen(lt); + if (nw === 0) { + out.push(""); + continue; + } + const start = posAtNonWsIndex(preReflowText, cumNw); + const lastPos = posAtNonWsIndex(preReflowText, cumNw + nw - 1); + out.push(preReflowText.slice(start, lastPos + 1)); + cumNw += nw; + } + return out; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/ReplaceImageCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/ReplaceImageCommand.ts new file mode 100644 index 0000000000..be778e6870 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/ReplaceImageCommand.ts @@ -0,0 +1,265 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { Affine, PageRect } from "@app/tools/pdfTextEditor/types"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import type { DecodedImage } from "@app/utils/pdfiumBitmapUtils"; +import { + embedBitmapImageOnPage, + embedJpegImageOnPage, +} from "@app/utils/pdfiumBitmapUtils"; + +interface ZOrderModule { + FPDFPage_InsertObjectAtIndex?: ( + page: number, + obj: number, + index: number, + ) => boolean; +} + +interface MatrixModule { + FPDFImageObj_SetMatrix?: ( + obj: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => boolean; + FPDFPageObj_SetMatrix?: (obj: number, matrix: number) => boolean; + pdfium?: { + setValue?: (ptr: number, value: number, type: string) => void; + wasmExports?: { + malloc?: (size: number) => number; + free?: (ptr: number) => void; + }; + }; +} + +/** Swap an image's pixels but keep its matrix, so it fills the same box. */ +interface ActivityModule { + FPDFPageObj_SetIsActive?: (obj: number, active: boolean) => boolean; +} + +// Hiding beats detaching for an object the page does not own: it is a pure +// state flip, so undo is exact and nothing changes hands. +function setActive( + m: EditorDocument["module"], + ptr: number, + active: boolean, +): void { + if (!ptr) return; + try { + (m as unknown as ActivityModule).FPDFPageObj_SetIsActive?.(ptr, active); + } catch { + /* best-effort */ + } +} + +export class ReplaceImageCommand implements Command { + readonly type = "replace-image"; + private readonly pageIndex: number; + private readonly imageId: string; + private readonly image: DecodedImage; + private readonly jpegBytes?: Uint8Array; + private prevObjPtr: number; + private prevMatrix: Affine | null; + private prevBounds: PageRect | null; + private prevIndex: number; + private nextObjPtr: number; + + constructor(opts: { + pageIndex: number; + imageId: string; + image: DecodedImage; + jpegBytes?: Uint8Array; + }) { + this.pageIndex = opts.pageIndex; + this.imageId = opts.imageId; + this.image = opts.image; + this.jpegBytes = opts.jpegBytes; + this.prevObjPtr = 0; + this.prevMatrix = null; + this.prevBounds = null; + this.prevIndex = -1; + this.nextObjPtr = 0; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img || !img.pdfiumObjPtr) return; + const m = doc.module; + // A form-nested original cannot be detached and put back (this build has + // no FPDFFormObj_InsertObject), so hide it in place instead and draw the + // replacement at page level using its already-composed page-space matrix. + const nested = img.containerPtr !== 0; + if (this.prevMatrix === null || this.prevBounds === null) { + this.prevObjPtr = img.pdfiumObjPtr; + this.prevMatrix = { ...img.matrix }; + this.prevBounds = { ...img.bounds }; + this.prevIndex = objectIndex(m, page.pagePtr, this.prevObjPtr); + } + const matrix = this.prevMatrix; + const box = this.prevBounds; + // Redo: revert only detached the replacement, so re-attach that same + // object rather than embedding the pixels a second time. + if (this.nextObjPtr) { + if (nested) setActive(m, this.prevObjPtr, false); + else m.FPDFPage_RemoveObject(page.pagePtr, this.prevObjPtr); + insertObjectAt(m, page.pagePtr, this.nextObjPtr, this.prevIndex); + this.adopt(page, img, this.nextObjPtr); + return; + } + let objPtr = this.jpegBytes + ? embedJpegImageOnPage( + m, + doc.docPtr, + page.pagePtr, + this.jpegBytes, + box.x, + box.y, + box.width, + box.height, + ) + : 0; + if (!objPtr) { + objPtr = embedBitmapImageOnPage( + m, + doc.docPtr, + page.pagePtr, + this.image, + box.x, + box.y, + box.width, + box.height, + ); + } + // Embedding failed - leave the page exactly as it was. + if (!objPtr) return; + // The embed helpers write an axis-aligned (w,0,0,h,x,y) box, which flips + // the image on a rotated page; the captured matrix is the truth here. + setImageMatrix(m, objPtr, matrix); + // Detach only: the old object carries the original pixels for undo, so + // destroying it would leave this command's undo entry pointing at free memory. + if (nested) setActive(m, this.prevObjPtr, false); + else m.FPDFPage_RemoveObject(page.pagePtr, this.prevObjPtr); + // The embed appended, so without this the replacement jumps to the top. + if (this.prevIndex >= 0 && supportsInsertAtIndex(m)) { + m.FPDFPage_RemoveObject(page.pagePtr, objPtr); + insertObjectAt(m, page.pagePtr, objPtr, this.prevIndex); + } + this.nextObjPtr = objPtr; + this.adopt(page, img, objPtr); + } + + revert(doc: EditorDocument): void { + if (!this.nextObjPtr || !this.prevObjPtr) return; + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img) return; + const m = doc.module; + // Detach only again: the replacement is what redo re-attaches. + m.FPDFPage_RemoveObject(page.pagePtr, this.nextObjPtr); + if (img.containerPtr) setActive(m, this.prevObjPtr, true); + else insertObjectAt(m, page.pagePtr, this.prevObjPtr, this.prevIndex); + this.adopt(page, img, this.prevObjPtr); + } + + private adopt(page: Page, img: ImageObject, objPtr: number): void { + img.pdfiumObjPtr = objPtr; + if (this.prevMatrix) img.matrix = { ...this.prevMatrix }; + if (this.prevBounds) img.bounds = { ...this.prevBounds }; + img.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } +} + +function objectIndex( + m: WrappedPdfiumModule, + pagePtr: number, + objPtr: number, +): number { + const total = m.FPDFPage_CountObjects(pagePtr); + for (let i = 0; i < total; i++) { + if (m.FPDFPage_GetObject(pagePtr, i) === objPtr) return i; + } + return -1; +} + +function supportsInsertAtIndex(m: WrappedPdfiumModule): boolean { + return ( + typeof (m as unknown as ZOrderModule).FPDFPage_InsertObjectAtIndex === + "function" + ); +} + +/** Re-attach a detached object at `index`, appending when that is unavailable. */ +function insertObjectAt( + m: WrappedPdfiumModule, + pagePtr: number, + objPtr: number, + index: number, +): void { + const insertAt = (m as unknown as ZOrderModule).FPDFPage_InsertObjectAtIndex; + if (typeof insertAt === "function" && index >= 0) { + try { + if (insertAt.call(m, pagePtr, objPtr, index)) return; + } catch { + /* fall through to append */ + } + } + m.FPDFPage_InsertObject(pagePtr, objPtr); +} + +function setImageMatrix( + m: WrappedPdfiumModule, + objPtr: number, + matrix: Affine, +): void { + const mod = m as unknown as MatrixModule; + const direct = mod.FPDFImageObj_SetMatrix; + if (typeof direct === "function") { + try { + const ok = direct.call( + m, + objPtr, + matrix.a, + matrix.b, + matrix.c, + matrix.d, + matrix.e, + matrix.f, + ); + if (ok) return; + } catch { + /* fall through to the struct setter */ + } + } + writeMatrixStruct(mod, objPtr, matrix); +} + +/** FS_MATRIX fallback for builds without the scalar `FPDFImageObj_SetMatrix`. */ +function writeMatrixStruct( + mod: MatrixModule, + objPtr: number, + matrix: Affine, +): void { + const setter = mod.FPDFPageObj_SetMatrix; + const rt = mod.pdfium; + if (!setter || !rt?.setValue || !rt.wasmExports?.malloc) return; + const ptr = rt.wasmExports.malloc(6 * 4); + if (!ptr) return; + const values = [matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f]; + try { + values.forEach((v, i) => rt.setValue?.(ptr + i * 4, v, "float")); + setter(objPtr, ptr); + } catch { + /* best-effort */ + } finally { + rt.wasmExports.free?.(ptr); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/SetColourCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetColourCommand.ts new file mode 100644 index 0000000000..8d1e3309d4 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetColourCommand.ts @@ -0,0 +1,117 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { RGBA } from "@app/tools/pdfTextEditor/types"; +import { PdfiumTextWriter } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextWriter"; +import { collectMemberPtrs } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +export class SetColourCommand implements Command { + readonly type = "set-colour"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly nextFill: RGBA; + private prevFill: RGBA | null; + /** Each member object's OWN pre-apply fill. */ + private prevMemberFills: Array<{ ptr: number; fill: RGBA }> | null; + + constructor(opts: { pageIndex: number; runId: string; nextFill: RGBA }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.nextFill = opts.nextFill; + this.prevFill = null; + this.prevMemberFills = null; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + if (this.prevFill === null) { + this.prevFill = { ...run.fill }; + const m = doc.module; + const seen = new Set(); + this.prevMemberFills = []; + for (const ptr of collectMemberPtrs(run)) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + this.prevMemberFills.push({ + ptr, + fill: readObjFill(m, ptr) ?? { ...run.fill }, + }); + } + } + run.fill = { ...this.nextFill }; + run.dirty = true; + page.markDirty(); + PdfiumTextWriter.commitRunFill(doc, page, run); + } + + revert(doc: EditorDocument): void { + if (this.prevFill === null) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + run.fill = { ...this.prevFill }; + run.dirty = true; + page.markDirty(); + // Restore each member's own colour rather than stamping the rep fill + // over the whole group. + const m = doc.module; + let restoredAny = false; + for (const entry of this.prevMemberFills ?? []) { + try { + m.FPDFPageObj_SetFillColor( + entry.ptr, + entry.fill.r, + entry.fill.g, + entry.fill.b, + entry.fill.a, + ); + restoredAny = true; + } catch { + /* best-effort - stale ptrs silently skipped */ + } + } + if (restoredAny) page.markNeedsGenerate(); + else PdfiumTextWriter.commitRunFill(doc, page, run); + } + + /** One colour-picker DRAG fires dozens of commands. */ + coalesceKey(): string { + return "set-colour"; + } + + describe(): string { + return `Set colour on ${this.runId}`; + } +} + +/** Read an object's current fill colour (0-255 RGBA), or null on failure. */ +function readObjFill( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + objPtr: number, +): RGBA | null { + const exports = m.pdfium.wasmExports as unknown as { + malloc: (n: number) => number; + free: (p: number) => void; + }; + const r = exports.malloc(4); + const g = exports.malloc(4); + const b = exports.malloc(4); + const a = exports.malloc(4); + try { + if (!m.FPDFPageObj_GetFillColor(objPtr, r, g, b, a)) return null; + return { + r: m.pdfium.getValue(r, "i32"), + g: m.pdfium.getValue(g, "i32"), + b: m.pdfium.getValue(b, "i32"), + a: m.pdfium.getValue(a, "i32"), + }; + } catch { + return null; + } finally { + exports.free(r); + exports.free(g); + exports.free(b); + exports.free(a); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontFamilyCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontFamilyCommand.ts new file mode 100644 index 0000000000..5d5e05f286 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontFamilyCommand.ts @@ -0,0 +1,251 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { + cloneParagraphLineSlot, + type ParagraphLineSlot, + type TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; +import { + collectContainersByPtr, + collectMemberPtrs, + emitRunLines, + planLineOrigins, + removeMemberPtrs, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { deviceFontEmitCount } from "@app/tools/pdfTextEditor/util/deviceFontEmbed"; + +// Re-emit a run's text in another family: PDFium has no SetFont accessor. +// Device fonts embed when pre-warmed, else the nearest standard face. +export class SetFontFamilyCommand implements Command { + readonly type = "set-font-family"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly nextFamily: string; + /** Full pre-edit model snapshot for revert. */ + private prev: RunModelSnapshot | null; + /** Original on-page member ptrs (re-inserted on revert). */ + private prevMemberPtrs: number[]; + /** Every object this command created (removed on revert). */ + private createdPtrs: number[]; + + constructor(opts: { pageIndex: number; runId: string; nextFamily: string }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.nextFamily = opts.nextFamily; + this.prev = null; + this.prevMemberPtrs = []; + this.createdPtrs = []; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + const m = doc.module; + + if (this.prev === null) { + this.prev = snapshotRun(run); + this.prevMemberPtrs = collectMemberPtrs(run).slice(); + } + + // Detach every original object so the page stops painting them. + removeMemberPtrs( + m, + page, + this.prevMemberPtrs, + collectContainersByPtr(run), + run.containerPtr, + ); + + // Re-emit one base-14 object per visual line at descending baselines. + const lineHeight = + run.paragraphLineHeight > 0 + ? run.paragraphLineHeight + : run.fontSize * 1.2; + // Prefer per-line SLOT ranges: run.text joins SOFT-wrapped lines with + // separators a \n split can't see. Baseline stepping for the fallback case + // comes from planLineOrigins so it cannot drift from EditTextCommand's. + const slots = run.paragraphLineSlots; + const splitTexts = slots.length > 0 ? null : run.text.split(/\r?\n/); + const emitLines: Array<{ text: string; x: number; y: number }> = splitTexts + ? (() => { + const origins = planLineOrigins(run, splitTexts.length, lineHeight); + return splitTexts.map((text, i) => ({ text, ...origins[i] })); + })() + : slots.map((s) => ({ + text: run.text + .slice( + Math.max(0, s.startChar), + Math.min(run.text.length, s.endChar), + ) + .replace(/[\r\n]+$/, ""), + x: s.matrixE, + y: s.baselineY, + })); + const lineAnchors: number[] = []; + const memberFs: number[] = []; + const leaf: number[] = []; + const created: number[] = []; + // Emits with the embedded device face are counted, so the font id below + // can say what actually rendered rather than what was requested. + const deviceEmitsBefore = deviceFontEmitCount(doc, this.nextFamily); + const emitted = emitRunLines({ + doc, + page, + run, + lines: emitLines.map((l) => l.text), + origins: emitLines.map((l) => ({ x: l.x, y: l.y })), + originalFontPtr: 0, // base-14: never reuse the source font + fallbackFamily: this.nextFamily, + }); + for (const line of emitted) { + memberFs.push(line.y); + if (line.ptrs.length === 0) { + lineAnchors.push(0); + continue; + } + lineAnchors.push(line.ptrs[0]); + leaf.push(...line.ptrs); + created.push(...line.ptrs); + } + + if (created.length === 0) { + // Nothing emitted (e.g. all-whitespace dropped) - restore and bail. + this.reinsertOriginals(m, page); + restoreRun(run, this.prev); + // Neutralise the command: it still lands in history, and a revert with + // `prev` set would reinsert the originals a SECOND time. + this.prev = null; + return; + } + + this.createdPtrs = created; + run.pdfiumObjPtr = lineAnchors.find((p) => p) ?? leaf[0]; + // `device:` marks glyphs from an embedded device font; a substituted run + // keeps `base14:`, so nothing keying off that prefix changes meaning. + const embedded = + deviceFontEmitCount(doc, this.nextFamily) > deviceEmitsBefore; + run.fontId = `${embedded ? "device" : "base14"}:${this.nextFamily}`; + run.fontSubset = false; + // Reset ALL model bookkeeping to the freshly-emitted objects so later + // commands act on the live objects, not the removed originals. + run.mergedFromPtrs = []; + run.mergedFromTexts = []; + run.mergedFromBounds = []; + run.mergedFromCharStarts = []; + run.paragraphLineSlots = []; + // Track every per-word leaf so later recolour/resize/move hit all words, + // not just the anchor. Line height stays paragraph-only (>1 line). + run.paragraphMemberPtrs = lineAnchors; + run.paragraphMemberContainers = lineAnchors.map(() => 0); + run.paragraphMemberFs = memberFs; + run.paragraphLeafPtrs = leaf; + run.paragraphLeafContainers = leaf.map(() => 0); + if (emitLines.length > 1) { + run.paragraphLineHeight = lineHeight; + } + run.containerPtr = 0; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (!this.prev) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + const m = doc.module; + + for (const ptr of this.createdPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + this.createdPtrs = []; + this.reinsertOriginals(m, page); + restoreRun(run, this.prev); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + private reinsertOriginals( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + page: import("@app/tools/pdfTextEditor/model/Page").Page, + ): void { + for (const ptr of this.prevMemberPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_InsertObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + } +} + +interface RunModelSnapshot { + text: string; + fontId: string; + fontSubset: boolean; + fill: { r: number; g: number; b: number; a: number }; + pdfiumObjPtr: number; + containerPtr: number; + mergedFromPtrs: number[]; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + mergedFromCharStarts: number[]; + paragraphMemberPtrs: number[]; + paragraphMemberContainers: number[]; + paragraphMemberFs: number[]; + paragraphLeafPtrs: number[]; + paragraphLeafContainers: number[]; + paragraphLineSlots: ParagraphLineSlot[]; + paragraphLineHeight: number; +} + +function snapshotRun(run: TextRun): RunModelSnapshot { + return { + text: run.text, + fontId: run.fontId, + fontSubset: run.fontSubset, + fill: { ...run.fill }, + pdfiumObjPtr: run.pdfiumObjPtr, + containerPtr: run.containerPtr, + mergedFromPtrs: [...run.mergedFromPtrs], + mergedFromTexts: [...run.mergedFromTexts], + mergedFromBounds: run.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...run.mergedFromCharStarts], + paragraphMemberPtrs: [...run.paragraphMemberPtrs], + paragraphMemberContainers: [...run.paragraphMemberContainers], + paragraphMemberFs: [...run.paragraphMemberFs], + paragraphLeafPtrs: [...run.paragraphLeafPtrs], + paragraphLeafContainers: [...run.paragraphLeafContainers], + paragraphLineSlots: run.paragraphLineSlots.map(cloneParagraphLineSlot), + paragraphLineHeight: run.paragraphLineHeight, + }; +} + +function restoreRun(run: TextRun, snap: RunModelSnapshot): void { + run.text = snap.text; + run.fontId = snap.fontId; + run.fontSubset = snap.fontSubset; + run.fill = { ...snap.fill }; + run.pdfiumObjPtr = snap.pdfiumObjPtr; + run.containerPtr = snap.containerPtr; + run.mergedFromPtrs = [...snap.mergedFromPtrs]; + run.mergedFromTexts = [...snap.mergedFromTexts]; + run.mergedFromBounds = snap.mergedFromBounds.map((b) => ({ ...b })); + run.mergedFromCharStarts = [...snap.mergedFromCharStarts]; + run.paragraphMemberPtrs = [...snap.paragraphMemberPtrs]; + run.paragraphMemberContainers = [...snap.paragraphMemberContainers]; + run.paragraphMemberFs = [...snap.paragraphMemberFs]; + run.paragraphLeafPtrs = [...snap.paragraphLeafPtrs]; + run.paragraphLeafContainers = [...snap.paragraphLeafContainers]; + run.paragraphLineSlots = snap.paragraphLineSlots.map(cloneParagraphLineSlot); + run.paragraphLineHeight = snap.paragraphLineHeight; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontSizeCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontSizeCommand.ts new file mode 100644 index 0000000000..dc53a25ee7 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontSizeCommand.ts @@ -0,0 +1,163 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { collectMemberPtrs } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { transformObject } from "@app/tools/pdfTextEditor/util/objectTransform"; + +/** Scale a text run so its effective on-page size matches `nextSize`. */ +export class SetFontSizeCommand implements Command { + readonly type = "set-font-size"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly nextSize: number; + private prevSize: number | null; + + constructor(opts: { pageIndex: number; runId: string; nextSize: number }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.nextSize = opts.nextSize; + this.prevSize = null; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run || !run.pdfiumObjPtr) return; + if (this.prevSize === null) { + this.prevSize = run.fontSize; + } + const ratio = this.nextSize / Math.max(0.01, run.fontSize); + // Scale about the run's own baseline anchor, NOT the page origin - scaling + // about moves the glyphs diagonally and the move persists on save. + this.scaleAllPtrs( + doc, + collectMemberPtrs(run), + ratio, + run.matrix.e, + run.matrix.f, + ); + run.fontSize = this.nextSize; + run.matrix = scaleMatrix( + run.matrix, + this.nextSize / Math.max(0.01, this.prevSize), + ); + rescaleRunModel(run, ratio, run.matrix.e, run.matrix.f); + // The glyph gaps scale with the glyphs, so the tracked letter-spacing + // must scale too or a later edit re-emits with the stale pt value. + run.charSpacingPt *= ratio; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (this.prevSize === null) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run || !run.pdfiumObjPtr) return; + const ratio = this.prevSize / Math.max(0.01, run.fontSize); + this.scaleAllPtrs( + doc, + collectMemberPtrs(run), + ratio, + run.matrix.e, + run.matrix.f, + ); + run.fontSize = this.prevSize; + run.matrix = scaleMatrix(run.matrix, ratio); + rescaleRunModel(run, ratio, run.matrix.e, run.matrix.f); + run.charSpacingPt *= ratio; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + private scaleAllPtrs( + doc: EditorDocument, + ptrs: number[], + relativeScale: number, + anchorX: number, + anchorY: number, + ): void { + if (!Number.isFinite(relativeScale) || relativeScale === 1) return; + const m = doc.module; + // Scale about (anchorX, anchorY): translate(-a) · scale(s) · translate(+a) + // collapses to [s,0,0,s, ax*(1-s), ay*(1-s)] - a single Transform call. + const tx = anchorX * (1 - relativeScale); + const ty = anchorY * (1 - relativeScale); + const seen = new Set(); + for (const ptr of ptrs) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + try { + transformObject( + m, + + ptr, + relativeScale, + 0, + 0, + relativeScale, + tx, + ty, + ); + } catch { + /* best-effort - missing ptr is silently skipped */ + } + } + } + + /** The stepper fires per tick; coalesce so one adjustment is one undo step. */ + coalesceKey(): string { + return `set-font-size:${this.pageIndex}:${this.runId}`; + } +} + +/** Mirror the PDFium object scaling in the run's model bookkeeping. */ +function rescaleRunModel( + run: import("@app/tools/pdfTextEditor/model/TextRun").TextRun, + s: number, + ax: number, + ay: number, +): void { + if (!Number.isFinite(s) || s === 1) return; + const mapX = (x: number) => s * x + (1 - s) * ax; + const mapY = (y: number) => s * y + (1 - s) * ay; + run.bounds = { + x: mapX(run.bounds.x), + y: mapY(run.bounds.y), + width: run.bounds.width * s, + height: run.bounds.height * s, + }; + run.mergedFromBounds = run.mergedFromBounds.map((b) => ({ + x: mapX(b.x), + right: mapX(b.right), + })); + run.paragraphMemberFs = run.paragraphMemberFs.map(mapY); + if (run.paragraphLineHeight > 0) run.paragraphLineHeight *= s; + for (const slot of run.paragraphLineSlots) { + slot.baselineY = mapY(slot.baselineY); + slot.matrixE = mapX(slot.matrixE); + slot.fontSize *= s; + slot.mergedFromBounds = slot.mergedFromBounds.map((b) => ({ + x: mapX(b.x), + right: mapX(b.right), + })); + } +} + +function scaleMatrix( + m: { a: number; b: number; c: number; d: number; e: number; f: number }, + ratio: number, +) { + if (!Number.isFinite(ratio) || ratio === 1) return m; + // Only the scale part changes; the anchor (e,f) stays put so the run keeps + // its on-page position (matches the anchored object Transform above). + return { + a: m.a * ratio, + b: m.b * ratio, + c: m.c * ratio, + d: m.d * ratio, + e: m.e, + f: m.f, + }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/SetImageTransformCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetImageTransformCommand.ts new file mode 100644 index 0000000000..db1af1a3b0 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetImageTransformCommand.ts @@ -0,0 +1,124 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Affine, PageRect } from "@app/tools/pdfTextEditor/types"; +import { + imageMatrixBounds, + remapImageMatrix, +} from "@app/tools/pdfTextEditor/model/affine"; +import { retargetClipPath } from "@app/tools/pdfTextEditor/util/objectTransform"; + +/** Set an image object's transform to an absolute target. */ +export class SetImageTransformCommand implements Command { + readonly type = "set-image-transform"; + private readonly pageIndex: number; + private readonly imageId: string; + private readonly nextBounds: PageRect; + private prevBounds: PageRect | null; + private prevMatrix: Affine | null; + + constructor(opts: { + pageIndex: number; + imageId: string; + nextBounds: PageRect; + }) { + this.pageIndex = opts.pageIndex; + this.imageId = opts.imageId; + this.nextBounds = opts.nextBounds; + this.prevBounds = null; + this.prevMatrix = null; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img || !img.pdfiumObjPtr) return; + let prevBounds = this.prevBounds; + let prevMatrix = this.prevMatrix; + if (prevBounds === null || prevMatrix === null) { + prevBounds = { ...img.bounds }; + prevMatrix = { ...img.matrix }; + this.prevBounds = prevBounds; + this.prevMatrix = prevMatrix; + } + // Remap the image's display AABB from prevBounds -> nextBounds while + // keeping the orientation/aspect of prevMatrix. + const next = remapImageMatrix( + prevMatrix, + prevBounds, + this.nextBounds, + page.display, + ); + setMatrix(doc, img.pdfiumObjPtr, next); + retargetClipPath(doc.module, img.pdfiumObjPtr, prevMatrix, next); + img.matrix = next; + img.bounds = imageMatrixBounds(next); + img.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (!this.prevBounds || !this.prevMatrix) return; + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img || !img.pdfiumObjPtr) return; + // Restore the captured matrix exactly (preserves any rotation / + // shear that wasn't expressed in the simple bounds form). + const m = doc.module; + const fn = ( + m as unknown as { + FPDFImageObj_SetMatrix?: ( + obj: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => boolean; + } + ).FPDFImageObj_SetMatrix; + if (!fn) return; + try { + fn( + img.pdfiumObjPtr, + this.prevMatrix.a, + this.prevMatrix.b, + this.prevMatrix.c, + this.prevMatrix.d, + this.prevMatrix.e, + this.prevMatrix.f, + ); + } catch { + /* best-effort */ + } + retargetClipPath(m, img.pdfiumObjPtr, img.matrix, this.prevMatrix); + img.bounds = { ...this.prevBounds }; + img.matrix = { ...this.prevMatrix }; + img.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } +} + +function setMatrix(doc: EditorDocument, objPtr: number, m: Affine): void { + const fn = ( + doc.module as unknown as { + FPDFImageObj_SetMatrix?: ( + obj: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => boolean; + } + ).FPDFImageObj_SetMatrix; + if (!fn) return; + try { + fn(objPtr, m.a, m.b, m.c, m.d, m.e, m.f); + } catch { + /* best-effort */ + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/SetLockCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetLockCommand.ts new file mode 100644 index 0000000000..07f48cbd59 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetLockCommand.ts @@ -0,0 +1,62 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +/** Toggle the session-only `locked` flag on a text run or image object. */ +export class SetLockCommand implements Command { + readonly type = "set-lock"; + private readonly pageIndex: number; + private readonly runId: string | null; + private readonly imageId: string | null; + private readonly nextLocked: boolean; + private prevLocked: boolean | null; + + constructor(opts: { + pageIndex: number; + runId?: string; + imageId?: string; + locked: boolean; + }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId ?? null; + this.imageId = opts.imageId ?? null; + this.nextLocked = opts.locked; + this.prevLocked = null; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + if (this.runId) { + const run = page.runs.find((r) => r.id === this.runId); + if (!run) return; + if (this.prevLocked === null) this.prevLocked = run.locked; + run.locked = this.nextLocked; + // Refresh the overlay snapshot so contentEditable/hit-test reflect + // the new lock state; lock is session-only, never dirties the page. + page.bumpRevision(); + return; + } + if (this.imageId) { + const img = page.images.find((i) => i.id === this.imageId); + if (!img) return; + if (this.prevLocked === null) this.prevLocked = img.locked; + img.locked = this.nextLocked; + page.bumpRevision(); + } + } + + revert(doc: EditorDocument): void { + if (this.prevLocked === null) return; + const page = doc.page(this.pageIndex); + if (this.runId) { + const run = page.runs.find((r) => r.id === this.runId); + if (run) run.locked = this.prevLocked; + page.bumpRevision(); + return; + } + if (this.imageId) { + const img = page.images.find((i) => i.id === this.imageId); + if (img) img.locked = this.prevLocked; + page.bumpRevision(); + } + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/SetTextOutlineCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetTextOutlineCommand.ts new file mode 100644 index 0000000000..9c69dc47ed --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetTextOutlineCommand.ts @@ -0,0 +1,223 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { RGBA } from "@app/tools/pdfTextEditor/types"; +import { + applyInkState, + collectMemberPtrs, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +/** Render modes that paint an outline; 0 is fill-only, 3 is invisible. */ +const FILL_ONLY = 0; +const FILL_AND_STROKE = 2; +/** Stroke-only (1) has to fall back to fill, or clearing hides the text. */ +const STROKING_MODES = new Set([1, 2]); + +interface MemberInk { + ptr: number; + renderMode: number; + stroke: RGBA | null; + strokeWidth: number; +} + +// Outline a run's glyphs, or clear it. Width alone is invisible, so this also +// moves the run between fill-only and fill-and-stroke render modes. +export class SetTextOutlineCommand implements Command { + readonly type = "set-text-outline"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly nextStroke: RGBA | null; + private readonly nextWidth: number; + private prev: { + renderMode: number; + stroke: RGBA | null; + strokeWidth: number; + members: MemberInk[]; + } | null = null; + + constructor(opts: { + pageIndex: number; + runId: string; + /** Null clears the outline entirely. */ + stroke: RGBA | null; + width: number; + }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.nextStroke = opts.stroke ? { ...opts.stroke } : null; + this.nextWidth = Math.max(0, opts.width); + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + + if (this.prev === null) { + const seen = new Set(); + const members: MemberInk[] = []; + for (const ptr of collectMemberPtrs(run)) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + members.push(readMemberInk(doc, ptr, run)); + } + this.prev = { + renderMode: run.renderMode, + stroke: run.stroke ? { ...run.stroke } : null, + strokeWidth: run.strokeWidth, + members, + }; + } + + const outlined = this.nextStroke !== null && this.nextWidth > 0; + // An invisible OCR layer stays invisible, and a clipping mode (4-7) keeps + // clipping - changing either would alter far more than an outline. + const preserveMode = run.renderMode === 3 || run.renderMode >= 4; + const nextMode = preserveMode + ? run.renderMode + : outlined + ? FILL_AND_STROKE + : STROKING_MODES.has(this.prev.renderMode) + ? FILL_ONLY + : run.renderMode; + + run.stroke = outlined && this.nextStroke ? { ...this.nextStroke } : null; + run.strokeWidth = outlined ? this.nextWidth : 0; + run.renderMode = nextMode; + run.dirty = true; + page.markDirty(); + this.writeMembers(doc, run, nextMode); + } + + revert(doc: EditorDocument): void { + const snapshot = this.prev; + if (!snapshot) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + run.renderMode = snapshot.renderMode; + run.stroke = snapshot.stroke ? { ...snapshot.stroke } : null; + run.strokeWidth = snapshot.strokeWidth; + run.dirty = true; + page.markDirty(); + // Each member kept its own ink, exactly as with fills: a merged line can + // hold objects that were not all outlined the same way. + for (const member of snapshot.members) { + applyInkState(doc.module, [member.ptr], { + renderMode: member.renderMode, + stroke: member.stroke, + strokeWidth: member.strokeWidth, + }); + if (!member.stroke) clearStroke(doc, member.ptr); + } + page.markNeedsGenerate(); + } + + private writeMembers( + doc: EditorDocument, + run: { stroke: RGBA | null; strokeWidth: number }, + mode: number, + ): void { + const seen = new Set(); + for (const ptr of collectMemberPtrs(run as never)) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + applyInkState(doc.module, [ptr], { + renderMode: mode, + stroke: run.stroke, + strokeWidth: run.strokeWidth, + }); + if (!run.stroke) clearStroke(doc, ptr); + } + doc.page(this.pageIndex).markNeedsGenerate(); + } + + /** One width-stepper drag must not fill the undo stack. */ + coalesceKey(): string { + return "set-text-outline"; + } + + describe(): string { + return `Set outline on ${this.runId}`; + } +} + +interface OutlineModule { + FPDFPageObj_GetStrokeColor?: ( + obj: number, + r: number, + g: number, + b: number, + a: number, + ) => boolean; + FPDFPageObj_GetStrokeWidth?: (obj: number, out: number) => boolean; + FPDFPageObj_SetStrokeColor?: ( + obj: number, + r: number, + g: number, + b: number, + a: number, + ) => boolean; + FPDFPageObj_SetStrokeWidth?: (obj: number, width: number) => boolean; + FPDFTextObj_GetTextRenderMode?: (obj: number) => number; +} + +function readMemberInk( + doc: EditorDocument, + ptr: number, + fallback: { renderMode: number; stroke: RGBA | null; strokeWidth: number }, +): MemberInk { + const m = doc.module; + const mod = m as unknown as OutlineModule; + let renderMode = fallback.renderMode; + try { + const v = mod.FPDFTextObj_GetTextRenderMode?.(ptr); + if (typeof v === "number" && v >= 0 && v <= 7) renderMode = v; + } catch { + /* keep the run-level value */ + } + const exports = m.pdfium.wasmExports as unknown as { + malloc: (n: number) => number; + free: (p: number) => void; + }; + const r = exports.malloc(4); + const g = exports.malloc(4); + const b = exports.malloc(4); + const a = exports.malloc(4); + const w = exports.malloc(4); + try { + let stroke: RGBA | null = null; + if (mod.FPDFPageObj_GetStrokeColor?.(ptr, r, g, b, a)) { + stroke = { + r: m.pdfium.getValue(r, "i32") & 0xff, + g: m.pdfium.getValue(g, "i32") & 0xff, + b: m.pdfium.getValue(b, "i32") & 0xff, + a: m.pdfium.getValue(a, "i32") & 0xff, + }; + } + let strokeWidth = 0; + if (mod.FPDFPageObj_GetStrokeWidth?.(ptr, w)) { + const raw = m.pdfium.getValue(w, "float"); + if (Number.isFinite(raw) && raw > 0) strokeWidth = raw; + } + return { ptr, renderMode, stroke, strokeWidth }; + } catch { + return { ptr, renderMode, stroke: null, strokeWidth: 0 }; + } finally { + exports.free(r); + exports.free(g); + exports.free(b); + exports.free(a); + exports.free(w); + } +} + +/** A zero-width transparent stroke is how PDFium expresses "no outline". */ +function clearStroke(doc: EditorDocument, ptr: number): void { + const mod = doc.module as unknown as OutlineModule; + try { + mod.FPDFPageObj_SetStrokeWidth?.(ptr, 0); + mod.FPDFPageObj_SetStrokeColor?.(ptr, 0, 0, 0, 0); + } catch { + /* best-effort */ + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/TransformImageObjectCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/TransformImageObjectCommand.ts new file mode 100644 index 0000000000..48455ceb90 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/TransformImageObjectCommand.ts @@ -0,0 +1,158 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Affine } from "@app/tools/pdfTextEditor/types"; +import { retargetClipPath } from "@app/tools/pdfTextEditor/util/objectTransform"; + +// Apply an in-place transform to an image: rotate by 90° (CW or CCW), flip +// horizontally, or flip vertically. +export type ImageTransformMode = + | "rotate-cw" + | "rotate-ccw" + | "flip-h" + | "flip-v"; + +interface ImageMatrixSetterModule { + FPDFImageObj_SetMatrix?: ( + obj: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => boolean; +} + +export class TransformImageObjectCommand implements Command { + readonly type = "transform-image"; + private readonly pageIndex: number; + private readonly imageId: string; + private readonly mode: ImageTransformMode; + private prevMatrix: Affine | null; + + constructor(opts: { + pageIndex: number; + imageId: string; + mode: ImageTransformMode; + }) { + this.pageIndex = opts.pageIndex; + this.imageId = opts.imageId; + this.mode = opts.mode; + this.prevMatrix = null; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img || !img.pdfiumObjPtr) return; + if (this.prevMatrix === null) this.prevMatrix = { ...img.matrix }; + const next = composeAboutCentre(img.matrix, this.mode); + setMatrix(doc, img.pdfiumObjPtr, next); + retargetClipPath(doc.module, img.pdfiumObjPtr, img.matrix, next); + img.matrix = next; + img.bounds = matrixBoundsAxisAligned(next); + img.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (!this.prevMatrix) return; + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img || !img.pdfiumObjPtr) return; + setMatrix(doc, img.pdfiumObjPtr, this.prevMatrix); + retargetClipPath(doc.module, img.pdfiumObjPtr, img.matrix, this.prevMatrix); + img.matrix = { ...this.prevMatrix }; + img.bounds = matrixBoundsAxisAligned(this.prevMatrix); + img.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } +} + +// Compose `T(cx, cy) * Op * T(-cx, -cy) * M` where M is the input matrix, Op is +// the rotation/flip, and (cx, cy) is M's image-centre in page space. +function composeAboutCentre(m: Affine, mode: ImageTransformMode): Affine { + const cx = m.e + (m.a + m.c) / 2; + const cy = m.f + (m.b + m.d) / 2; + // Op transforms image-space (post-rotation/flip is applied to page-space + // output). + let oa: number, ob: number, oc: number, od: number; + switch (mode) { + case "rotate-ccw": + oa = 0; + ob = 1; + oc = -1; + od = 0; + break; + case "rotate-cw": + oa = 0; + ob = -1; + oc = 1; + od = 0; + break; + case "flip-h": + oa = -1; + ob = 0; + oc = 0; + od = 1; + break; + case "flip-v": + oa = 1; + ob = 0; + oc = 0; + od = -1; + break; + } + // M' = T * O * T * M = Concretely: new_a = oa*m.a + oc*m.b new_b = ob*m.a + + // od*m.b new_c = oa*m.c + oc*m.d new_d = ob*m.c + od*m.d. + return { + a: oa * m.a + oc * m.b, + b: ob * m.a + od * m.b, + c: oa * m.c + oc * m.d, + d: ob * m.c + od * m.d, + e: oa * (m.e - cx) + oc * (m.f - cy) + cx, + f: ob * (m.e - cx) + od * (m.f - cy) + cy, + }; +} + +// Axis-aligned bounding box of the image's projected 1x1 square under matrix m. +function matrixBoundsAxisAligned(m: Affine): { + x: number; + y: number; + width: number; + height: number; +} { + const corners: Array<[number, number]> = [ + [0, 0], + [1, 0], + [0, 1], + [1, 1], + ]; + const xs: number[] = []; + const ys: number[] = []; + for (const [u, v] of corners) { + xs.push(m.a * u + m.c * v + m.e); + ys.push(m.b * u + m.d * v + m.f); + } + const minX = Math.min(...xs); + const minY = Math.min(...ys); + return { + x: minX, + y: minY, + width: Math.max(...xs) - minX, + height: Math.max(...ys) - minY, + }; +} + +function setMatrix(doc: EditorDocument, objPtr: number, m: Affine): void { + const fn = (doc.module as unknown as ImageMatrixSetterModule) + .FPDFImageObj_SetMatrix; + if (!fn) return; + try { + fn(objPtr, m.a, m.b, m.c, m.d, m.e, m.f); + } catch { + /* best-effort */ + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/UngroupParagraphCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/UngroupParagraphCommand.ts new file mode 100644 index 0000000000..389bd0cafc --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/UngroupParagraphCommand.ts @@ -0,0 +1,157 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { + cloneParagraphLineSlot, + type ParagraphLineSlot, + TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; + +/** Split a paragraph-grouped run back into one editable run per source line. */ +interface RepSnapshot { + text: string; + bounds: { x: number; y: number; width: number; height: number }; + paragraphLineHeight: number; + paragraphMemberPtrs: number[]; + paragraphMemberContainers: number[]; + paragraphMemberFs: number[]; + paragraphLeafPtrs: number[]; + paragraphLeafContainers: number[]; + paragraphLineSlots: ParagraphLineSlot[]; +} + +export class UngroupParagraphCommand implements Command { + readonly type = "ungroup-paragraph"; + private readonly pageIndex: number; + private readonly runId: string; + private prev: RepSnapshot | null = null; + private createdRunIds: string[] = []; + + constructor(opts: { pageIndex: number; runId: string }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + } + + /** Run IDs produced by the split (rep line + one per extra source line). */ + get resultRunIds(): string[] { + return this.createdRunIds; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const rep = page.findRun(this.runId); + if (!rep) return; + if (rep.paragraphMemberPtrs.length < 2) return; + + this.prev = { + text: rep.text, + bounds: { ...rep.bounds }, + paragraphLineHeight: rep.paragraphLineHeight, + paragraphMemberPtrs: [...rep.paragraphMemberPtrs], + paragraphMemberContainers: [...rep.paragraphMemberContainers], + paragraphMemberFs: [...rep.paragraphMemberFs], + paragraphLeafPtrs: [...rep.paragraphLeafPtrs], + paragraphLeafContainers: [...rep.paragraphLeafContainers], + paragraphLineSlots: rep.paragraphLineSlots.map(cloneParagraphLineSlot), + }; + + const ptrs = rep.paragraphMemberPtrs; + const fs = rep.paragraphMemberFs; + const containers = rep.paragraphMemberContainers; + // Prefer per-line slots: their startChar/endChar ranges split the text + // correctly even for SOFT-wrapped paragraphs. + const slots = rep.paragraphLineSlots; + const useSlots = slots.length >= 2 && slots.length === ptrs.length; + const lines = useSlots + ? slots.map((s) => rep.text.slice(s.startChar, s.endChar)) + : rep.text.split(/\r?\n/); + const n = Math.min(lines.length, ptrs.length); + const newRuns: TextRun[] = []; + const perLineHeight = + rep.paragraphLineHeight > 0 + ? rep.paragraphLineHeight + : rep.fontSize * 1.2; + for (let i = 0; i < n; i++) { + const baselineY = fs[i] ?? rep.matrix.f - i * perLineHeight; + const id = `${rep.id}-line-${i}-${ptrs[i] || "stub"}`; + const lineHeight = rep.fontSize; + const r = new TextRun({ + id, + pageIndex: page.index, + pdfiumObjPtr: ptrs[i] || 0, + bounds: { + x: rep.bounds.x, + y: baselineY - rep.fontSize * 0.2, + width: rep.bounds.width, + height: lineHeight, + }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: rep.bounds.x, f: baselineY }, + text: lines[i] ?? "", + fontId: rep.fontId, + fontSize: rep.fontSize, + fill: { ...rep.fill }, + fontSubset: rep.fontSubset, + }); + r.containerPtr = containers[i] ?? 0; + newRuns.push(r); + } + this.createdRunIds = newRuns.map((r) => r.id); + + rep.paragraphMemberPtrs = []; + rep.paragraphMemberContainers = []; + rep.paragraphMemberFs = []; + rep.paragraphLeafPtrs = []; + rep.paragraphLeafContainers = []; + rep.paragraphLineSlots = []; + rep.paragraphLineHeight = 0; + rep.text = lines[0] ?? ""; + rep.bounds = { + x: rep.bounds.x, + y: (fs[0] ?? rep.matrix.f) - rep.fontSize * 0.2, + width: rep.bounds.width, + height: rep.fontSize, + }; + rep.matrix = { ...rep.matrix, f: fs[0] ?? rep.matrix.f }; + + // Replace rep with rep + (n-1) new lines; the first line stays on rep. + const tail = newRuns.slice(1); + const idx = page.runs.findIndex((r) => r.id === rep.id); + if (idx >= 0) { + const next = [...page.runs]; + next.splice(idx + 1, 0, ...tail); + page.setRuns(next); + } + // Bump revision so the dirty-only resnapshot republishes the page - + // this command only mutates the in-memory run model. + page.markDirty(); + } + + revert(doc: EditorDocument): void { + if (!this.prev) return; + const page = doc.page(this.pageIndex); + const rep = page.findRun(this.runId); + if (!rep) return; + rep.text = this.prev.text; + rep.bounds = { ...this.prev.bounds }; + rep.matrix = { + ...rep.matrix, + f: this.prev.paragraphMemberFs[0] ?? rep.matrix.f, + }; + rep.paragraphLineHeight = this.prev.paragraphLineHeight; + rep.paragraphMemberPtrs = [...this.prev.paragraphMemberPtrs]; + rep.paragraphMemberContainers = [...this.prev.paragraphMemberContainers]; + rep.paragraphMemberFs = [...this.prev.paragraphMemberFs]; + rep.paragraphLeafPtrs = [...this.prev.paragraphLeafPtrs]; + rep.paragraphLeafContainers = [...this.prev.paragraphLeafContainers]; + rep.paragraphLineSlots = this.prev.paragraphLineSlots.map( + cloneParagraphLineSlot, + ); + const tailIds = new Set(this.createdRunIds.slice(1)); + page.setRuns(page.runs.filter((r) => !tailIds.has(r.id))); + page.markDirty(); + this.createdRunIds = []; + } + + describe(): string { + return `Ungroup paragraph ${this.runId}`; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/editTextHelpers.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/editTextHelpers.ts new file mode 100644 index 0000000000..7e1cd1b865 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/editTextHelpers.ts @@ -0,0 +1,1565 @@ +import { readUtf16, writeUtf16 } from "@app/services/pdfiumService"; +import type { + ParagraphLineSlot, + TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import type { RGBA } from "@app/tools/pdfTextEditor/types"; +import { + emitCharcodeEvent, + findFontForChar, + fontIsReusable, + setCharcodesOn, + styleClassFromName, + tryResolveCharcodes, +} from "@app/tools/pdfTextEditor/charcode/charcodeRegistry"; +import { getActiveCharcodeStrategy } from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; +import { emitFallbackTextObject } from "@app/tools/pdfTextEditor/util/fallbackFont"; +import { emitDeviceFontTextObject } from "@app/tools/pdfTextEditor/util/deviceFontEmbed"; +import { nearestStandardFont } from "@app/tools/pdfTextEditor/util/fontFamily"; + +// Remove a PAGE-level object and FREE its PDFium allocation. +// `FPDFPage_RemoveObject` only detaches the object. +export function removeAndDestroyObject( + m: WrappedPdfiumModule, + pagePtr: number, + ptr: number, +): void { + if (!ptr) return; + try { + m.FPDFPage_RemoveObject(pagePtr, ptr); + } catch { + /* best-effort */ + } + try { + m.FPDFPageObj_Destroy(ptr); + } catch { + /* best-effort */ + } +} + +// Pointers freshly created by the per-char BACKEND emit branch in +// `emitTextLine`. +const perCharBranchPtrs = new Set(); + +// (fontPtr:char) pairs a read-back has PROVEN render faithfully via SetText. +const readBackValidated = new Set(); + +/** Caller check: was this ptr produced by the per-char emit branch? */ +export function isVerifiedPerCharPtr(ptr: number): boolean { + return perCharBranchPtrs.has(ptr); +} + +/** Doc-scoped reset: PDFium reuses freed pointers across documents. */ +export function resetPerCharBranchPtrs(): void { + perCharBranchPtrs.clear(); + readBackValidated.clear(); +} + +/** Test-only: clear the verified-ptr set between cases. */ +export function _clearVerifiedPerCharPtrsForTests(): void { + resetPerCharBranchPtrs(); +} + +// Characters that an edit could NOT represent and silently dropped: the source +// font couldn't render them. +const droppedBase14Chars = new Set(); + +/** Visible chars dropped this session because nothing could render them. */ +export function getDroppedBase14Chars(): string[] { + return [...droppedBase14Chars]; +} + +/** Doc-scoped reset for the dropped-char record. */ +export function resetDroppedBase14Chars(): void { + droppedBase14Chars.clear(); +} + +/** Test-only alias for {@link resetDroppedBase14Chars}. */ +export function _clearDroppedBase14CharsForTests(): void { + resetDroppedBase14Chars(); +} + +/** Record every VISIBLE char present in `original` but missing from `kept`. */ +function recordDroppedChars(original: string, kept: string): void { + const keptSet = new Set(kept); + for (const ch of original) { + if (!keptSet.has(ch) && ch.trim().length > 0) droppedBase14Chars.add(ch); + } +} + +/** True when every character in `text` is also present in `pool`. */ +export function everyCharIn(text: string, pool: string): boolean { + const set = new Set(pool); + for (const c of text) if (!set.has(c)) return false; + return true; +} + +// Whether a font can encode a given character, keyed by font pointer. Replace +// all rewrites every matching run, so resolving per run made the click block. +const charCoverage = new Map>(); + +/** Doc-scoped reset: PDFium reuses font pointers across documents. */ +export function resetCharCoverageCache(): void { + charCoverage.clear(); +} + +// True when the emit path will map EVERY char in this font. Same condition +// emitTextLine uses to take its setCharcodes branch, so a true here means the +// reuse really will render rather than fall through to raw SetText. +export function charcodesResolveFully( + m: WrappedPdfiumModule, + fontPtr: number, + text: string, + pagePtr: number, + docPtr: number, +): boolean { + if (!fontPtr || !text) return false; + let perFont = charCoverage.get(fontPtr); + if (!perFont) { + perFont = new Map(); + charCoverage.set(fontPtr, perFont); + } + // Distinct characters only: a long string costs no more than its alphabet. + for (const ch of new Set([...text])) { + const known = perFont.get(ch); + if (known === false) return false; + if (known === true) continue; + let ok = false; + try { + const resolved = tryResolveCharcodes( + fontPtr, + ch, + { module: m, pagePtr, docPtr }, + true, + ); + const r = resolved?.result; + ok = !!r && r.coverage === 1 && r.charcodes.length === 1; + } catch { + ok = false; + } + // Only memoise a POSITIVE result. A miss here can simply mean the + // charcode cache was cold or the backend was briefly unreachable, and + // caching that as "this font cannot encode this character" made the + // failure permanent for the session. + if (ok) perFont.set(ch, true); + else return false; + } + return true; +} + +/** Strip characters a base-14 (WinAnsi) font cannot render. */ +export function sanitizeForBase14(text: string): string { + let out = ""; + for (const ch of text) { + const cp = ch.codePointAt(0) ?? 0; + if (cp === 0x09 || cp === 0x0a || cp === 0x0d) { + out += ch; + } else if (cp < 0x20 || cp === 0x7f || (cp >= 0x80 && cp <= 0x9f)) { + // C0/DEL/C1 controls are un-encodable in WinAnsi - drop them. + continue; + } else if (cp === 0x00a0) { + out += " "; + } else if (cp <= 0xff) { + out += ch; + } + // else: unrepresentable in base-14 - drop it (no tofu). + } + return out; +} + +/** Every PDFium pointer that backs a run. */ +export function collectMemberPtrs(run: TextRun): number[] { + if (run.paragraphLeafPtrs.length > 0) return run.paragraphLeafPtrs; + if (run.paragraphMemberPtrs.length > 0) return run.paragraphMemberPtrs; + if (run.mergedFromPtrs.length > 0) return run.mergedFromPtrs; + return [run.pdfiumObjPtr]; +} + +// Parallel map from member pointer to its form-xobject container (zero for +// page-level members). +export function collectContainersByPtr(run: TextRun): Map { + const map = new Map(); + if (run.paragraphLeafPtrs.length > 0) { + run.paragraphLeafPtrs.forEach((ptr, i) => { + map.set(ptr, run.paragraphLeafContainers[i] ?? 0); + }); + return map; + } + if (run.paragraphMemberPtrs.length > 0) { + run.paragraphMemberPtrs.forEach((ptr, i) => { + map.set(ptr, run.paragraphMemberContainers[i] ?? 0); + }); + return map; + } + for (const ptr of run.mergedFromPtrs) map.set(ptr, run.containerPtr); + if (run.pdfiumObjPtr) map.set(run.pdfiumObjPtr, run.containerPtr); + return map; +} + +interface FormRemovalModule { + FPDFFormObj_RemoveObject?: (form: number, obj: number) => boolean; +} + +/** Best-effort removal of every pointer in `ptrs`. */ +export function removeMemberPtrs( + m: WrappedPdfiumModule, + page: Page, + ptrs: number[], + containerByPtr: Map, + fallbackContainerPtr: number, +): boolean { + if (ptrs.length === 0) return false; + const formMod = m as unknown as FormRemovalModule; + let allOk = true; + for (const ptr of ptrs) { + if (!ptr) { + allOk = false; + continue; + } + const container = containerByPtr.get(ptr) ?? fallbackContainerPtr; + let ok: boolean; + if (container && formMod.FPDFFormObj_RemoveObject) { + try { + ok = !!formMod.FPDFFormObj_RemoveObject(container, ptr); + } catch { + ok = false; + } + } else { + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + ok = true; + } catch { + ok = false; + } + } + if (!ok) allOk = false; + } + return allOk; +} + +interface CreatedTextOptions { + doc: EditorDocument; + page: Page; + text: string; + x: number; + y: number; + fontSize: number; + fill: { r: number; g: number; b: number; a: number }; + /** When non-zero, reuse the source font instead of base-14. */ + originalFontPtr: number; + /** Whether the reused source font is a SUBSET font. */ + originalFontSubset?: boolean; + /** Base-14 family used when `originalFontPtr` is zero. Defaults to Helvetica. */ + fallbackFamily?: string; + /** The source run's PDF text render mode (Tr). */ + renderMode?: number; + /** Glyph outline colour; only paints under a stroking render mode. */ + stroke?: RGBA | null; + strokeWidth?: number; + /** The run's on-page rotation (normalised cos/sin of its text matrix). */ + rotation?: { cos: number; sin: number }; + // Extra advance per glyph in PDF points - the source run's rendered + // letter-spacing (Tc), inferred at read time. + charSpacingPt?: number; + // Optional sink for the text each returned pointer carries, parallel to the + // return value. The emit branches chunk by word, by character, or not at all, + // so callers that must map pointers back onto the source string cannot guess + // it - and reading it back costs a full page text extraction per line. + outTexts?: string[]; +} + +interface CreateTextObjModule { + FPDFPageObj_CreateTextObj?: ( + doc: number, + font: number, + size: number, + ) => number; +} + +// NOTE on spaces: PDFium normalises consecutive ASCII spaces inside a single +// text object, and base-14 Helvetica maps NBSP to 0xFF, which renders as junk. + +let measureCanvas: HTMLCanvasElement | null = null; + +/** Hidden canvas used to measure CSS-Helvetica advance widths. */ +function measureCtx(): CanvasRenderingContext2D | null { + if (typeof document === "undefined") return null; + if (!measureCanvas) measureCanvas = document.createElement("canvas"); + return measureCanvas.getContext("2d"); +} + +// Map a base-14 PostScript name to a CSS font spec the browser actually has. +export function cssFontSpecFor(fontFamily: string, sizePx: number): string { + const f = fontFamily.toLowerCase(); + const bold = f.includes("bold") ? "bold " : ""; + const italic = f.includes("italic") || f.includes("oblique") ? "italic " : ""; + let stack = "Helvetica, Arial, sans-serif"; + if (f.startsWith("times")) stack = "'Times New Roman', Times, serif"; + else if (f.startsWith("courier")) stack = "'Courier New', Courier, monospace"; + return `${italic}${bold}${sizePx}px ${stack}`; +} + +/** Measure the natural advance width of `s` in PDF points. */ +function measureAdvancePt( + text: string, + fontFamily: string, + fontSizePt: number, +): number { + const ctx = measureCtx(); + if (!ctx) return text.length * fontSizePt * 0.5; + ctx.font = cssFontSpecFor(fontFamily, fontSizePt); + return ctx.measureText(text).width; +} + +// Per-page cache of each char's ON-PAGE rendered advance (per em), keyed +// pagePtr -> fontPtr -> unicode -> advanceEm. +const onPageAdvCache = new Map>>(); + +interface LooseBoxModule { + FPDFText_LoadPage?: (page: number) => number; + FPDFText_ClosePage?: (tp: number) => void; + FPDFText_CountChars?: (tp: number) => number; + FPDFText_GetUnicode?: (tp: number, i: number) => number; + FPDFText_GetTextObject?: (tp: number, i: number) => number; + FPDFTextObj_GetFont?: (obj: number) => number; + FPDFText_GetFontSize?: (tp: number, i: number) => number; + FPDFText_GetLooseCharBox?: (tp: number, i: number, rect: number) => boolean; + FPDFText_GetCharOrigin?: ( + tp: number, + i: number, + x: number, + y: number, + ) => boolean; +} + +// A measured advance below this many ems is treated as an ink box mistaken for +// an advance - that collapse is what stacked Type 3 glyphs onto each other. +// +// This is a deliberate trade-off, not a safe floor: real faces do go under it +// (Garamond's "i" is 0.177em), and such a glyph falls through to an estimated +// metric that can be ~25% wide. Lowering the threshold is not the fix - the +// Type 3 ink boxes it exists to reject measure about 0.12em, so there is no +// gap between the two populations to separate them cleanly. +const MIN_PLAUSIBLE_ADVANCE_EM = 0.18; +// Above this, the "advance" swallowed a word gap or a Td jump. +const MAX_PLAUSIBLE_ADVANCE_EM = 2; + +/** Baseline origin of char `idx` in page points, or null when unreadable. */ +function charOriginPt( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + tp: number, + idx: number, +): { x: number; y: number } | null { + const mod = m as unknown as LooseBoxModule; + if (!mod.FPDFText_GetCharOrigin) return null; + // FPDFText_GetCharOrigin takes two double* out-params. + const buf = m.pdfium.wasmExports.malloc(16); + try { + if (!mod.FPDFText_GetCharOrigin(tp, idx, buf, buf + 8)) return null; + return { + x: m.pdfium.getValue(buf, "double"), + y: m.pdfium.getValue(buf + 8, "double"), + }; + } catch { + return null; + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +function looseBoxAdvancePt( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + tp: number, + idx: number, +): number | null { + const mod = m as unknown as LooseBoxModule; + if (!mod.FPDFText_GetLooseCharBox) return null; + const wasm = ( + m.pdfium as unknown as { + wasmExports: { malloc: (n: number) => number; free: (p: number) => void }; + } + ).wasmExports; + const buf = wasm.malloc(16); // FS_RECT = 4 floats {left, top, right, bottom} + try { + if (!mod.FPDFText_GetLooseCharBox(tp, idx, buf)) return null; + const heap = (m.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8; + const f32 = new Float32Array(heap.buffer, buf, 4); + const width = f32[2] - f32[0]; + return width > 0 ? width : null; + } catch { + return null; + } finally { + wasm.free(buf); + } +} + +/** |scale| of a page object's matrix (1 when unreadable). */ +function objMatrixScale( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + objPtr: number, +): number { + const buf = m.pdfium.wasmExports.malloc(6 * 4); + try { + if (!m.FPDFPageObj_GetMatrix(objPtr, buf)) return 1; + const a = m.pdfium.getValue(buf, "float"); + const b = m.pdfium.getValue(buf + 4, "float"); + const s = Math.hypot(a, b); + return s > 0 ? s : 1; + } catch { + return 1; + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +function buildOnPageAdvMap( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + pagePtr: number, +): Map> { + const mod = m as unknown as LooseBoxModule; + const out = new Map>(); + if ( + !mod.FPDFText_LoadPage || + !mod.FPDFText_CountChars || + !mod.FPDFText_GetUnicode || + !mod.FPDFText_GetTextObject || + !mod.FPDFTextObj_GetFont || + !mod.FPDFText_GetFontSize + ) { + return out; + } + const tp = mod.FPDFText_LoadPage(pagePtr); + if (!tp) return out; + // FPDFText_GetFontSize returns the raw Tf operand, but many producers set Tf + // 1 and carry the real size in the text matrix. + const scaleByObj = new Map(); + try { + const count = mod.FPDFText_CountChars(tp); + for (let i = 0; i < count; i++) { + const u = mod.FPDFText_GetUnicode(tp, i); + if (!u) continue; + const obj = mod.FPDFText_GetTextObject(tp, i); + if (!obj) continue; + let font = 0; + try { + font = mod.FPDFTextObj_GetFont(obj); + } catch { + /* skip */ + } + if (!font) continue; + let fm = out.get(font); + if (!fm) { + fm = new Map(); + out.set(font, fm); + } + if (fm.has(u)) continue; + const fs = mod.FPDFText_GetFontSize(tp, i); + if (!fs || fs <= 0) continue; + let scale = scaleByObj.get(obj); + if (scale === undefined) { + scale = objMatrixScale(m, obj); + scaleByObj.set(obj, scale); + } + const effFs = fs * scale; + if (!effFs || effFs <= 0) continue; + // The loose char box is the glyph's own advance, which is what the emit + // path wants: it re-applies the run's letter-spacing itself. On Type 3 + // faces (Figma/Skia exports) PDFium degrades it to the tight ink box, + // which collapses every advance and stacks the glyphs on re-emit - so + // an implausible value falls through to the pen movement on the page. + // That gap includes any Tc the producer used, but an advance that is + // slightly too wide beats one that is zero. + let advEm: number | null = null; + const adv = looseBoxAdvancePt(m, tp, i); + const looseEm = adv == null ? null : adv / effFs; + if ( + looseEm != null && + looseEm >= MIN_PLAUSIBLE_ADVANCE_EM && + looseEm <= MAX_PLAUSIBLE_ADVANCE_EM + ) { + advEm = looseEm; + } else { + const here = charOriginPt(m, tp, i); + const next = i + 1 < count ? charOriginPt(m, tp, i + 1) : null; + if (here && next && Math.abs(next.y - here.y) < 0.5) { + const delta = (next.x - here.x) / effFs; + if ( + delta >= MIN_PLAUSIBLE_ADVANCE_EM && + delta <= MAX_PLAUSIBLE_ADVANCE_EM + ) { + advEm = delta; + } + } + } + // No trustworthy measurement: leave the char unmapped so the caller + // falls back to font metrics rather than advancing by ~nothing. + if (advEm == null) continue; + fm.set(u, advEm); + } + } finally { + try { + mod.FPDFText_ClosePage?.(tp); + } catch { + /* best-effort */ + } + } + return out; +} + +/** On-page rendered advance (per em) of `ch` in `font`, or null if absent. */ +function onPageAdvanceEm( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + pagePtr: number, + font: number, + ch: string, +): number | null { + if (!font) return null; + let pageMap = onPageAdvCache.get(pagePtr); + if (!pageMap) { + pageMap = buildOnPageAdvMap(m, pagePtr); + onPageAdvCache.set(pagePtr, pageMap); + } + const cp = ch.codePointAt(0) ?? 0; + return pageMap.get(font)?.get(cp) ?? null; +} + +/** + * Build the page's advance map now, while every source glyph is still on the + * page. + * + * The map is the only place a Type 3 glyph's real advance can come from, and + * an edit removes the objects it is measured off. Warming it first is what + * lets a re-emit keep the original face instead of collapsing. + */ +export function warmOnPageAdvances( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + pagePtr: number, +): void { + if (!pagePtr || onPageAdvCache.has(pagePtr)) return; + try { + onPageAdvCache.set(pagePtr, buildOnPageAdvMap(m, pagePtr)); + } catch { + /* best-effort - callers fall back to font metrics */ + } +} + +/** Drop the per-page on-page-advance cache. */ +export function resetOnPageAdvCache(): void { + onPageAdvCache.clear(); +} + +/** Test-only alias for {@link resetOnPageAdvCache}. */ +export function _clearOnPageAdvCacheForTests(): void { + resetOnPageAdvCache(); +} + +// Split a line into one chunk per word with the trailing whitespace stored as +// an explicit `gapAfterPt`. +export interface WordChunk { + text: string; + gapAfterPt: number; + /** How many whitespace chars the gap after this chunk represents. */ + gapCharCount: number; +} +export function splitIntoWordChunks( + line: string, + fontFamily: string, + fontSizePt: number, +): WordChunk[] { + const chunks: WordChunk[] = []; + // Any run of 1+ whitespace becomes a chunk boundary. + const gapRe = /\s+/g; + let leadingGapPt = 0; + let leadingGapChars = 0; + let lastIdx = 0; + let m: RegExpExecArray | null; + while ((m = gapRe.exec(line)) !== null) { + const before = line.slice(lastIdx, m.index); + const gapText = m[0]; + const gapPt = measureAdvancePt(gapText, fontFamily, fontSizePt); + if (before.length === 0) { + // Whitespace at the very start of `line`, or two whitespace runs + // back-to-back with no non-space char between. + leadingGapPt += gapPt; + leadingGapChars += gapText.length; + } else { + chunks.push({ + text: before, + gapAfterPt: gapPt, + gapCharCount: gapText.length, + }); + } + lastIdx = gapRe.lastIndex; + } + // Trailing non-whitespace tail. + if (lastIdx < line.length) { + chunks.push({ text: line.slice(lastIdx), gapAfterPt: 0, gapCharCount: 0 }); + } + // Leading whitespace is exposed as a side field the caller folds into + // the initial cursor (it can't live in any chunk's gapAfterPt). + const side = chunks as WordChunk[] & { + leadingGapPt?: number; + leadingGapChars?: number; + }; + side.leadingGapPt = leadingGapPt; + side.leadingGapChars = leadingGapChars; + return chunks; +} + +/** Insert one or more text objects representing `opts.text`. */ +/** Normalised rotation of a text matrix, or undefined for upright text. */ +export function rotationFromMatrix(matrix: { + a: number; + b: number; + c?: number; + d?: number; +}): { cos: number; sin: number } | undefined { + const scale = Math.hypot(matrix.a, matrix.b); + if (!scale) return undefined; + const cos = matrix.a / scale; + const sin = matrix.b / scale; + // a,b alone cannot tell a mirrored generator from upright text - both read + // sin~=0 / cos>0 - so the determinant decides. + const c = matrix.c ?? 0; + const d = matrix.d ?? scale; + const mirrored = matrix.a * d - matrix.b * c < 0; + if (Math.abs(sin) < 1e-4 && cos > 0 && !mirrored) return undefined; + return { cos, sin }; +} + +// The rotation a NEW object needs so it reads upright on a page displayed with +// `/Rotate` (quarter-turns CW). +export function counterPageRotation( + rotateQuarterTurnsCw: number, +): { cos: number; sin: number } | undefined { + switch (((rotateQuarterTurnsCw % 4) + 4) % 4) { + case 1: + return { cos: 0, sin: 1 }; + case 2: + return { cos: -1, sin: 0 }; + case 3: + return { cos: 0, sin: -1 }; + default: + return undefined; + } +} + +/** Rotate a page object about (ax, ay). Identity (no-op) when cos=1, sin=0. */ +export function rotateObjectAbout( + m: WrappedPdfiumModule, + ptr: number, + ax: number, + ay: number, + cos: number, + sin: number, +): void { + m.FPDFPageObj_Transform( + ptr, + cos, + sin, + -sin, + cos, + ax - ax * cos + ay * sin, + ay - ax * sin - ay * cos, + ); +} + +export function emitTextLine(opts: CreatedTextOptions): number[] { + const m = opts.doc.module; + const size = Math.max(4, opts.fontSize); + const family = opts.fallbackFamily ?? "Helvetica"; + const m2 = m as unknown as CreateTextObjModule; + const canReuse = opts.originalFontPtr !== 0 && !!m2.FPDFPageObj_CreateTextObj; + + // Words are laid out horizontally from (opts.x, opts.y). + const withRotation = (ptrs: number[]): number[] => { + const rot = opts.rotation; + if (rot) { + for (const p of ptrs) { + if (p) rotateObjectAbout(m, p, opts.x, opts.y, rot.cos, rot.sin); + } + } + // Every successful emit funnels through here, so this is the one place to + // re-apply the source run's ink state - new objects default to a flat fill. + applyInkState(m, ptrs, opts); + return ptrs; + }; + + // Emit ONE word at (x, y) and return its pointer (0 on failure). + const emitWord = (text: string, x: number): number => { + // base-14 can only render Latin-1; drop the rest so PDFium never emits + // U+00FF tofu. + const base14Text = sanitizeForBase14(text); + const newBase14 = (): number => { + const ptr = m.FPDFPageObj_NewTextObj(opts.doc.docPtr, family, size); + if (ptr) return ptr; + // PDFium only knows the standard font names, so any other family fails + // here. Substituting is what editors do; returning 0 would drop the text. + const substitute = nearestStandardFont(family); + return substitute === family + ? 0 + : m.FPDFPageObj_NewTextObj(opts.doc.docPtr, substitute, size); + }; + const emitBase14 = (): number => { + // A pre-warmed device font emits with its REAL face. Standard names skip + // this and a cold cache returns 0, so existing emits are unchanged. + if (nearestStandardFont(family) !== family) { + const dp = emitDeviceFontTextObject( + opts.doc, + opts.page, + family, + text, + size, + opts.fill, + x, + opts.y, + ); + if (dp) return dp; + } + // Some chars are outside base-14's Latin-1 range. + if ([...text].length > [...base14Text].length) { + const fp = emitFallbackTextObject( + opts.doc, + opts.page, + text, + size, + opts.fill, + x, + opts.y, + ); + if (fp) return fp; + // The bundled Noto fallback couldn't render the non-Latin chars either, + // so the base-14 emit below drops them. + recordDroppedChars(text, base14Text); + } + if (base14Text.length === 0) return 0; // nothing representable - drop + const p = newBase14(); + if (!p) return 0; + setTextOn(m, p, base14Text); + applyFillAndPos(m, opts.page, p, opts.fill, x, opts.y); + return p; + }; + if (!canReuse) { + // Still record the attempt: this is the only signal that an edit fell + // back instead of reusing the source face. + emitCharcodeEvent({ + timestamp: 0, + strategy: getActiveCharcodeStrategy(), + text, + fontPtr: opts.originalFontPtr, + resolved: [], + missing: [...text], + note: + opts.originalFontPtr !== 0 + ? "source font cannot author glyphs (Type 3 / no font program) - substituting" + : "no source font available (Helvetica fresh emit)", + outcome: "no-font", + }); + return emitBase14(); + } + + const ptr = m2.FPDFPageObj_CreateTextObj!( + opts.doc.docPtr, + opts.originalFontPtr, + size, + ); + if (!ptr) return emitBase14(); + // Reuse path: resolve real font charcodes so the embedded subset font + // renders the chars; falls back to SetText internally. + const strategyUsed = writeViaCharcodesOrSetText(ptr, text); + applyFillAndPos(m, opts.page, ptr, opts.fill, x, opts.y); + // A whole-word SetCharcodes write via the BACKEND resolver used known-good + // (font, charcode) pairs PDFBox validated, so the glyph is real. + if (strategyUsed === "backend") return ptr; + const right = measureObjRightEdgePt(m, ptr); + const visible = text.replace(/\s+/g, "").length; + // Narrowest base-14 glyph ("i") is ~0.22em; anything well under ~0.15em + // per visible char means the reused font produced .notdef / 0-width. + const minExpected = visible * size * 0.15; + if (visible > 0 && right - x < minExpected) { + // Discard the .notdef object and free it (we re-emit in base-14 next). + removeAndDestroyObject(m, opts.page.pagePtr, ptr); + return emitBase14(); + } + // Read-back validation for a source-font SetText. + if (strategyUsed === null) { + // Throttle: chars a previous read-back already proved this font renders + // faithfully never need re-checking. + const visibleChars = [...text].filter((c) => c.trim().length > 0); + const allProven = + opts.originalFontPtr !== 0 && + visibleChars.every((c) => + readBackValidated.has(`${opts.originalFontPtr}:${c}`), + ); + if (!allProven) { + const got = readBackTextObj(m, opts.page.pagePtr, ptr); + if (got !== null) { + const norm = (s: string) => s.replace(/\s+/g, ""); + if (norm(got) !== norm(text)) { + removeAndDestroyObject(m, opts.page.pagePtr, ptr); + return emitBase14(); + } + if (opts.originalFontPtr) { + for (const c of visibleChars) { + readBackValidated.add(`${opts.originalFontPtr}:${c}`); + } + } + } + } + } + // Self-validate an UNTRUSTED charcode GUESS. + if ( + (strategyUsed === "content-stream" || strategyUsed === "cmap") && + opts.originalFontPtr + ) { + let expected = 0; + let known = 0; + for (const ch of text) { + if (/\s/.test(ch)) continue; + const em = onPageAdvanceEm( + m, + opts.page.pagePtr, + opts.originalFontPtr, + ch, + ); + if (em != null) { + expected += em * size; + known += 1; + } + } + if (known > 0 && expected > 0) { + const ratio = (right - x) / expected; + if (ratio < 0.6 || ratio > 1.7) { + // Wrong-glyph guess: discard + free, then re-emit in base-14. + removeAndDestroyObject(m, opts.page.pagePtr, ptr); + return emitBase14(); + } + } + } + return ptr; + }; + + // Try-charcodes wrapper: when we're reusing a source font AND the active + // charcode strategy can resolve EVERY char in the chunk. + function writeViaCharcodesOrSetText( + ptr: number, + text: string, + ): string | null { + const strategy = getActiveCharcodeStrategy(); + // The content-stream resolver is an untrusted sequential-CID GUESS. + if ( + strategy === "content-stream" && + !(!!opts.originalFontSubset && [...text].length === 1) + ) { + emitCharcodeEvent({ + timestamp: 0, + strategy, + text, + fontPtr: opts.originalFontPtr, + resolved: [], + missing: [...text], + note: "content-stream active but ungated (not subset+single-cp) - using SetText", + outcome: "partial-coverage-fallback", + }); + setTextOn(m, ptr, text); + return null; + } + if (!canReuse || !opts.originalFontPtr) { + emitCharcodeEvent({ + timestamp: 0, + strategy, + text, + fontPtr: opts.originalFontPtr, + resolved: [], + missing: [...text], + note: !canReuse + ? "no source font available (Helvetica fresh emit)" + : "originalFontPtr is 0", + outcome: "no-font", + }); + setTextOn(m, ptr, text); + return null; + } + // allowContentStreamFallback: if the active resolver misses, reuse the + // on-page glyph via the client-side content-stream resolver. + const allowGuessFallback = + !!opts.originalFontSubset && [...text].length === 1; + const resolved = tryResolveCharcodes( + opts.originalFontPtr, + text, + { + module: m, + pagePtr: opts.page.pagePtr, + docPtr: opts.doc.docPtr, + }, + allowGuessFallback, + ); + if (!resolved) { + emitCharcodeEvent({ + timestamp: 0, + strategy, + text, + fontPtr: opts.originalFontPtr, + resolved: [], + missing: [...text], + note: "active strategy is 'helvetica' (no resolver)", + outcome: "no-strategy", + }); + setTextOn(m, ptr, text); + return null; + } + const r = resolved.result; + // Code points, not UTF-16 units: the resolver counts per code point, + // so an astral char (emoji, CJK Ext-B) never matched text.length. + const cpLen = [...text].length; + if (r && r.coverage === cpLen && r.charcodes.length === cpLen) { + const ok = setCharcodesOn(m, ptr, r.charcodes); + emitCharcodeEvent({ + timestamp: 0, + strategy: resolved.strategy, + text, + fontPtr: opts.originalFontPtr, + resolved: [...r.charcodes], + missing: [], + note: r.note, + outcome: ok ? "charcodes-ok" : "charcodes-call-failed", + }); + if (ok) return resolved.strategy; + // SetCharcodes binding rejected the call - fall back. + } else if (r) { + emitCharcodeEvent({ + timestamp: 0, + strategy: resolved.strategy, + text, + fontPtr: opts.originalFontPtr, + resolved: [...r.charcodes], + missing: [...r.missing], + note: r.note, + outcome: "partial-coverage-fallback", + }); + } else { + emitCharcodeEvent({ + timestamp: 0, + strategy: resolved.strategy, + text, + fontPtr: opts.originalFontPtr, + resolved: [], + missing: [...text], + note: "resolver returned null (unavailable for this font)", + outcome: "partial-coverage-fallback", + }); + } + setTextOn(m, ptr, text); + return null; + } + + // Per-char emit branch for the BACKEND strategy. + const isBackendStrategy = getActiveCharcodeStrategy() === "backend"; + const hasAnyWhitespaceForBranch = /\s/.test(opts.text); + if ( + isBackendStrategy && + !hasAnyWhitespaceForBranch && + opts.text.length > 0 && + m2.FPDFPageObj_CreateTextObj + ) { + const ctx = { + module: m, + pagePtr: opts.page.pagePtr, + docPtr: opts.doc.docPtr, + }; + // Probe per char first. + const perChar: Array<{ ch: string; font: number; charcodes: number[] }> = + []; + let allOk = true; + for (const ch of opts.text) { + // Prefer the run's OWN font when it renders this char: it is the + // authoritative font for the run's text. + let charFont = 0; + let resolved = null; + if (opts.originalFontPtr) { + const own = tryResolveCharcodes(opts.originalFontPtr, ch, ctx); + if ( + own?.result && + own.result.charcodes.length === 1 && + own.result.missing.length === 0 + ) { + charFont = opts.originalFontPtr; + resolved = own; + } + } + if (!charFont) { + // Constrained to the run's own weight/slant: an unconstrained borrow + // takes the first matching glyph in content order, which is usually a + // bold heading, and the edited body text comes back bold. + charFont = + findFontForChar( + ch, + ctx, + opts.originalFontPtr, + styleClassFromName(family), + ) || 0; + if (!charFont) { + allOk = false; + break; + } + resolved = tryResolveCharcodes(charFont, ch, ctx); + } + if ( + !resolved?.result || + resolved.result.charcodes.length !== 1 || + resolved.result.missing.length > 0 + ) { + allOk = false; + break; + } + // A Type 3 face has no font program, so PDFium can report neither a + // glyph advance nor a usable ink box for it: the only trustworthy + // advance is one measured from the glyph as the page already draws it. + // Without that, each following glyph lands on top of this one - the + // reported scramble. Substitute a real face instead. + if ( + !fontIsReusable(m, charFont) && + onPageAdvanceEm(m, opts.page.pagePtr, charFont, ch) == null + ) { + allOk = false; + break; + } + perChar.push({ + ch, + font: charFont, + charcodes: resolved.result.charcodes, + }); + } + if (allOk && perChar.length === [...opts.text].length) { + // Per-char emit: one text object per char, each with its OWN font. + const ptrs: number[] = []; + let cursor = opts.x; + for (const pc of perChar) { + const ptr = m2.FPDFPageObj_CreateTextObj!( + opts.doc.docPtr, + pc.font, + size, + ); + if (!ptr) { + // CreateTextObj failed mid-word. + for (const p of ptrs) { + perCharBranchPtrs.delete(p); + removeAndDestroyObject(m, opts.page.pagePtr, p); + } + ptrs.length = 0; + break; + } + const ok = setCharcodesOn(m, ptr, pc.charcodes); + if (!ok) { + // Couldn't set charcodes - rare but possible. + removeAndDestroyObject(m, opts.page.pagePtr, ptr); + for (const p of ptrs) { + perCharBranchPtrs.delete(p); + removeAndDestroyObject(m, opts.page.pagePtr, p); + } + ptrs.length = 0; + break; + } + applyFillAndPos(m, opts.page, ptr, opts.fill, cursor, opts.y); + // Advance by the char's REAL on-page advance width, read from the same + // font+char already on the page. + const advEm = onPageAdvanceEm(m, opts.page.pagePtr, pc.font, pc.ch); + if (advEm != null) { + cursor += advEm * size; + } else { + // Unmeasurable: step by the font metric rather than the object's ink + // box. The ink box collapses on faces PDFium can't measure (stacking + // the glyphs) and overshoots on wide ones (visible gaps mid-word); + // a metric advance is even and always moves forward. + cursor += measureAdvancePt(pc.ch, family, size); + } + // Reproduce the source run's letter-spacing: the glyph advance above is + // the font's natural width. + cursor += opts.charSpacingPt ?? 0; + emitCharcodeEvent({ + timestamp: 0, + strategy: "backend", + text: pc.ch, + fontPtr: pc.font, + resolved: [...pc.charcodes], + missing: [], + note: `per-char backend emit: font=${pc.font} charcode=${pc.charcodes[0]}`, + outcome: "charcodes-ok", + }); + ptrs.push(ptr); + opts.outTexts?.push(pc.ch); + // Mark this ptr as verified - it was created via the per-char branch + // with a known-good pair from the backend resolver cache. + perCharBranchPtrs.add(ptr); + } + if (ptrs.length === [...opts.text].length) return withRotation(ptrs); + // Any other incomplete outcome: destroy the partial emit before the + // fall-through path re-renders the word. + for (const p of ptrs) { + perCharBranchPtrs.delete(p); + removeAndDestroyObject(m, opts.page.pagePtr, p); + } + if (opts.outTexts) opts.outTexts.length = 0; + } + // fall through to the normal path if per-char attempt didn't work + } + + // Letter-spaced runs: a single text object cannot carry Tc. + const hasAnyWhitespace = /\s/.test(opts.text); + const spacingPt = opts.charSpacingPt ?? 0; + if ( + !hasAnyWhitespace && + Math.abs(spacingPt) > 0.05 && + [...opts.text].length > 1 + ) { + const ptrs: number[] = []; + let cursor = opts.x; + for (const ch of opts.text) { + const ptr = emitWord(ch, cursor); + if (ptr) { + ptrs.push(ptr); + opts.outTexts?.push(ch); + } + // Advance by the char's true advance width: the on-page advance of the + // same char+font when it is still measurable, else canvas font metrics. + const advEm = opts.originalFontPtr + ? onPageAdvanceEm(m, opts.page.pagePtr, opts.originalFontPtr, ch) + : null; + cursor += + (advEm != null ? advEm * size : measureAdvancePt(ch, family, size)) + + spacingPt; + } + return withRotation(ptrs); + } + + // Fast path: no whitespace at all → one text object holds the whole word. + if (!hasAnyWhitespace) { + const ptr = emitWord(opts.text, opts.x); + if (ptr) opts.outTexts?.push(opts.text); + return withRotation(ptr ? [ptr] : []); + } + + // Per-chunk emit (split on ANY whitespace run). + const chunks = splitIntoWordChunks(opts.text, family, size) as WordChunk[] & { + leadingGapPt?: number; + leadingGapChars?: number; + }; + const spacing = opts.charSpacingPt ?? 0; + const ptrs: number[] = []; + let cursor = + opts.x + + (chunks.leadingGapPt ?? 0) + + spacing * (chunks.leadingGapChars ?? 0); + for (const chunk of chunks) { + if (chunk.text.length > 0) { + // Recurse per word. + const chunkTexts: string[] = []; + const wordPtrs = emitTextLine({ + ...opts, + text: chunk.text, + x: cursor, + rotation: undefined, + outTexts: opts.outTexts ? chunkTexts : undefined, + }); + if (wordPtrs.length === 0) continue; + if (opts.outTexts) opts.outTexts.push(...chunkTexts); + let rightEdge = 0; + for (const p of wordPtrs) + rightEdge = Math.max(rightEdge, measureObjRightEdgePt(m, p)); + // Only trust the measured edge when it advanced by a believable amount: + // a face PDFium can't measure reports a near-zero ink box and would put + // the next word on top of this one. + const metric = measureAdvancePt(chunk.text, family, size); + const advanced = rightEdge > cursor ? rightEdge - cursor : 0; + cursor += advanced >= metric * 0.35 ? advanced : metric; + ptrs.push(...wordPtrs); + } + // Word gaps stretch with the run's letter-spacing too: the source layout + // applies Tc after the glyph preceding the gap AND after each space. + cursor += + chunk.gapAfterPt + + (chunk.gapCharCount > 0 ? spacing * (chunk.gapCharCount + 1) : 0); + } + return withRotation(ptrs); +} + +interface TextObjReadModule { + FPDFText_LoadPage?: (page: number) => number; + FPDFText_ClosePage?: (tp: number) => void; + FPDFTextObj_GetText?: ( + obj: number, + tp: number, + buf: number, + len: number, + ) => number; +} + +// Decode a just-inserted text object's content through the font's ToUnicode +// (what any PDF reader will see), or null when unavailable. +// Read what several objects actually carry, through ONE text page. Callers that +// need to map emitted pointers back onto their source string must not assume a +// chunking: emitTextLine may produce one object per word, per char, or one for +// the whole string depending on which branch rendered it. +export function readObjTexts( + m: WrappedPdfiumModule, + pagePtr: number, + objPtrs: number[], +): Array { + const mod = m as unknown as TextObjReadModule; + const out: Array = objPtrs.map(() => null); + if ( + !mod.FPDFText_LoadPage || + !mod.FPDFTextObj_GetText || + !mod.FPDFText_ClosePage + ) { + return out; + } + const tp = mod.FPDFText_LoadPage(pagePtr); + if (!tp) return out; + try { + for (let i = 0; i < objPtrs.length; i += 1) { + const objPtr = objPtrs[i]; + if (!objPtr) continue; + try { + const len = mod.FPDFTextObj_GetText(objPtr, tp, 0, 0); + if (len <= 2) { + out[i] = ""; + continue; + } + const buf = m.pdfium.wasmExports.malloc(len); + try { + mod.FPDFTextObj_GetText(objPtr, tp, buf, len); + out[i] = readUtf16(m, buf, len); + } finally { + m.pdfium.wasmExports.free(buf); + } + } catch { + out[i] = null; + } + } + } finally { + try { + mod.FPDFText_ClosePage(tp); + } catch { + /* best-effort */ + } + } + return out; +} + +function readBackTextObj( + m: WrappedPdfiumModule, + pagePtr: number, + objPtr: number, +): string | null { + const mod = m as unknown as TextObjReadModule; + if ( + !mod.FPDFText_LoadPage || + !mod.FPDFTextObj_GetText || + !mod.FPDFText_ClosePage + ) { + return null; + } + const tp = mod.FPDFText_LoadPage(pagePtr); + if (!tp) return null; + try { + const len = mod.FPDFTextObj_GetText(objPtr, tp, 0, 0); + if (len <= 2) return ""; + const buf = m.pdfium.wasmExports.malloc(len); + try { + mod.FPDFTextObj_GetText(objPtr, tp, buf, len); + return readUtf16(m, buf, len); + } finally { + m.pdfium.wasmExports.free(buf); + } + } catch { + return null; + } finally { + try { + mod.FPDFText_ClosePage(tp); + } catch { + /* best-effort */ + } + } +} + +export function measureObjRightEdgePt( + m: WrappedPdfiumModule, + objPtr: number, +): number { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(objPtr, l, b, r, t)) return 0; + return m.pdfium.getValue(r, "float"); + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +/** + * Horizontal span covered by `ptrs`, or null when nothing is measurable. + * + * A fresh overlay emit replaces every object a run owns, so the run's old + * bounds describe geometry that no longer exists - a stale box leaves the + * editable overlay the wrong size over correctly drawn text. + */ +export function measureObjSpanPt( + m: WrappedPdfiumModule, + ptrs: number[], +): { left: number; right: number } | null { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + let left = Infinity; + let right = -Infinity; + for (const ptr of ptrs) { + if (!ptr) continue; + try { + if (!m.FPDFPageObj_GetBounds(ptr, l, b, r, t)) continue; + } catch { + continue; + } + const lo = m.pdfium.getValue(l, "float"); + const hi = m.pdfium.getValue(r, "float"); + if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue; + if (lo < left) left = lo; + if (hi > right) right = hi; + } + return right > left ? { left, right } : null; + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +function setTextOn(m: WrappedPdfiumModule, ptr: number, text: string): void { + const textPtr = writeUtf16(m, text); + try { + m.FPDFText_SetText(ptr, textPtr); + } finally { + m.pdfium.wasmExports.free(textPtr); + } +} + +interface InkState { + renderMode?: number; + stroke?: RGBA | null; + strokeWidth?: number; +} + +interface InkModule { + FPDFTextObj_SetTextRenderMode?: (obj: number, mode: number) => boolean; + FPDFPageObj_SetStrokeColor?: ( + obj: number, + r: number, + g: number, + b: number, + a: number, + ) => boolean; + FPDFPageObj_SetStrokeWidth?: (obj: number, width: number) => boolean; +} + +/** Pen origin for one output line, in raw PDF page space. */ +export interface LineOrigin { + x: number; + y: number; +} + +// THE one place that decides where each re-emitted line's pen starts. Reuse the +// run's existing per-line origins when the line count still matches (so an edit +// keeps the source's exact baselines), otherwise step along the run's rotated +// down-axis: the (0,-lineHeight) vector through [cos,-sin] gives (sin*L,-cos*L). +export function planLineOrigins( + run: TextRun, + lineCount: number, + lineHeight: number, +): LineOrigin[] { + const rot = rotationFromMatrix(run.matrix); + const dcos = rot ? rot.cos : 1; + const dsin = rot ? rot.sin : 0; + const slots = run.paragraphLineSlots; + // Line i keeps slot i whenever that slot exists, even when the edit changed + // the line COUNT: an edit that drops a line must not move the lines above it. + const last = slots.length > 0 ? slots[slots.length - 1] : null; + // Past the last known slot, keep the paragraph's own leading. Restarting the + // ladder at run.matrix instead drops the surviving lines onto the text below. + const leading = paragraphLeading(slots) || lineHeight; + const out: LineOrigin[] = []; + for (let i = 0; i < lineCount; i++) { + const slot = slots[i]; + if (slot) { + out.push({ x: slot.matrixE, y: slot.baselineY }); + continue; + } + const step = last ? i - (slots.length - 1) : i; + const baseX = last ? last.matrixE : run.matrix.e; + const baseY = last ? last.baselineY : run.matrix.f; + out.push({ + x: baseX + step * leading * dsin, + y: baseY - step * leading * dcos, + }); + } + return out; +} + +/** Distance between consecutive line origins, robust under rotation. */ +function paragraphLeading(slots: ParagraphLineSlot[]): number { + if (slots.length < 2) return 0; + const a = slots[slots.length - 2]; + const b = slots[slots.length - 1]; + return Math.hypot(b.matrixE - a.matrixE, b.baselineY - a.baselineY); +} + +/** One re-emitted line: the objects created for it and where they landed. */ +export interface EmittedLine { + ptrs: number[]; + text: string; + /** Text of each ptr, parallel to `ptrs`. Callers must not re-derive this: + * emitTextLine emits per word OR per character, and guessing drops ptrs. */ + texts: string[]; + x: number; + y: number; +} + +// THE one place a whole run is re-emitted line by line. Rotation, ink state and +// per-line baselines are applied here so no caller can carry one and drop +// another - that fragmentation is why the same class of bug kept recurring. +export function emitRunLines(opts: { + doc: EditorDocument; + page: Page; + run: TextRun; + lines: string[]; + origins: LineOrigin[]; + originalFontPtr: number; + fallbackFamily: string; + originalFontSubset?: boolean; +}): EmittedLine[] { + const rot = rotationFromMatrix(opts.run.matrix); + const out: EmittedLine[] = []; + for (let i = 0; i < opts.lines.length; i++) { + const text = opts.lines[i]; + const origin = opts.origins[i]; + if (!origin) continue; + if (text.length === 0) { + out.push({ ptrs: [], text: "", texts: [], x: origin.x, y: origin.y }); + continue; + } + const texts: string[] = []; + const ptrs = emitTextLine({ + outTexts: texts, + doc: opts.doc, + page: opts.page, + text, + x: origin.x, + y: origin.y, + fontSize: opts.run.fontSize, + fill: opts.run.fill, + ...inkFromRun(opts.run), + originalFontPtr: opts.originalFontPtr, + originalFontSubset: opts.originalFontSubset, + charSpacingPt: opts.run.charSpacingPt, + fallbackFamily: opts.fallbackFamily, + // Keep the run's rotation on re-emit (no-op for upright text). + rotation: rot, + }); + out.push({ ptrs, text, texts, x: origin.x, y: origin.y }); + } + return out; +} + +// How a run's glyphs are painted, other than the fill. Spread as a unit so a +// call site cannot carry the render mode and forget the outline. +export function inkFromRun(run: { + renderMode?: number; + stroke?: RGBA | null; + strokeWidth?: number; +}): InkState { + return { + renderMode: run.renderMode, + stroke: run.stroke ?? null, + strokeWidth: run.strokeWidth, + }; +} + +/** Re-apply render mode and outline to freshly created text objects. */ +export function applyInkState( + m: WrappedPdfiumModule, + ptrs: number[], + ink: InkState, +): void { + const mod = m as unknown as InkModule; + const mode = ink.renderMode ?? 0; + const stroke = ink.stroke ?? null; + const width = ink.strokeWidth ?? 0; + for (const p of ptrs) { + if (!p) continue; + try { + // Written unconditionally: skipping mode 0 means nothing could ever put + // an object back to fill-only, so undoing an outline left it stroked. + mod.FPDFTextObj_SetTextRenderMode?.(p, mode); + if (stroke) { + mod.FPDFPageObj_SetStrokeColor?.( + p, + stroke.r, + stroke.g, + stroke.b, + stroke.a, + ); + mod.FPDFPageObj_SetStrokeWidth?.(p, width); + } else { + // A transparent zero-width stroke is how "no outline" is expressed. + mod.FPDFPageObj_SetStrokeWidth?.(p, 0); + mod.FPDFPageObj_SetStrokeColor?.(p, 0, 0, 0, 0); + } + } catch { + /* best-effort */ + } + } +} + +function applyFillAndPos( + m: WrappedPdfiumModule, + page: Page, + ptr: number, + fill: { r: number; g: number; b: number; a: number }, + x: number, + y: number, +): void { + m.FPDFPageObj_SetFillColor(ptr, fill.r, fill.g, fill.b, fill.a); + m.FPDFPageObj_Transform(ptr, 1, 0, 0, 1, x, y); + m.FPDFPage_InsertObject(page.pagePtr, ptr); +} + +/** Insert a filled rectangle (cover/background) and return its pointer. */ +export function emitFillRect( + m: WrappedPdfiumModule, + page: Page, + bounds: { x: number; y: number; width: number; height: number }, + fill: { r: number; g: number; b: number }, + margin = 1.5, +): number { + const ptr = m.FPDFPageObj_CreateNewRect( + bounds.x - margin, + bounds.y - margin, + bounds.width + margin * 2, + bounds.height + margin * 2, + ); + if (!ptr) return 0; + m.FPDFPageObj_SetFillColor(ptr, fill.r, fill.g, fill.b, 255); + m.FPDFPath_SetDrawMode(ptr, 2, false); + m.FPDFPage_InsertObject(page.pagePtr, ptr); + return ptr; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/partialEdit.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/partialEdit.ts new file mode 100644 index 0000000000..9fa46d4751 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/partialEdit.ts @@ -0,0 +1,1460 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { + ParagraphLineSlot, + TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; +import { + cssFontSpecFor, + emitTextLine, + inkFromRun, + isVerifiedPerCharPtr, + measureObjRightEdgePt, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { + fallbackFamilyFor, + fallbackFontIdFor, +} from "@app/tools/pdfTextEditor/util/fontCapability"; +import { writeUtf16 } from "@app/services/pdfiumService"; +import { transformObject } from "@app/tools/pdfTextEditor/util/objectTransform"; + +/** Set the text of an EXISTING PDFium text object, preserving its font. */ +export function setObjText( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptr: number, + text: string, +): void { + if (!ptr) return; + const buf = writeUtf16(m, text); + try { + m.FPDFText_SetText(ptr, buf); + } catch { + /* best-effort */ + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +/** Read a text object's left/right edge in page points. */ +function objBoundsLR( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptr: number, + fallbackX: number, +): { x: number; right: number } { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(ptr, l, b, r, t)) { + return { x: fallbackX, right: fallbackX }; + } + return { + x: m.pdfium.getValue(l, "float"), + right: m.pdfium.getValue(r, "float"), + }; + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +// Map freshly-emitted line objects back to the text they carry, building the +// slot's mergedFrom* arrays. `emitted` is emitTextLine's own record of what +// each ptr holds - it emits per word OR per character, so deriving it from the +// text mislabels every ptr past the word count. +function buildSlotMerged( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptrs: number[], + text: string, + leftX: number, + emitted?: string[], +): { + ptrs: number[]; + texts: string[]; + bounds: Array<{ x: number; right: number }>; + charStarts: number[]; +} { + const outPtrs: number[] = []; + const texts: string[] = []; + const bounds: Array<{ x: number; right: number }> = []; + const charStarts: number[] = []; + const words: Array<{ text: string; start: number }> = []; + if (emitted && emitted.length === ptrs.length) { + let at = 0; + for (const piece of emitted) { + const found = text.indexOf(piece, at); + const start = found >= 0 ? found : at; + words.push({ text: piece, start }); + at = start + piece.length; + } + } else { + const re = /\S+/g; + let wm: RegExpExecArray | null; + while ((wm = re.exec(text)) !== null) { + words.push({ text: wm[0], start: wm.index }); + } + } + for (let i = 0; i < ptrs.length; i++) { + const w = words[i]; + const b = objBoundsLR(m, ptrs[i], leftX); + outPtrs.push(ptrs[i]); + texts.push(w ? w.text : ""); + bounds.push({ x: b.x, right: b.right }); + charStarts.push(w ? w.start : text.length); + } + return { ptrs: outPtrs, texts, bounds, charStarts }; +} + +// Astral characters (emoji, math symbols, CJK ext-B) are two UTF-16 code units. +// The planners index by code UNIT, so a boundary landing between the halves +// would emit a lone surrogate. The helpers below let the planners bail only on +// the edits that actually cut a pair, instead of on any text containing one. +const HI_MIN = 0xd800; +const HI_MAX = 0xdbff; +const LO_MIN = 0xdc00; +const LO_MAX = 0xdfff; + +/** Any surrogate code unit at all - BMP-only text skips every check below. */ +function hasAnySurrogate(s: string): boolean { + for (let i = 0; i < s.length; i++) { + const u = s.charCodeAt(i); + if (u >= HI_MIN && u <= LO_MAX) return true; + } + return false; +} + +/** No orphaned half: every high surrogate is followed by its low. */ +function isWellFormedUtf16(s: string): boolean { + for (let i = 0; i < s.length; i++) { + const u = s.charCodeAt(i); + if (u >= HI_MIN && u <= HI_MAX) { + const next = i + 1 < s.length ? s.charCodeAt(i + 1) : 0; + if (next < LO_MIN || next > LO_MAX) return false; + i++; + continue; + } + if (u >= LO_MIN && u <= LO_MAX) return false; + } + return true; +} + +/** True when slicing `s` at code-unit `idx` would not cut a surrogate pair. */ +function isCodePointBoundary(s: string, idx: number): boolean { + if (idx <= 0 || idx >= s.length) return true; + const before = s.charCodeAt(idx - 1); + const at = s.charCodeAt(idx); + return !( + before >= HI_MIN && + before <= HI_MAX && + at >= LO_MIN && + at <= LO_MAX + ); +} + +/** Push a slice end off the middle of a pair so no half is orphaned. */ +function toCodePointBoundary(s: string, idx: number): number { + return isCodePointBoundary(s, idx) ? idx : idx + 1; +} + +// Both halves of every astral char must share the SAME fate in the diff, and a +// kept pair must stay adjacent on the other side. Sibling emoji share a high +// surrogate (U+1F600 and U+1F601 are both \uD83D...), so the code-unit LCS can +// match the highs and drop the lows - exactly the case this rejects. +function surrogatePairsSurviveTogether( + prev: string, + next: string, + keptA: Set, + keptB: Set, + alignment: Array<{ aIdx: number; bIdx: number }>, +): boolean { + const aToB = new Map(); + const bToA = new Map(); + for (const { aIdx, bIdx } of alignment) { + aToB.set(aIdx, bIdx); + bToA.set(bIdx, aIdx); + } + for (let a = 0; a + 1 < prev.length; a++) { + const hi = prev.charCodeAt(a); + if (hi < HI_MIN || hi > HI_MAX) continue; + const lo = prev.charCodeAt(a + 1); + if (lo < LO_MIN || lo > LO_MAX) continue; + if (keptA.has(a) !== keptA.has(a + 1)) return false; + if (keptA.has(a) && aToB.get(a + 1) !== (aToB.get(a) ?? -2) + 1) + return false; + a++; + } + for (let b = 0; b + 1 < next.length; b++) { + const hi = next.charCodeAt(b); + if (hi < HI_MIN || hi > HI_MAX) continue; + const lo = next.charCodeAt(b + 1); + if (lo < LO_MIN || lo > LO_MAX) continue; + if (keptB.has(b) !== keptB.has(b + 1)) return false; + if (keptB.has(b) && bToA.get(b + 1) !== (bToA.get(b) ?? -2) + 1) + return false; + b++; + } + return true; +} + +/** Diff-driven partial editing. */ +export interface PartialEditOp { + type: "keep" | "insert" | "modify"; + /** keep / modify: sub-run index in run.mergedFromPtrs */ + subRunIdx?: number; + /** insert: text to emit in fallback font. modify: surviving chars to + * SetText onto the existing object (keeps its embedded font). */ + text?: string; + // insert only: the original sub-run this insert is replacing (came from a + // "mixed" sub-run whose kept chars need a new emit). + anchorSubRunIdx?: number; + /** insert only: the FOLLOWING kept sub-run this insert is a prefix of. */ + anchorBeforeSubRunIdx?: number; + // insert only: how many whitespace chars in nextText sit between the previous + // emitted glyph and this insert but belong to NO sub-run. + leadingGhostCount?: number; + /** Position in nextText where this op's first char lives. */ + startBIdx: number; +} + +export interface PartialEditPlan { + removePtrs: Array<{ ptr: number; containerPtr: number }>; + ops: PartialEditOp[]; + /** Per-sub-run status (parallel to prevMergedFromPtrs). */ + subRunStatus: Array<"all-kept" | "all-deleted" | "mixed">; + /** Snapshot of current model arrays for revert. */ + prevMergedFromPtrs: number[]; + prevMergedFromTexts: string[]; + prevMergedFromBounds: Array<{ x: number; right: number }>; +} + +function lcsIndices( + a: string, + b: string, +): { + keptA: Set; + keptB: Set; + alignment: Array<{ aIdx: number; bIdx: number }>; +} { + const m = a.length; + const n = b.length; + const dp: Int32Array[] = new Array(m + 1); + for (let i = 0; i <= m; i++) dp[i] = new Int32Array(n + 1); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1; + else + dp[i][j] = dp[i - 1][j] >= dp[i][j - 1] ? dp[i - 1][j] : dp[i][j - 1]; + } + } + const keptA = new Set(); + const keptB = new Set(); + const alignment: Array<{ aIdx: number; bIdx: number }> = []; + let i = m; + let j = n; + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + keptA.add(i - 1); + keptB.add(j - 1); + alignment.unshift({ aIdx: i - 1, bIdx: j - 1 }); + i--; + j--; + } else if (dp[i - 1][j] >= dp[i][j - 1]) { + i--; + } else { + j--; + } + } + return { keptA, keptB, alignment }; +} + +export function planPartialEdit( + run: TextRun, + prevText: string, + nextText: string, +): PartialEditPlan | null { + if (run.mergedFromPtrs.length === 0) return null; + if (run.mergedFromTexts.length !== run.mergedFromPtrs.length) return null; + if (run.mergedFromBounds.length !== run.mergedFromPtrs.length) return null; + if (nextText.length === 0) return null; + if (prevText === nextText) return null; + // Astral text is diffed in code UNITS. Rather than refusing every run that + // holds a pair, refuse only the edits that would cut one (checked below). + const astral = hasAnySurrogate(prevText) || hasAnySurrogate(nextText); + if ( + astral && + (!isWellFormedUtf16(prevText) || !isWellFormedUtf16(nextText)) + ) { + return null; + } + + let { keptA, keptB, alignment } = lcsIndices(prevText, nextText); + + // Pure append (nextText starts with prevText): force the trivial 1:1 prefix + // alignment. + if (nextText.startsWith(prevText)) { + keptA = new Set(); + keptB = new Set(); + alignment = []; + for (let i = 0; i < prevText.length; i++) { + keptA.add(i); + keptB.add(i); + alignment.push({ aIdx: i, bIdx: i }); + } + } + + // Only now, against the alignment the ops walk will actually use: a diff + // boundary landing inside an astral char would emit a lone surrogate. + if ( + astral && + !surrogatePairsSurviveTogether(prevText, nextText, keptA, keptB, alignment) + ) { + return null; + } + + // Read per-sub-run char-start positions directly off the run. + if ( + run.mergedFromCharStarts.length !== run.mergedFromPtrs.length || + run.mergedFromCharStarts.some((s) => s < 0 || s > prevText.length) + ) { + // Stale or missing char-starts (e.g. an overlay-path edit cleared + // the ptrs without also setting char-starts). Bail safely. + return null; + } + const charToSubRun = new Array(prevText.length).fill(-1); + const subRunRanges: Array<{ start: number; end: number } | null> = []; + for (let i = 0; i < run.mergedFromTexts.length; i++) { + const subText = run.mergedFromTexts[i]; + const start = run.mergedFromCharStarts[i]; + const end = start + subText.length; + if (subText.length === 0) { + subRunRanges.push({ start, end }); + continue; + } + if (end > prevText.length) return null; + // Sanity check: the stored chars must actually match prevText at + // that position. Catches model corruption without silent drift. + if (prevText.slice(start, end) !== subText) return null; + // A sub-run split mid-pair would make "modify" SetText half a char. + if ( + astral && + (!isCodePointBoundary(prevText, start) || + !isCodePointBoundary(prevText, end)) + ) { + return null; + } + for (let c = start; c < end; c++) { + charToSubRun[c] = i; + } + subRunRanges.push({ start, end }); + } + + // Classify sub-runs by counting how many of their own chars (the + // tracked range, not ghost gaps) survived the LCS. + const subRunStatus: Array<"all-kept" | "all-deleted" | "mixed"> = []; + const mixedSubRuns = new Set(); + // For each mixed sub-run, the surviving chars (in original order). + const mixedSurviving = new Map(); + for (let i = 0; i < run.mergedFromTexts.length; i++) { + const range = subRunRanges[i]; + if (!range) { + subRunStatus.push("all-kept"); + continue; + } + const subLen = range.end - range.start; + if (subLen === 0) { + subRunStatus.push("all-kept"); + continue; + } + let keptCount = 0; + let surviving = ""; + for (let c = range.start; c < range.end; c++) { + if (keptA.has(c)) { + keptCount += 1; + surviving += prevText[c]; + } + } + if (keptCount === 0) subRunStatus.push("all-deleted"); + else if (keptCount === subLen) subRunStatus.push("all-kept"); + else if (surviving.trim() === "") { + // Only whitespace survives this partially-deleted sub-run. + subRunStatus.push("all-deleted"); + } else { + subRunStatus.push("mixed"); + mixedSubRuns.add(i); + mixedSurviving.set(i, surviving); + } + } + + // Build ops by walking nextText. + const ops: PartialEditOp[] = []; + let lastSubRun = -1; + let insertBuf = ""; + let insertAnchorSubRun: number | undefined; + let insertStartBIdx = 0; + // bIdx of the last char that produced (or rode on) a glyph - i.e. a kept real + // char, a modified char, or an inserted char. + let lastEmittedBIdx = -1; + // Ghost whitespace chars sitting right before the pending insert. + let insertLeadingGhosts = 0; + // Mixed sub-runs we've already emitted a single "modify" op for, so a + // later surviving char from the same sub-run doesn't emit a second. + const modifiedSubRuns = new Set(); + function flushInsert(anchorBeforeSubRunIdx?: number): void { + if (insertBuf.length === 0) return; + ops.push({ + type: "insert", + text: insertBuf, + anchorSubRunIdx: insertAnchorSubRun, + anchorBeforeSubRunIdx, + leadingGhostCount: insertLeadingGhosts, + startBIdx: insertStartBIdx, + }); + insertBuf = ""; + insertAnchorSubRun = undefined; + insertLeadingGhosts = 0; + } + // Map next-bIdx → aIdx via alignment array + const bToA = new Map(); + for (const { aIdx, bIdx } of alignment) bToA.set(bIdx, aIdx); + + // INTERIOR-INSERT GUARD. Single-char sub-runs have no interior. + { + const keptMin = new Map(); + const keptMax = new Map(); + const keptCnt = new Map(); + for (const b of keptB) { + const a = bToA.get(b); + if (a === undefined) continue; + const sr = charToSubRun[a]; + if (sr < 0) continue; + keptMin.set(sr, Math.min(keptMin.get(sr) ?? b, b)); + keptMax.set(sr, Math.max(keptMax.get(sr) ?? b, b)); + keptCnt.set(sr, (keptCnt.get(sr) ?? 0) + 1); + } + for (const [sr, cnt] of keptCnt) { + if (keptMax.get(sr)! - keptMin.get(sr)! + 1 !== cnt) return null; + } + } + + for (let b = 0; b < nextText.length; b++) { + if (keptB.has(b)) { + const a = bToA.get(b)!; + const subRunIdx = charToSubRun[a]; + // Ghost char (LineGrouper-synthesised whitespace, not part of any PDFium + // text object). + if (subRunIdx === -1) continue; + // Whitespace-only survivor of a now-deleted sub-run: drop, never keep its ptr. + if (subRunStatus[subRunIdx] === "all-deleted") continue; + // Surviving chars of a mixed sub-run keep their ORIGINAL embedded font: + // we SetText the surviving substring back onto the existing. + if (mixedSubRuns.has(subRunIdx)) { + flushInsert(); + if (!modifiedSubRuns.has(subRunIdx)) { + ops.push({ + type: "modify", + subRunIdx, + text: mixedSurviving.get(subRunIdx) ?? "", + startBIdx: b, + }); + modifiedSubRuns.add(subRunIdx); + } + lastEmittedBIdx = b; + continue; + } + // A pending pure-insert that ends in a non-whitespace char, sits at the + // START of this NEW sub-run. + let anchorBeforeIdx: number | undefined; + if ( + insertBuf.length > 0 && + insertAnchorSubRun === undefined && + subRunIdx !== lastSubRun && + !/\s$/.test(insertBuf) && + (insertStartBIdx === 0 || /\s/.test(nextText[insertStartBIdx - 1])) + ) { + anchorBeforeIdx = subRunIdx; + } + flushInsert(anchorBeforeIdx); + if (subRunIdx !== lastSubRun) { + ops.push({ type: "keep", subRunIdx, startBIdx: b }); + lastSubRun = subRunIdx; + } + lastEmittedBIdx = b; + } else { + if (insertBuf.length === 0) { + insertStartBIdx = b; + // Whitespace chars skipped since the last real glyph are ghost + // spaces this insert must sit AFTER (not on top of). + insertLeadingGhosts = Math.max(0, b - lastEmittedBIdx - 1); + } + insertBuf += nextText[b]; + lastEmittedBIdx = b; + } + } + flushInsert(); + + // Collect removals: only ALL-deleted sub-runs. + const removePtrs: Array<{ ptr: number; containerPtr: number }> = []; + for (let i = 0; i < run.mergedFromPtrs.length; i++) { + if (subRunStatus[i] === "all-deleted") { + removePtrs.push({ + ptr: run.mergedFromPtrs[i], + containerPtr: run.containerPtr, + }); + } + } + + if (ops.length === 0) return null; + + return { + removePtrs, + ops, + subRunStatus, + prevMergedFromPtrs: [...run.mergedFromPtrs], + prevMergedFromTexts: [...run.mergedFromTexts], + prevMergedFromBounds: run.mergedFromBounds.map((b) => ({ ...b })), + }; +} + +let _wsMeasureCanvas: HTMLCanvasElement | null = null; +/** Canvas-measured advance width for whitespace chars. */ +function measureWhitespaceAdvancePt( + text: string, + fontFamily: string, + fontSizePt: number, +): number { + if (typeof document === "undefined") return text.length * fontSizePt * 0.27; + if (!_wsMeasureCanvas) _wsMeasureCanvas = document.createElement("canvas"); + const ctx = _wsMeasureCanvas.getContext("2d"); + if (!ctx) return text.length * fontSizePt * 0.27; + // px on purpose: an n-px font measured in px returns the same number as + // an n-pt font in pt; `${n}pt` would inflate the result by 4/3. + ctx.font = cssFontSpecFor(fontFamily, fontSizePt); + return ctx.measureText(text).width; +} + +interface FontReadingModule { + FPDFTextObj_GetFont?: (ptr: number) => number; +} + +// Borrow the font handle from the FIRST surviving sub-object that wasn't slated +// for removal. +function borrowFontFromSurvivor( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + plan: PartialEditPlan, +): number { + const fontMod = m as unknown as FontReadingModule; + if (!fontMod.FPDFTextObj_GetFont) return 0; + const removed = new Set(plan.removePtrs.map((r) => r.ptr)); + for (let i = 0; i < plan.prevMergedFromPtrs.length; i++) { + const ptr = plan.prevMergedFromPtrs[i]; + if (!ptr || removed.has(ptr)) continue; + try { + const fontPtr = fontMod.FPDFTextObj_GetFont(ptr); + if (fontPtr) return fontPtr; + } catch { + /* try next survivor */ + } + } + return 0; +} + +// Borrow the font of a surviving sub-object that ACTUALLY CONTAINS the +// characters we're about to insert. +function borrowFontForChars( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + plan: PartialEditPlan, + chars: string, +): number { + const fontMod = m as unknown as FontReadingModule; + if (!fontMod.FPDFTextObj_GetFont) return 0; + const removed = new Set(plan.removePtrs.map((r) => r.ptr)); + const want = new Set([...chars].filter((c) => c.trim().length > 0)); + if (want.size > 0) { + // Prefer a survivor whose text shares the most chars with the insert + // (so multi-char inserts pick a font covering as much as possible). + let bestPtr = 0; + let bestScore = 0; + for (let i = 0; i < plan.prevMergedFromPtrs.length; i++) { + const ptr = plan.prevMergedFromPtrs[i]; + if (!ptr || removed.has(ptr)) continue; + const text = plan.prevMergedFromTexts[i] ?? ""; + let score = 0; + for (const c of text) if (want.has(c)) score += 1; + if (score > bestScore) { + bestScore = score; + bestPtr = ptr; + } + } + if (bestPtr) { + try { + const fontPtr = fontMod.FPDFTextObj_GetFont(bestPtr); + if (fontPtr) return fontPtr; + } catch { + /* fall through */ + } + } + } + return borrowFontFromSurvivor(m, plan); +} + +interface FormRemovalModule { + FPDFFormObj_RemoveObject?: (form: number, obj: number) => boolean; +} + +export interface PartialEditApplyResult { + newMergedFromPtrs: number[]; + newMergedFromTexts: string[]; + newMergedFromBounds: Array<{ x: number; right: number }>; + /** Per-sub-run char-start positions in the NEW run.text (post-edit). */ + newMergedFromCharStarts: number[]; + insertedPtrs: number[]; + newBoundsX: number; + newBoundsWidth: number; +} + +export function applyPartialEditPlan( + doc: EditorDocument, + page: Page, + run: TextRun, + plan: PartialEditPlan, + /** Override the baseline used for emitted inserts. */ + baselineY?: number, + // Override the left edge used for the FIRST unanchored insert (before any + // keep op has set the cursor). + defaultX?: number, +): PartialEditApplyResult { + const m = doc.module; + const formMod = m as unknown as FormRemovalModule; + const emitY = baselineY ?? run.matrix.f; + const startX = defaultX ?? run.bounds.x; + // Removals run before the walk below: it re-emits from the surviving + // pointers, so a deleted object still on the page would be re-counted. + for (const { ptr, containerPtr } of plan.removePtrs) { + if (!ptr) continue; + if (containerPtr && formMod.FPDFFormObj_RemoveObject) { + try { + formMod.FPDFFormObj_RemoveObject(containerPtr, ptr); + } catch { + /* best-effort */ + } + } else { + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + } + + const fallbackFamily = fallbackFamilyFor(run.fontId); + const newMergedFromPtrs: number[] = []; + const newMergedFromTexts: string[] = []; + const newMergedFromBounds: Array<{ x: number; right: number }> = []; + const newMergedFromCharStarts: number[] = []; + const insertedPtrs: number[] = []; + + // Font-borrow strategy for inserted text: Embedded CID fonts have no reliable + // Unicode→CID reverse lookup (ToUnicode CMaps are one-way by design). + const survivingChars = new Set(); + for (let i = 0; i < plan.prevMergedFromTexts.length; i++) { + if (plan.subRunStatus[i] !== "all-deleted") { + for (const ch of plan.prevMergedFromTexts[i]) survivingChars.add(ch); + } + } + for (const otherPage of doc.loadedPages()) { + for (const otherRun of otherPage.runs) { + if (otherRun.fontId !== run.fontId) continue; + for (const ch of otherRun.text) survivingChars.add(ch); + for (const sub of otherRun.mergedFromTexts) { + for (const ch of sub) survivingChars.add(ch); + } + } + } + let allInsertCharsAreSafe = true; + for (const op of plan.ops) { + if (op.type === "insert" && op.text) { + for (const ch of op.text) { + if (!survivingChars.has(ch)) { + allInsertCharsAreSafe = false; + break; + } + } + } + if (!allInsertCharsAreSafe) break; + } + + // Strategy: walk ops in order. + let firstX = startX; + let lastEnd = startX; + let offset = 0; + // Tracks the highest sub-run index we've already accounted for in `offset`. + let processedUpTo = -1; + function absorbDeletesBefore(idx: number): void { + for (let i = processedUpTo + 1; i < idx; i++) { + if (plan.subRunStatus[i] === "all-deleted") { + const b = plan.prevMergedFromBounds[i]; + if (!b) continue; + // Subtract the deleted sub-run's ADVANCE, not just its ink width. + const next = plan.prevMergedFromBounds[i + 1]; + offset -= next && next.x > b.x ? next.x - b.x : b.right - b.x; + } + } + processedUpTo = Math.max(processedUpTo, idx); + } + + for (const op of plan.ops) { + if (op.type === "keep" && op.subRunIdx !== undefined) { + absorbDeletesBefore(op.subRunIdx); + const ptr = plan.prevMergedFromPtrs[op.subRunIdx]; + const text = plan.prevMergedFromTexts[op.subRunIdx]; + const origBounds = plan.prevMergedFromBounds[op.subRunIdx]; + if (Math.abs(offset) > 0.05) { + try { + transformObject(m, ptr, 1, 0, 0, 1, offset, 0); + } catch { + /* best-effort */ + } + } + const newX = origBounds.x + offset; + const newRight = origBounds.right + offset; + newMergedFromPtrs.push(ptr); + newMergedFromTexts.push(text); + newMergedFromBounds.push({ x: newX, right: newRight }); + newMergedFromCharStarts.push(op.startBIdx); + if (newRight > lastEnd) lastEnd = newRight; + } else if ( + op.type === "modify" && + op.subRunIdx !== undefined && + op.text !== undefined + ) { + // Edit a mixed sub-run's EXISTING object in place: SetText the surviving + // chars so the embedded font is kept. + absorbDeletesBefore(op.subRunIdx); + const ptr = plan.prevMergedFromPtrs[op.subRunIdx]; + const origBounds = plan.prevMergedFromBounds[op.subRunIdx]; + const origWidth = origBounds.right - origBounds.x; + const modText = op.text; + // Read the object's own font BEFORE we touch it, so a fallback re-emit + // can reuse the same embedded font via the charcode/backend path. + const modFontPtr = objFontPtr(m, ptr); + setObjText(m, ptr, modText); + if (Math.abs(offset) > 0.05) { + try { + transformObject(m, ptr, 1, 0, 0, 1, offset, 0); + } catch { + /* best-effort */ + } + } + const newX = origBounds.x + offset; + const measuredRight = measureObjRightEdgePt(m, ptr); + // Validate the in-place SetText the SAME way inserts are validated. + const modNonWs = modText.replace(/\s+/g, "").length; + const modMinExpected = modNonWs * run.fontSize * 0.15; + if (modNonWs > 0 && measuredRight - newX < modMinExpected) { + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + const reptrs = emitTextLine({ + doc, + page, + text: modText, + x: newX, + y: emitY, + fontSize: run.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: modFontPtr, + originalFontSubset: run.fontSubset, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + let reRight = newX; + for (const rp of reptrs) { + const r = measureObjRightEdgePt(m, rp); + if (r > reRight) reRight = r; + } + if (reptrs.length === 0) { + // Nothing representable emitted - treat like a deletion: the sub-run's + // width collapses and following sub-runs shift left to close the gap. + offset -= origWidth; + } else { + // Slice modText across the re-emitted ptrs so each stored text is + // contiguous and the next edit's char-range sanity check still tiles. + const total = reRight - newX; + const per = Math.max(1, Math.floor(modText.length / reptrs.length)); + let cur = newX; + let charCursor = 0; + for (let i = 0; i < reptrs.length; i++) { + const isLast = i === reptrs.length - 1; + const slice = isLast + ? modText.slice(charCursor) + : modText.slice( + charCursor, + toCodePointBoundary(modText, charCursor + per), + ); + const w = total / reptrs.length; + newMergedFromPtrs.push(reptrs[i]); + newMergedFromTexts.push(slice); + newMergedFromBounds.push({ x: cur, right: cur + w }); + newMergedFromCharStarts.push(op.startBIdx + charCursor); + insertedPtrs.push(reptrs[i]); + cur += w; + charCursor += slice.length; + } + if (reRight > lastEnd) lastEnd = reRight; + offset += reRight - newX - origWidth; + } + } else { + const newRight = + measuredRight > newX ? measuredRight : newX + origWidth; + newMergedFromPtrs.push(ptr); + newMergedFromTexts.push(modText); + newMergedFromBounds.push({ x: newX, right: newRight }); + newMergedFromCharStarts.push(op.startBIdx); + if (newRight > lastEnd) lastEnd = newRight; + // Subsequent sub-runs shift by the width delta (surviving text is + // usually narrower than the original). + offset += newRight - newX - origWidth; + } + } else if (op.type === "insert" && op.text) { + const insertText = op.text; + const anchorIdx = op.anchorSubRunIdx; + const beforeIdx = op.anchorBeforeSubRunIdx; + if (anchorIdx !== undefined) absorbDeletesBefore(anchorIdx); + else if (beforeIdx !== undefined) absorbDeletesBefore(beforeIdx); + const origBounds = + anchorIdx !== undefined ? plan.prevMergedFromBounds[anchorIdx] : null; + // "prefix of the following word" anchor: emit at that kept sub-run's + // original left edge so the insert + the glyphs after it read as one. + const beforeBounds = + beforeIdx !== undefined ? plan.prevMergedFromBounds[beforeIdx] : null; + // Anchor priority: * anchorSubRunIdx: emit at the replaced sub-run's x. + const leadingGap = + (op.leadingGhostCount ?? 0) * Math.max(1, run.fontSize) * 0.25; + const anchorX = origBounds + ? origBounds.x + offset + : beforeBounds + ? beforeBounds.x + offset + : lastEnd + leadingGap; + + // Borrow the font from a survivor that actually contains the inserted + // chars, so the new glyph reuses that exact embedded font. + const borrowedFontPtr = allInsertCharsAreSafe + ? borrowFontForChars(m, plan, insertText) + : 0; + + // Try the borrowed source font first; measure the result and fall back to + // Helvetica if the rendered width is sub-threshold. + let ptrs = emitTextLine({ + doc, + page, + text: insertText, + x: anchorX, + y: emitY, + fontSize: run.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: borrowedFontPtr, + originalFontSubset: run.fontSubset, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + let realRightEdge = anchorX; + for (const ptr of ptrs) { + const r = measureObjRightEdgePt(m, ptr); + if (r > realRightEdge) realRightEdge = r; + } + let measuredWidth = realRightEdge - anchorX; + + // Heuristic: a working visible glyph is at least ~0.15 * fontSize wide. + const nonWhitespaceLen = insertText.replace(/\s/g, "").length; + const minExpected = nonWhitespaceLen * run.fontSize * 0.15; + // Skip the tofu retry when ALL returned ptrs came from the per-char + // backend emit branch in emitTextLine. + const allVerified = + ptrs.length > 0 && ptrs.every((p) => isVerifiedPerCharPtr(p)); + if ( + !allVerified && + borrowedFontPtr !== 0 && + nonWhitespaceLen > 0 && + measuredWidth < minExpected + ) { + // Remove the failed text objects before retrying. + for (const ptr of ptrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + ptrs = emitTextLine({ + doc, + page, + text: insertText, + x: anchorX, + y: emitY, + fontSize: run.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: 0, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + realRightEdge = anchorX; + for (const ptr of ptrs) { + const r = measureObjRightEdgePt(m, ptr); + if (r > realRightEdge) realRightEdge = r; + } + measuredWidth = realRightEdge - anchorX; + } + // Add the advance width of whitespace chars so the offset that shifts + // following kept sub-runs accounts for inserted spaces. + const whitespaceLen = insertText.length - nonWhitespaceLen; + if (whitespaceLen > 0) { + const wsWidth = measureWhitespaceAdvancePt( + " ".repeat(whitespaceLen), + fallbackFamily, + run.fontSize, + ); + // Letter-spaced runs stretch inserted spaces too (Tc applies to + // space glyphs), matching the widened gaps emitTextLine produced. + measuredWidth += wsWidth + run.charSpacingPt * whitespaceLen; + } + // Map emitted ptrs back to text. emitTextLine emits one ptr per + // whitespace-separated WORD on the normal path. + const insertWords: Array<{ text: string; start: number }> = []; + { + const wordRe = /\S+/g; + let wm: RegExpExecArray | null; + while ((wm = wordRe.exec(insertText)) !== null) { + insertWords.push({ text: wm[0], start: wm.index }); + } + } + if (ptrs.length === insertWords.length) { + for (let i = 0; i < ptrs.length; i++) { + const word = insertWords[i]; + if (!word) { + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptrs[i]); + } catch { + /* best-effort */ + } + continue; + } + const bnds = objBoundsLR(m, ptrs[i], anchorX); + newMergedFromPtrs.push(ptrs[i]); + newMergedFromTexts.push(word.text); + newMergedFromBounds.push({ x: bnds.x, right: bnds.right }); + newMergedFromCharStarts.push(op.startBIdx + word.start); + insertedPtrs.push(ptrs[i]); + } + } else { + // Per-char (or mismatched) emit: slice the insert text across ptrs. + let runningCursor = anchorX; + const charsPerPtr = Math.max( + 1, + Math.floor(insertText.length / Math.max(1, ptrs.length)), + ); + let charCursor = 0; + for (let i = 0; i < ptrs.length; i++) { + const sliceWidth = measuredWidth / ptrs.length; + const isLast = i === ptrs.length - 1; + const sliceText = isLast + ? insertText.slice(charCursor) + : insertText.slice( + charCursor, + toCodePointBoundary(insertText, charCursor + charsPerPtr), + ); + newMergedFromPtrs.push(ptrs[i]); + newMergedFromTexts.push(sliceText); + newMergedFromBounds.push({ + x: runningCursor, + right: runningCursor + sliceWidth, + }); + newMergedFromCharStarts.push(op.startBIdx + charCursor); + insertedPtrs.push(ptrs[i]); + runningCursor += sliceWidth; + charCursor += sliceText.length; + } + } + if (realRightEdge > lastEnd) lastEnd = realRightEdge; + // Update offset: * anchored (mixed-replacement): delta vs original + // sub-run width. + if (origBounds) { + const origWidth = origBounds.right - origBounds.x; + offset += measuredWidth - origWidth; + } else if (beforeBounds) { + offset += measuredWidth; + } else { + // The ghost-space gap also pushes everything after this insert right. + offset += leadingGap + measuredWidth; + } + } + } + + page.markNeedsGenerate(); + + if (newMergedFromBounds.length > 0) { + firstX = newMergedFromBounds[0].x; + } + + // newMergedFromCharStarts is populated inline by the ops walk above. + + return { + newMergedFromPtrs, + newMergedFromTexts, + newMergedFromBounds, + newMergedFromCharStarts, + insertedPtrs, + newBoundsX: firstX, + newBoundsWidth: lastEnd - firstX, + }; +} + +/** Paragraph-aware partial edit. */ +export interface ParagraphEditPlan { + /** Per-slot per-line plan, parallel to `run.paragraphLineSlots`. */ + perSlot: Array<{ + slotIdx: number; + plan: PartialEditPlan | null; + nextLine: string; + }>; + /** Per-VISUAL-line next text, parallel to `run.paragraphLineSlots`. */ + nextLines: string[]; + /** Snapshot of the rep's slots for revert. */ + prevSlots: ParagraphLineSlot[]; +} + +/** Count occurrences of a single char in a string. */ +function countChar(s: string, ch: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (s[i] === ch) n++; + return n; +} + +/** True when a plan would SetText whitespace in place via a "modify" op. */ +export function planModifiesWhitespace(plan: PartialEditPlan): boolean { + return plan.ops.some( + (op) => op.type === "modify" && !!op.text && /\s/.test(op.text), + ); +} + +/** Read a text object's own font handle (0 when unavailable). */ +function objFontPtr( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptr: number, +): number { + const fontMod = m as unknown as FontReadingModule; + if (!ptr || !fontMod.FPDFTextObj_GetFont) return 0; + try { + return fontMod.FPDFTextObj_GetFont(ptr) || 0; + } catch { + return 0; + } +} + +// Pick the member object whose text shares the most characters with the text +// about to be emitted, and return ITS font handle. +/** + * The best font handle for `targetText` taken from the OTHER lines of the same + * paragraph, nearest line first. + * + * Only lines whose slot carries the same `fontId` are considered, so a bold or + * italic sub-run inside the paragraph cannot lend its face to plain body text. + * Returns 0 when nothing matches, leaving the caller on its normal fallback. + */ +export function siblingFontPtrForText( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + slots: ParagraphLineSlot[], + selfIndex: number, + fontId: string, + targetText: string, +): number { + const order = slots + .map((s, i) => ({ s, i })) + .filter(({ s, i }) => i !== selfIndex && s.fontId === fontId) + .sort((a, b) => Math.abs(a.i - selfIndex) - Math.abs(b.i - selfIndex)); + for (const { s } of order) { + const ptr = bestFontPtrForText( + m, + s.mergedFromPtrs, + s.mergedFromTexts, + targetText, + ); + if (ptr) return ptr; + } + return 0; +} + +export function bestFontPtrForText( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptrs: number[], + texts: string[], + targetText: string, +): number { + const want = new Set([...targetText].filter((c) => c.trim().length > 0)); + let bestPtr = 0; + let bestScore = 0; + for (let i = 0; i < ptrs.length; i++) { + const ptr = ptrs[i]; + if (!ptr) continue; + let score = 0; + for (const c of texts[i] ?? "") if (want.has(c)) score += 1; + if (score > bestScore) { + bestScore = score; + bestPtr = ptr; + } + } + if (bestPtr) { + const font = objFontPtr(m, bestPtr); + if (font) return font; + } + for (const ptr of ptrs) { + const font = objFontPtr(m, ptr); + if (font) return font; + } + return 0; +} + +// Locate the single contiguous edit between `prev` and `next` via a +// prefix/suffix scan. +function diffSpan( + prev: string, + next: string, +): { start: number; prevEnd: number; nextEnd: number } { + const minLen = Math.min(prev.length, next.length); + let start = 0; + while (start < minLen && prev[start] === next[start]) start++; + let end = 0; + while ( + end < minLen - start && + prev[prev.length - 1 - end] === next[next.length - 1 - end] + ) { + end++; + } + return { start, prevEnd: prev.length - end, nextEnd: next.length - end }; +} + +// Verify the slot char ranges exactly tile `text` with one-char separators +// between visual lines` per slot, a single separator at each `endChar`. +function slotsTileText(slots: ParagraphLineSlot[], text: string): boolean { + if (slots.length === 0) return false; + if (slots[0].startChar !== 0) return false; + for (let i = 0; i < slots.length; i++) { + const s = slots[i]; + if (s.endChar < s.startChar || s.endChar > text.length) return false; + if (i > 0 && s.startChar !== slots[i - 1].endChar + 1) return false; + } + return slots[slots.length - 1].endChar === text.length; +} + +export function planParagraphEdit( + run: TextRun, + prevText: string, + nextText: string, +): ParagraphEditPlan | null { + const slots = run.paragraphLineSlots; + if (slots.length < 2) return null; + if (prevText === nextText) return null; + // Slot ranges are code-unit offsets. Only refuse astral text when a slot + // boundary would cut a pair; the per-line planPartialEdit re-checks the rest. + const astral = hasAnySurrogate(prevText) || hasAnySurrogate(nextText); + if (astral) { + if (!isWellFormedUtf16(prevText) || !isWellFormedUtf16(nextText)) { + return null; + } + for (const s of slots) { + if ( + !isCodePointBoundary(prevText, s.startChar) || + !isCodePointBoundary(prevText, s.endChar) + ) { + return null; + } + } + } + // Per-VISUAL-line text comes from the slot char ranges. + if (!slotsTileText(slots, prevText)) return null; + const prevLines = slots.map((s) => prevText.slice(s.startChar, s.endChar)); + + // A change in the count of hard breaks ("\n") is a structural line add/remove + // the slot model can't express; let the line-edit path handle it. + if (countChar(prevText, "\n") !== countChar(nextText, "\n")) return null; + + // The edit must be confined to a single visual line. + const span = diffSpan(prevText, nextText); + let hitSlot = -1; + for (let i = 0; i < slots.length; i++) { + const s = slots[i]; + if (span.start >= s.startChar && span.prevEnd <= s.endChar) { + hitSlot = i; + break; + } + } + if (hitSlot < 0) return null; + + // Only the hit slot's text changes; its new length shifts by the edit + // delta. Every other visual line is untouched. + const delta = nextText.length - prevText.length; + const nextLines = prevLines.slice(); + const hit = slots[hitSlot]; + nextLines[hitSlot] = nextText.slice(hit.startChar, hit.endChar + delta); + + const perSlot: Array<{ + slotIdx: number; + plan: PartialEditPlan | null; + nextLine: string; + }> = []; + + const prevLine = prevLines[hitSlot]; + const nextLine = nextLines[hitSlot]; + if (prevLine === nextLine) return null; + // A slot with no sub-run objects can't be partially edited (e.g. an empty + // line the user just typed the first character into). + if (hit.mergedFromPtrs.length === 0) { + perSlot.push({ slotIdx: hitSlot, plan: null, nextLine }); + } else { + // Build a synthetic mini-TextRun view of the slot so the existing + // planPartialEdit / applyPartialEditPlan code can operate on it. + const slotView = makeSlotView(run, hit, prevLine); + let plan = planPartialEdit(slotView, prevLine, nextLine); + // An in-place "modify" op re-SetTexts a sub-run's surviving chars. + if (plan && planModifiesWhitespace(plan)) plan = null; + // Per-line LCS couldn't model the change - re-emit just this line + // rather than failing the whole paragraph to the overlay re-emit. + perSlot.push({ slotIdx: hitSlot, plan: plan ?? null, nextLine }); + } + + return { + perSlot, + nextLines, + prevSlots: slots.map((s) => cloneSlot(s)), + }; +} + +export interface ParagraphEditApplyResult { + newSlots: ParagraphLineSlot[]; + insertedPtrs: number[]; + newBoundsX: number; + newBoundsWidth: number; +} + +export function applyParagraphEditPlan( + doc: EditorDocument, + page: Page, + run: TextRun, + paraPlan: ParagraphEditPlan, +): ParagraphEditApplyResult { + const m = doc.module; + // Per-VISUAL-line next text from the plan (slot-range derived). + const lines = paraPlan.nextLines; + const newSlots: ParagraphLineSlot[] = run.paragraphLineSlots.map((s) => + cloneSlot(s), + ); + const planBySlot = new Map< + number, + { plan: PartialEditPlan | null; nextLine: string } + >(); + for (const entry of paraPlan.perSlot) { + planBySlot.set(entry.slotIdx, { + plan: entry.plan, + nextLine: entry.nextLine, + }); + } + + const allInsertedPtrs: number[] = []; + let minX = Infinity; + let maxRight = -Infinity; + + for (let i = 0; i < newSlots.length; i++) { + const slot = newSlots[i]; + const lineText = lines[i] ?? ""; + const planEntry = planBySlot.get(i); + if (!planEntry) { + // Unchanged line - keep slot data, just update bounds tracking. + if (slot.mergedFromBounds.length > 0) { + const first = slot.mergedFromBounds[0]; + const last = slot.mergedFromBounds[slot.mergedFromBounds.length - 1]; + if (first.x < minX) minX = first.x; + if (last.right > maxRight) maxRight = last.right; + } + continue; + } + + if (planEntry.plan === null) { + // Fresh-emit line: this line couldn't be partially edited. + const leftX = slot.mergedFromBounds[0]?.x ?? slot.matrixE; + // Read the font handle BEFORE the objects are removed. + const reuseFontPtr = + bestFontPtrForText( + m, + slot.mergedFromPtrs, + slot.mergedFromTexts, + lineText, + ) || + // A line the user just created with Enter owns no objects yet, so the + // search above has nothing to score and returns 0 - which re-emits it + // in Helvetica while the paragraph around it keeps the document's own + // face. Its SIBLING lines carry exactly the face it should inherit. + siblingFontPtrForText(m, newSlots, i, slot.fontId, lineText); + for (const ptr of slot.mergedFromPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + const fallbackFamily = fallbackFamilyFor(run.fontId); + if (lineText.length > 0) { + const emittedTexts: string[] = []; + const ptrs = emitTextLine({ + outTexts: emittedTexts, + doc, + page, + text: lineText, + x: leftX, + y: slot.baselineY, + fontSize: slot.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: reuseFontPtr, + originalFontSubset: slot.fontSubset, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + const built = buildSlotMerged(m, ptrs, lineText, leftX, emittedTexts); + slot.mergedFromPtrs = built.ptrs; + slot.mergedFromTexts = built.texts; + slot.mergedFromBounds = built.bounds; + slot.mergedFromCharStarts = built.charStarts; + // Only drop to a base-14 identity when the source font wasn't reused; + // otherwise keep the slot's font so the NEXT edit reuses it again. + if (reuseFontPtr === 0) { + slot.fontId = fallbackFontIdFor(fallbackFamily); + slot.fontSubset = false; + } + slot.containerPtr = 0; + allInsertedPtrs.push(...ptrs); + for (const b of built.bounds) { + if (b.x < minX) minX = b.x; + if (b.right > maxRight) maxRight = b.right; + } + } else { + slot.mergedFromPtrs = []; + slot.mergedFromTexts = []; + slot.mergedFromBounds = []; + slot.mergedFromCharStarts = []; + } + slot.endChar = slot.startChar + lineText.length; + continue; + } + + // Run the existing applyPartialEditPlan against the slot, emitting + // at the slot's own baseline and starting from the slot's left x. + const slotView = makeSlotView(run, slot, ""); + const result = applyPartialEditPlan( + doc, + page, + slotView, + planEntry.plan, + slot.baselineY, + slot.mergedFromBounds[0]?.x ?? slot.matrixE, + ); + slot.mergedFromPtrs = result.newMergedFromPtrs; + slot.mergedFromTexts = result.newMergedFromTexts; + slot.mergedFromBounds = result.newMergedFromBounds; + slot.mergedFromCharStarts = result.newMergedFromCharStarts; + allInsertedPtrs.push(...result.insertedPtrs); + if (result.newBoundsX < minX) minX = result.newBoundsX; + if (result.newBoundsX + result.newBoundsWidth > maxRight) { + maxRight = result.newBoundsX + result.newBoundsWidth; + } + // Update slot's char range against the new line text. + slot.endChar = slot.startChar + lineText.length; + } + + // Fix up startChar/endChar across all slots so each slot's range reflects the + // new joined text. + let cursor = 0; + for (let i = 0; i < newSlots.length; i++) { + const lineLen = (lines[i] ?? "").length; + newSlots[i].startChar = cursor; + newSlots[i].endChar = cursor + lineLen; + cursor += lineLen + (i < newSlots.length - 1 ? 1 : 0); + } + + // Re-flatten leaf ptrs from the updated slots so EditTextCommand's + // removal pass can find every original sub-object next time. + const leafPtrs: number[] = []; + const leafContainers: number[] = []; + for (const s of newSlots) { + for (const p of s.mergedFromPtrs) { + leafPtrs.push(p); + leafContainers.push(s.containerPtr); + } + } + run.paragraphLeafPtrs = leafPtrs; + run.paragraphLeafContainers = leafContainers; + + return { + newSlots, + insertedPtrs: allInsertedPtrs, + newBoundsX: isFinite(minX) ? minX : run.bounds.x, + newBoundsWidth: isFinite(maxRight) + ? maxRight - (isFinite(minX) ? minX : run.bounds.x) + : run.bounds.width, + }; +} + +// Build a synthetic TextRun "view" of a paragraph slot so the existing +// planPartialEdit / applyPartialEditPlan can operate on it. +function makeSlotView( + run: TextRun, + slot: ParagraphLineSlot, + text: string, +): TextRun { + return { + ...run, + text, + fontId: slot.fontId, + fontSize: slot.fontSize, + fontSubset: slot.fontSubset, + containerPtr: slot.containerPtr, + matrix: { ...run.matrix, e: slot.matrixE, f: slot.baselineY }, + bounds: { + x: slot.mergedFromBounds[0]?.x ?? slot.matrixE, + y: run.bounds.y, + width: + (slot.mergedFromBounds[slot.mergedFromBounds.length - 1]?.right ?? + slot.matrixE) - (slot.mergedFromBounds[0]?.x ?? slot.matrixE), + height: slot.fontSize * 1.2, + }, + mergedFromPtrs: slot.mergedFromPtrs, + mergedFromTexts: slot.mergedFromTexts, + mergedFromBounds: slot.mergedFromBounds, + mergedFromCharStarts: slot.mergedFromCharStarts, + } as TextRun; +} + +function cloneSlot(s: ParagraphLineSlot): ParagraphLineSlot { + return { + startChar: s.startChar, + endChar: s.endChar, + baselineY: s.baselineY, + matrixE: s.matrixE, + containerPtr: s.containerPtr, + fontId: s.fontId, + fontSize: s.fontSize, + fontSubset: s.fontSubset, + mergedFromPtrs: [...s.mergedFromPtrs], + mergedFromTexts: [...s.mergedFromTexts], + mergedFromBounds: s.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...s.mergedFromCharStarts], + }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.css b/frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.css new file mode 100644 index 0000000000..6cb74be1d9 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.css @@ -0,0 +1,21 @@ +/* Annotation-backed text: visible on the canvas, outside the editable model. */ +.pdf-editor-annotation-outline { + border: 1px dashed color-mix(in srgb, var(--c-text-subtle) 55%, transparent); + border-radius: 2px; + background: transparent; + cursor: help; + transition: + border-color 120ms ease, + background-color 120ms ease; +} + +.pdf-editor-annotation-outline:hover { + border-color: color-mix(in srgb, var(--c-primary) 90%, transparent); + background: color-mix(in srgb, var(--c-primary) 8%, transparent); +} + +@media (prefers-reduced-motion: reduce) { + .pdf-editor-annotation-outline { + transition: none; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.tsx new file mode 100644 index 0000000000..f5d95e6521 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from "react-i18next"; +import type { AnnotationBox } from "@app/tools/pdfTextEditor/model/AnnotationBox"; +import type { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import "@app/tools/pdfTextEditor/components/AnnotationOutline.css"; + +interface AnnotationOutlineProps { + annotation: AnnotationBox; + pageHeight: number; + transform: DisplayTransform; + scale: number; +} + +// FreeText/widget/stamp text is painted by FPDF_ANNOT but lives outside the +// page-object tree the editor walks, so it is visible and not editable. Outline +// it and say so rather than leaving the user to wonder why clicking does +// nothing. +export function AnnotationOutline({ + annotation, + pageHeight, + transform, + scale, +}: AnnotationOutlineProps) { + const { t } = useTranslation(); + const { rect, kind } = annotation; + + // Raw-PDF AABB -> display-PDF space -> CSS px. All FOUR corners go through + // the transform: on a /Rotate page two corners give the wrong box. + const corners = [ + transform.apply(rect.x, rect.y), + transform.apply(rect.x + rect.width, rect.y), + transform.apply(rect.x, rect.y + rect.height), + transform.apply(rect.x + rect.width, rect.y + rect.height), + ]; + const minX = Math.min(...corners.map((c) => c.x)); + const maxX = Math.max(...corners.map((c) => c.x)); + const minY = Math.min(...corners.map((c) => c.y)); + const maxY = Math.max(...corners.map((c) => c.y)); + const left = minX * scale; + const top = (pageHeight - maxY) * scale; + const width = (maxX - minX) * scale; + const height = (maxY - minY) * scale; + if (!(width > 1 && height > 1)) return null; + + const label = + kind === "widget" + ? t( + "pdfTextEditor.annotations.widget", + "Form field - not page text, so it can't be edited here", + ) + : kind === "freetext" + ? t( + "pdfTextEditor.annotations.freetext", + "Annotation text - not page text, so it can't be edited here", + ) + : t( + "pdfTextEditor.annotations.stamp", + "Stamp annotation - not page text, so it can't be edited here", + ); + + return ( +

+ ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileInputs.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileInputs.tsx new file mode 100644 index 0000000000..b85bc6d3c4 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileInputs.tsx @@ -0,0 +1,34 @@ +interface FileInputsProps { + onPickPdf: (file: File) => void; + onPickImage: (file: File) => void; +} + +/** Hidden file inputs used by the toolbar buttons, drag-and-drop, and tests. */ +export function EditorFileInputs({ onPickPdf, onPickImage }: FileInputsProps) { + return ( + <> + { + const file = e.target.files?.[0]; + if (file) onPickPdf(file); + e.target.value = ""; + }} + /> + { + const file = e.target.files?.[0]; + if (file) onPickImage(file); + e.target.value = ""; + }} + /> + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileSwitcher.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileSwitcher.tsx new file mode 100644 index 0000000000..b782700913 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileSwitcher.tsx @@ -0,0 +1,66 @@ +import { Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import DescriptionIcon from "@mui/icons-material/DescriptionOutlined"; +import { Button } from "@app/ui/Button"; +import { useAllFiles, useFileSelection } from "@app/contexts/FileContext"; +import type { FileId } from "@app/types/file"; + +interface Props { + /** Workbench file the editor currently holds, when it came from one. */ + currentFileId: FileId | null; + /** Open the picked file; the editor never follows the selection on its own. */ + onPick: (file: File) => void; +} + +/** + * Switch which workbench file the editor is editing. + * + * The editor owns the whole canvas, so the workbench's own Active Files grid is + * a view away; without this the user can open the tool with several files + * loaded and have no way to say which one to edit. Picking here sets the + * workbench selection rather than loading directly, so the rest of the app + * agrees about which file is being worked on. + */ +export function EditorFileSwitcher({ currentFileId, onPick }: Props) { + const { t } = useTranslation(); + const { files } = useAllFiles(); + const { setSelectedFiles } = useFileSelection(); + + const pdfs = files.filter((f) => /\.pdf$/i.test(f.name)); + if (pdfs.length < 2) return null; + + return ( + + + {t("pdfTextEditor.sidebar.document", "Document")} + + {pdfs.map((file) => { + const fileId = (file as File & { fileId?: FileId }).fileId; + const current = fileId != null && fileId === currentFileId; + return ( + + ); + })} + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/EditorSaveBar.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/EditorSaveBar.tsx new file mode 100644 index 0000000000..853da07efc --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/EditorSaveBar.tsx @@ -0,0 +1,113 @@ +import { Box, Group, Text, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import DownloadIcon from "@mui/icons-material/FileDownloadOutlined"; +import { EditorFileSwitcher } from "@app/tools/pdfTextEditor/components/EditorFileSwitcher"; +import type { FileId } from "@app/types/file"; + +interface Props { + openedFileName: string | null; + dirty: boolean; + /** Workbench file currently open, so the switcher can mark it. */ + currentFileId: FileId | null; + /** Open a different workbench file. */ + onPickFile: (file: File) => void; + onSave: () => void; + onDownload: () => void; +} + +/** + * Pinned footer: what file you are editing, and the one action that finishes. + * + * Save is the primary verb - it lands the edit back in the workbench like + * every other tool. Download is the same save plus a file, so it rides along + * as a subordinate icon rather than a second full-width button competing for + * the same attention. + */ +export function EditorSaveBar({ + openedFileName, + dirty, + currentFileId, + onPickFile, + onSave, + onDownload, +}: Props) { + const { t } = useTranslation(); + return ( + + {/* Choosing which file to edit is navigation, not a document fact, so it + stays reachable here rather than behind the Document tab. Renders + nothing until the workbench holds more than one PDF. */} + + {openedFileName && ( + // The name truncates but the unsaved marker must not, so it sits in + // its own non-shrinking element rather than inside the ellipsis. + + + {openedFileName} + + {dirty && ( + + {t("pdfTextEditor.unsaved", "(unsaved)")} + + )} + + )} + + + + + + + + + + + + {hasSelection ? ( + + ) : ( + + )} + + + + + + + ); +} + +/** What the Selected tab shows before the user has picked anything. */ +function NothingSelected() { + const { t } = useTranslation(); + return ( +
+ + + + {t("pdfTextEditor.inspector.nothingSelected", "Nothing selected")} + + + {t( + "pdfTextEditor.inspector.nothingSelectedHint", + "Click any text or image on the page to edit it here.", + )} + + +
+ ); +} + +/** + * One line about the selected runs' font - or nothing at all. + * + * It speaks only when a character the user types might not survive: a missing + * glyph, or an embedded face whose coverage we could not read. A font that can + * render everything says nothing, because "all fine" is not worth a line. + */ +function useSelectedFontNote( + state: EditorViewState, + selection: SelectionState, +): string | null { + const { t } = useTranslation(); + return useMemo(() => { + if (selection.runIds.length === 0) return null; + const picked = new Set(selection.runIds); + const fontIds = new Set(); + for (const page of state.pages) + for (const run of page.runs) + if (picked.has(run.id)) fontIds.add(run.fontId); + if (fontIds.size === 0) return null; + + const fonts = analyzePageFonts(state.pages).filter((f) => + // analyzePageFonts keys by display name + status, so match on the names + // the selected runs' fonts resolve to. + Array.from(fontIds).some((id) => id.endsWith(f.name)), + ); + if (fonts.length !== 1) return null; + const font = fonts[0]; + const gaps = font.coverage.known ? font.coverage.missing : []; + if (gaps.length > 0) { + return t( + "pdfTextEditor.inspector.fontGap", + "{{name}} · missing {{glyphs}} - typing those falls back to Helvetica.", + { name: font.name, glyphs: gaps.slice(0, 6).join(" ") }, + ); + } + // Silent when the font can render anything the user types: a standard + // base-14 face, or an embedded one whose cmap we read and found complete. + if (font.status === "standard") return null; + if (font.coverage.known) return null; + return t( + "pdfTextEditor.inspector.fontEmbedded", + "Embedded font · a character it lacks falls back to Helvetica.", + ); + }, [state.pages, selection.runIds, t]); +} + +function EmptySidebar({ + loading, + progress, +}: { + loading: boolean; + progress: LoadProgress | null; +}) { + const { t } = useTranslation(); + return ( + + + {t("pdfTextEditor.sidebar.noFile", "No file loaded")} + + + {t( + "pdfTextEditor.sidebar.noFileHint", + "Pick a PDF from the Files panel on the left, or drop one in. The editor will open it automatically.", + )} + + {loading && ( + + + {progress?.stage ?? + t("pdfTextEditor.sidebar.opening", "Opening document...")} + + {progress && progress.total > 0 && ( + + {progress.current} / {progress.total} + + )} + + )} + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/FindBar.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/FindBar.tsx new file mode 100644 index 0000000000..4971464710 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/FindBar.tsx @@ -0,0 +1,351 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Group, Stack, Text, TextInput, Tooltip } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "@mui/icons-material/Close"; +import { EditTextCommand } from "@app/tools/pdfTextEditor/commands/EditTextCommand"; +import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand"; +import { + findMatches, + replaceMatches, +} from "@app/tools/pdfTextEditor/util/textMatching"; +import type { + MatchOptions, + TextMatch, +} from "@app/tools/pdfTextEditor/util/textMatching"; +import { ensureAllPagesRead } from "@app/tools/pdfTextEditor/hooks/useDocumentLoader"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { + PageSnapshot, + TextRunSnapshot, +} from "@app/tools/pdfTextEditor/types"; + +interface FindBarProps { + store: EditorStore; + pages: PageSnapshot[]; + onClose: () => void; +} + +interface Match { + pageIndex: number; + runId: string; + /** Run snapshot (cached so navigation can scroll to it). */ + run: TextRunSnapshot; + /** Every occurrence inside this run, as offsets into `run.text`. */ + ranges: TextMatch[]; +} + +/** + * In-document find + replace. Searches every loaded TextRun snapshot + * for the query (case, whole-word and accent handling come from the + * toggles), tracks the current match, and scrolls / selects it. + * Replace and Replace All rewrite the matching runs via batched + * `EditTextCommand`s. + * + * Triggered from Ctrl+F in PdfTextEditor. Matches that haven't been + * lazy-loaded yet won't show until the user scrolls past those pages + * (the `ensurePageRead` hook will populate them on intersection). + */ +export function FindBar({ store, pages, onClose }: FindBarProps) { + const { t } = useTranslation(); + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [replace, setReplace] = useState(""); + const [matchCase, setMatchCase] = useState(false); + const [wholeWord, setWholeWord] = useState(false); + const [ignoreAccents, setIgnoreAccents] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const [replaceCount, setReplaceCount] = useState(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Opening Find is a document-wide request, so pull in every page that lazy + // loading has not read yet. Yield first: the read is synchronous, and on a + // long document it would otherwise block before the bar has painted. + useEffect(() => { + const id = setTimeout(() => ensureAllPagesRead(store), 0); + return () => clearTimeout(id); + }, [store]); + + const options: MatchOptions = useMemo( + () => ({ matchCase, wholeWord, ignoreAccents }), + [matchCase, wholeWord, ignoreAccents], + ); + + const matches: Match[] = useMemo(() => { + if (!query) return []; + const out: Match[] = []; + for (const page of pages) { + for (const run of page.runs) { + const ranges = findMatches(run.text, query, options); + if (ranges.length > 0) { + out.push({ pageIndex: page.pageIndex, runId: run.id, run, ranges }); + } + } + } + return out; + }, [query, pages, options]); + + const focusMatch = useCallback( + (idx: number) => { + const m = matches[idx]; + if (!m) return; + store.selection.selectOne(m.runId); + store.selection.highlight.set(m.runId); + const el = document.querySelector( + `[data-testid="pdf-editor-run-${m.runId}"]`, + ); + el?.scrollIntoView({ block: "center", behavior: "smooth" }); + }, + [matches, store], + ); + + // Clear the highlight when the find bar unmounts. + useEffect(() => () => store.selection.highlight.set(null), [store]); + + const next = useCallback(() => { + if (matches.length === 0) return; + const idx = (activeIndex + 1) % matches.length; + setActiveIndex(idx); + focusMatch(idx); + }, [activeIndex, matches.length, focusMatch]); + + const prev = useCallback(() => { + if (matches.length === 0) return; + const idx = (activeIndex - 1 + matches.length) % matches.length; + setActiveIndex(idx); + focusMatch(idx); + }, [activeIndex, matches.length, focusMatch]); + + // Scroll the very first match into view when the SEARCH changes (query or + // a toggle) - and only then. `matches` also recomputes on every document + // edit (page snapshots refresh), and resetting to match #1 + stealing + // selection/scroll on each keystroke elsewhere was hostile. + const searchKey = `${matchCase ? 1 : 0}${wholeWord ? 1 : 0}${ + ignoreAccents ? 1 : 0 + }\u0000${query}`; + const lastSearchRef = useRef("000\u0000"); + useEffect(() => { + if (lastSearchRef.current !== searchKey) { + lastSearchRef.current = searchKey; + setActiveIndex(0); + setReplaceCount(null); + if (matches.length > 0) focusMatch(0); + } else if (activeIndex >= matches.length && matches.length > 0) { + // Matches shrank under the current index (an edit removed some); + // clamp without stealing focus. + setActiveIndex(0); + } + }, [searchKey, matches, focusMatch, activeIndex]); + + /** + * Replace the CURRENT match with the replace text. Dispatches one + * EditTextCommand. Every occurrence inside that run is swapped in a + * single pass so a run like "Foo foo FOO" becomes "bar bar bar" - + * matches the user's mental model of "replace happens to the + * highlighted run" without surprising them with partial mutations. + * The replacement is spliced literally, so "$&" stays "$&". + */ + const doReplaceOne = useCallback(() => { + if (!query) return; + const m = matches[activeIndex]; + if (!m) return; + // A locked run is still findable, but must not be rewritten. + if (m.run.locked) return; + const updated = replaceMatches(m.run.text, m.ranges, replace); + if (updated === m.run.text) return; + store.dispatch( + new EditTextCommand({ + pageIndex: m.pageIndex, + runId: m.runId, + nextText: updated, + }), + ); + setReplaceCount(1); + }, [query, replace, matches, activeIndex, store]); + + /** + * Replace EVERY match. Each affected run gets one EditTextCommand, + * batched into a single CompositeCommand so "Undo undoes the whole + * Replace all". + */ + const doReplaceAll = useCallback(() => { + if (!query || matches.length === 0) return; + let n = 0; + const cmds: EditTextCommand[] = []; + for (const m of matches) { + // Skip locked runs: the lock is a user instruction, not a hint. + if (m.run.locked) continue; + const updated = replaceMatches(m.run.text, m.ranges, replace); + if (updated === m.run.text) continue; + cmds.push( + new EditTextCommand({ + pageIndex: m.pageIndex, + runId: m.runId, + nextText: updated, + }), + ); + n += 1; + } + if (cmds.length === 1) store.dispatch(cmds[0]); + else if (cmds.length > 1) store.dispatch(new CompositeCommand(cmds)); + setReplaceCount(n); + }, [query, replace, matches, store]); + + return ( + + + + {t("pdfTextEditor.find.title", "Find & replace")} + + + + + + + + + + + + + {matches.length === 0 + ? query + ? t("pdfTextEditor.find.noMatches", "No matches") + : t("pdfTextEditor.find.typeToSearch", "Type to search") + : t("pdfTextEditor.find.count", "{{current}} of {{total}}", { + current: activeIndex + 1, + total: matches.length, + })} + {replaceCount !== null + ? t("pdfTextEditor.find.replaced", " · {{count}} replaced", { + count: replaceCount, + }) + : ""} + + + + + + + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/FontFamilySelect.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/FontFamilySelect.tsx new file mode 100644 index 0000000000..a29e1738a6 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/FontFamilySelect.tsx @@ -0,0 +1,199 @@ +import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import { Group, Select, Text, Tooltip } from "@mantine/core"; +import type { ComboboxData, ComboboxItemGroup } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import FontDownloadIcon from "@mui/icons-material/FontDownloadOutlined"; +import { + groupByFamily, + isLocalFontAccessSupported, + listLocalFonts, + loadedLocalFonts, + subscribeLocalFonts, +} from "@app/tools/pdfTextEditor/util/localFonts"; + +export interface FontFamilyOption { + value: string; + label: string; +} + +/** Base-14 families, renderable by every viewer without embedding. */ +export const BUILT_IN_FONT_FAMILIES: FontFamilyOption[] = [ + { value: "Helvetica", label: "Helvetica" }, + { value: "Helvetica-Bold", label: "Helvetica Bold" }, + { value: "Times-Roman", label: "Times Roman" }, + { value: "Times-Bold", label: "Times Bold" }, + { value: "Times-Italic", label: "Times Italic" }, + { value: "Courier", label: "Courier" }, + { value: "Courier-Bold", label: "Courier Bold" }, +]; + +type DeviceFontNotice = "unavailable" | "none"; + +interface FontFamilySelectProps { + value: string | null; + onChange: (family: string) => void; + mixed?: boolean; + disabled?: boolean; +} + +/** Font picker. Device fonts are additive: no prompt until the user asks. */ +export function FontFamilySelect({ + value, + onChange, + mixed = false, + disabled = false, +}: FontFamilySelectProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [notice, setNotice] = useState(null); + const supported = useMemo(() => isLocalFontAccessSupported(), []); + // Read the fonts from the module, not local state: switching files remounts + // the toolbar, and the grant the user already gave must survive that. + const localFonts = useSyncExternalStore( + subscribeLocalFonts, + loadedLocalFonts, + loadedLocalFonts, + ); + + const deviceFamilies = useMemo(() => { + if (!localFonts) return []; + const builtIn = new Set( + BUILT_IN_FONT_FAMILIES.map((option) => option.value.toLowerCase()), + ); + return groupByFamily(localFonts) + .map((family) => family.family) + .filter((family) => !builtIn.has(family.toLowerCase())); + }, [localFonts]); + + const loadDeviceFonts = useCallback(async () => { + setLoading(true); + setNotice(null); + try { + const fonts = await listLocalFonts(); + // deviceFamilies recomputes off the store, so only the empty outcomes + // need reporting here. + if (!fonts) setNotice("unavailable"); + else if (fonts.length === 0) setNotice("none"); + } finally { + setLoading(false); + } + }, []); + + const isKnown = useCallback( + (family: string) => + BUILT_IN_FONT_FAMILIES.some((option) => option.value === family) || + deviceFamilies.includes(family), + [deviceFamilies], + ); + + // The run's own face when we hold no bytes for it. Shown so the user can see + // what the text IS, listed disabled so picking it can't substitute Helvetica. + const documentFamily = useMemo( + () => (!mixed && value && !isKnown(value) ? value : null), + [mixed, value, isKnown], + ); + + const data = useMemo(() => { + if (deviceFamilies.length === 0 && !documentFamily) { + return BUILT_IN_FONT_FAMILIES; + } + const groups: ComboboxItemGroup[] = []; + if (documentFamily) { + groups.push({ + group: t("pdfTextEditor.fontPicker.documentGroup", "Document font"), + items: [ + { value: documentFamily, label: documentFamily, disabled: true }, + ], + }); + } + groups.push({ + group: t("pdfTextEditor.fontPicker.builtInGroup", "Built-in fonts"), + items: BUILT_IN_FONT_FAMILIES, + }); + if (deviceFamilies.length > 0) { + groups.push({ + group: t("pdfTextEditor.fontPicker.deviceGroup", "Device fonts"), + items: deviceFamilies.map((family) => ({ + value: family, + label: family, + })), + }); + } + return groups; + }, [deviceFamilies, documentFamily, t]); + + // Mantine shows nothing for a value with no matching option; the document + // font is in `data` precisely so a recognised face still gets named. + const selected = useMemo(() => { + if (mixed || !value) return null; + return isKnown(value) || documentFamily === value ? value : null; + }, [mixed, value, isKnown, documentFamily]); + + return ( + + setSpellcheckLang(value ?? SPELLCHECK_AUTO)} + disabled={!pref.enabled} + aria-label={t( + "pdfTextEditor.spellcheck.language", + "Dictionary language", + )} + data-testid="pdf-editor-spellcheck-language" + /> + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.css b/frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.css new file mode 100644 index 0000000000..8bccca1bc2 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.css @@ -0,0 +1,24 @@ +.pdf-editor-run { + position: absolute; + padding: 2px; + margin: 0; + cursor: text; + pointer-events: auto; + user-select: text; + translate: -2px -2px; +} + +.pdf-editor-run.is-pristine, +.pdf-editor-run.is-pristine * { + color: transparent !important; + -webkit-text-fill-color: transparent !important; + -webkit-text-stroke-color: transparent !important; + text-decoration-color: transparent !important; +} + +.pdf-editor-run.is-pristine::selection, +.pdf-editor-run.is-pristine *::selection { + background: color-mix(in srgb, var(--c-primary) 28%, transparent); + color: transparent; + -webkit-text-fill-color: transparent; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.tsx new file mode 100644 index 0000000000..ee16ddf884 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.tsx @@ -0,0 +1,1073 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { + TextRunSnapshot, + WidthMode, +} from "@app/tools/pdfTextEditor/types"; +import { toCssHex } from "@app/tools/pdfTextEditor/model/Color"; +import type { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import { + resolveLang, + useSpellcheckPreference, +} from "@app/tools/pdfTextEditor/util/spellcheck"; +import { + embeddedFaceFamily, + onEmbeddedFaceLoaded, +} from "@app/tools/pdfTextEditor/util/embeddedFace"; +import { nearestStandardFont } from "@app/tools/pdfTextEditor/util/fontFamily"; +import { fitTextToWidth, NO_FIT } from "@app/tools/pdfTextEditor/util/fitText"; +import { + sampleRunBackground, + toOpaqueCss, +} from "@app/tools/pdfTextEditor/util/canvasBackground"; +import { buildExactLines } from "@app/tools/pdfTextEditor/util/exactLayout"; +import { stackLineBoxes } from "@app/tools/pdfTextEditor/util/lineLayout"; +import { + isLinePainted, + normalizeContainerCaret, + type PaintLine, + paintLines, + paintPlainText, + plainCaretOffset, + readOverlayText, + refitEditedTokens, + refitTokens, + restoreCaretOffset, +} from "@app/tools/pdfTextEditor/util/overlayPainter"; +import { + cssFontShorthand, + measureFontMetrics, + measureLongestTokenWidth, + measureMaxLineWidth, + resetTextMetricsCache, +} from "@app/tools/pdfTextEditor/util/textMetrics"; +import "@app/tools/pdfTextEditor/components/TextRunOverlay.css"; + +const RENDER_MODE_INVISIBLE = 3; + +const SETTLE_MS = 400; + +const STALL_MS = 250; + +// Idle time before a wrap-mode run re-wraps. This has to be longer than the gap +// between keystrokes: a reflow physically moves the glyph objects, so one that +// lands mid-burst drags the text - and the caret - out from under the user. +// Measured at 180ms it fired 10 times across 70 typed characters and produced +// 13 backward caret jumps. It only needs to beat the user clicking away. +const LIVE_WRAP_MS = 700; + +// Un-measured keystrokes a run absorbs before the overlay takes over the +// glyphs. One or two are re-rendered fast enough to leave the page's own ink +// alone; a burst is not. +const GUESSED_EDITS_BEFORE_MASK = 2; + +// Map a font id like "base14:Helvetica-Bold" or "pdf:1234:Arial" to a CSS +// font-family stack that visually approximates the PDFium-rendered glyphs. +function cssFontFamilyFor(fontId: string): string { + const idx = fontId.lastIndexOf(":"); + const family = idx >= 0 ? fontId.slice(idx + 1) : fontId; + // The document's own face, when PDFium gave us bytes a FontFace accepts. + // An unresolved name costs nothing: the browser moves on to the next entry. + const own = ownFaceFor(fontId); + // An edit that outgrew a subset now re-emits in the user's INSTALLED face + // (`device:Calibri`), so the page really is Calibri. Naming it first keeps + // the overlay measuring and drawing what the page renders; without it + // nearestStandardFont collapses it to Helvetica and every advance the + // overlay predicts is a different font's. + if (fontId.startsWith("device:")) { + return `"${family}", ${own}"Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif`; + } + const standard = nearestStandardFont(family); + if (standard.startsWith("Times")) { + return `${own}"Liberation Serif", "Times New Roman", Times, serif`; + } + if (standard.startsWith("Courier")) { + return `${own}"Liberation Mono", "Courier New", Courier, monospace`; + } + return `${own}"Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif`; +} + +/** `"pdfface-N", ` for a `pdf::` id, else the empty string. */ +function ownFaceFor(fontId: string): string { + const m = /^pdf:(\d+):/.exec(fontId); + return m ? `"${embeddedFaceFamily(Number(m[1]))}", ` : ""; +} + +function cssWeightFor(fontId: string): number { + return /bold/i.test(fontId) ? 700 : 400; +} + +function cssStyleFor(fontId: string): "italic" | "normal" { + return /italic|oblique/i.test(fontId) ? "italic" : "normal"; +} + +// Read the page bitmap under a run and return an opaque CSS colour for the +// editing mask. Null when the canvas is unreadable, so callers keep a default. +function readMaskColor(el: HTMLDivElement): string | null { + const page = el.closest("[data-testid^='pdf-editor-page-']"); + const canvas = page?.querySelector("canvas") as HTMLCanvasElement | null; + if (!canvas) return null; + const cb = canvas.getBoundingClientRect(); + if (cb.width < 1 || cb.height < 1) return null; + const rb = el.getBoundingClientRect(); + // CSS px -> canvas px: the bitmap is rendered at its own device scale. + const sx = canvas.width / cb.width; + const sy = canvas.height / cb.height; + const rgb = sampleRunBackground(canvas, { + x: (rb.left - cb.left) * sx, + y: (rb.top - cb.top) * sy, + width: rb.width * sx, + height: rb.height * sy, + }); + return rgb ? toOpaqueCss(rgb) : null; +} + +/** Pick an editing-mask color that always contrasts with the text fill. */ +function contrastingMaskFor(fill: { + r: number; + g: number; + b: number; + a: number; +}): string { + // ITU-R BT.601 luma; 0 = black, 255 = white. + const luma = (fill.r * 299 + fill.g * 587 + fill.b * 114) / 1000; + return luma > 160 ? "rgba(30, 30, 30, 0.85)" : "rgba(255, 255, 255, 0.9)"; +} + +// Put the caret at the end of the LAST painted line block rather than at the +// container's end. A container-level caret makes Firefox insert typed text as +// a bare sibling of the line div, which then reads back as an extra line. +function caretToEnd(el: HTMLElement, sel: Selection): void { + let node: Node = el; + while (node.lastChild) node = node.lastChild; + const range = document.createRange(); + if (node.nodeType === Node.TEXT_NODE) { + range.setStart(node, (node.textContent ?? "").length); + range.collapse(true); + } else if (node !== el && node.parentNode) { + // Trailing filler
: sit just before it, still inside its block. + range.setStartBefore(node); + range.collapse(true); + } else { + range.selectNodeContents(el); + range.collapse(false); + } + sel.removeAllRanges(); + sel.addRange(range); +} + +interface ExactLayout { + lines: PaintLine[]; + leftPx: number; + topPx: number; + widthPx: number; + heightPx: number; + signature: string; +} + +function computeExactLayout(args: { + run: TextRunSnapshot; + transform: DisplayTransform; + pageHeight: number; + scale: number; + font: string; + fontSizePx: number; + lineHeightPx: number; + ascent: number; + descent: number; +}): ExactLayout | null { + const { run, transform, pageHeight, scale } = args; + if (!run.charStartsX || !run.charEndsX) return null; + const exact = buildExactLines(run.text, { + starts: run.charStartsX, + ends: run.charEndsX, + }); + if (!exact || exact.length === 0) return null; + + // Slot lefts are indexed by line, so an edit that added or removed a line + // makes every entry below it describe a different line - the same length + // guard the baselines already get. + const slotLefts = + run.paragraphLineLefts?.length === exact.length + ? run.paragraphLineLefts + : undefined; + const lineLefts = exact.map((line, i) => { + const fromSlot = slotLefts?.[i]; + if (fromSlot !== undefined && Number.isFinite(fromSlot)) return fromSlot; + if (Number.isFinite(line.left)) return line.left; + return i === 0 ? run.matrix.e : run.bounds.x; + }); + + const baselines = baselinesFor(run, exact.length); + if (!baselines) return null; + + const anchors = baselines.map((y, i) => transform.apply(lineLefts[i], y)); + const leftsPx = anchors.map((a) => a.x * scale); + const baselineTopsPx = anchors.map((a) => (pageHeight - a.y) * scale); + + const halfLeading = Math.max( + 0, + (args.lineHeightPx - (args.ascent + args.descent)) / 2, + ); + const stack = stackLineBoxes( + baselineTopsPx, + args.lineHeightPx, + halfLeading + args.ascent, + ); + if (!stack) return null; + + const leftPx = Math.min(...leftsPx); + if (!Number.isFinite(leftPx) || !Number.isFinite(stack.topPx)) return null; + + const lines: PaintLine[] = exact.map((line, i) => ({ + tokens: line.tokens.map((t) => ({ + text: t.text, + advancePx: t.width * scale, + })), + heightPx: args.lineHeightPx, + marginTopPx: stack.marginTopsPx[i], + marginLeftPx: leftsPx[i] - leftPx, + })); + + const widthPx = Math.max( + run.bounds.width * scale, + ...lines.map( + (l) => l.marginLeftPx + l.tokens.reduce((sum, t) => sum + t.advancePx, 0), + ), + ); + const heightPx = + lines.reduce((sum, l) => sum + l.marginTopPx + l.heightPx, 0) + + args.descent; + if (!Number.isFinite(widthPx) || !Number.isFinite(heightPx)) return null; + const signature = [ + args.font, + leftPx.toFixed(2), + stack.topPx.toFixed(2), + ...lines.map((l) => + [ + l.marginTopPx.toFixed(2), + l.marginLeftPx.toFixed(2), + l.tokens.length, + l.tokens.reduce((sum, t) => sum + t.advancePx, 0).toFixed(2), + ].join(","), + ), + ].join("|"); + return { lines, leftPx, topPx: stack.topPx, widthPx, heightPx, signature }; +} + +// PDF advance per em for every character the run already carries. Scale-free, +// so it stays valid as the user zooms. +function charAdvancesEm(run: TextRunSnapshot): Map | null { + const starts = run.charStartsX; + const ends = run.charEndsX; + if (!starts || !ends || starts.length !== run.text.length) return null; + if (!(run.fontSize > 0)) return null; + const map = new Map(); + for (let i = 0; i < run.text.length; i += 1) { + const width = ends[i] - starts[i]; + if (!Number.isFinite(width) || width <= 0) continue; + const ch = run.text[i]; + if (!map.has(ch)) map.set(ch, width / run.fontSize); + } + return map.size > 0 ? map : null; +} + +function baselinesFor( + run: TextRunSnapshot, + lineCount: number, +): number[] | null { + const stored = run.paragraphBaselines; + if (stored && stored.length === lineCount && stored.every(Number.isFinite)) { + return stored; + } + if (lineCount === 1) return [run.matrix.f]; + const step = + run.paragraphLineHeight && run.paragraphLineHeight > 0 + ? run.paragraphLineHeight + : run.fontSize * 1.2; + const out: number[] = []; + for (let i = 0; i < lineCount; i += 1) out.push(run.matrix.f - i * step); + return out; +} + +interface TextRunOverlayProps { + run: TextRunSnapshot; + pageHeight: number; + /** Page width in PDF points - caps the box so it never runs off-page. */ + pageWidth: number; + /** Raw-PDF -> display (CropBox/rotation) transform. */ + transform: DisplayTransform; + scale: number; + /** "grow": box widens to the right. "wrap": locked width, wraps down. */ + widthMode: WidthMode; + selected: boolean; + /** True when this run is the active find-match (yellow highlight). */ + highlighted?: boolean; + pageRevision?: number; + onSelect: (shiftKey: boolean) => void; + onEdit: (nextText: string) => void; + /** Fires when the user Ctrl+drags the run to a new position. dx/dy are PDF points. */ + onMove?: (dx: number, dy: number) => void; + // Fires on blur in Wrap mode when the edited content overflows the locked box + // width. + onWrap?: (maxWidthPt: number) => void; +} + +/** + * Which gesture the pointer is over: the frame, or the text interior. + * + * There is deliberately no resize zone. Re-wrapping to an arbitrary width goes + * through ReflowWrapCommand, whose word grouping is x-gap based - on a run + * whose glyphs are individually positioned (letter-spaced headings, button + * labels) every glyph becomes its own "word" and the line breaker splits + * inside words, shredding "Open Source" into one character per line. Until + * that grouping is token-aware, a drag handle would make the corruption a + * one-gesture accident. + */ +type EdgeZone = "move" | null; + +/** Grab band around the box, in CSS px. Matches the visible ring's reach. */ +const EDGE_PX = 7; + +/** + * Classify a pointer position against the run's own box. + * + * The box is contentEditable, so handles cannot be child elements without + * becoming editable content. Hit-testing the border band instead gives the + * same affordance with no DOM inside the editable region. + */ +function edgeZoneAt( + el: HTMLElement, + clientX: number, + clientY: number, +): EdgeZone { + const r = el.getBoundingClientRect(); + const nearLeft = clientX - r.left <= EDGE_PX; + const nearRight = r.right - clientX <= EDGE_PX; + const nearTop = clientY - r.top <= EDGE_PX; + const nearBottom = r.bottom - clientY <= EDGE_PX; + if (nearTop || nearBottom || nearLeft || nearRight) return "move"; + return null; +} + +/** One editable HTML element per PDF text run. */ +export function TextRunOverlay({ + run, + pageHeight, + pageWidth, + transform, + scale, + widthMode, + selected, + highlighted, + pageRevision, + onSelect, + onEdit, + onMove, + onWrap, +}: TextRunOverlayProps) { + const { t } = useTranslation(); + // Subscribed, so toggling the preference re-renders every overlay. + const spellcheck = useSpellcheckPreference(); + const ref = useRef(null); + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + // Masking a run the user has only clicked into swaps real PDF ink for a + // CSS approximation, so hold the pristine bitmap until an actual edit. + const [touched, setTouched] = useState(false); + const [editTick, setEditTick] = useState(0); + const [stalled, setStalled] = useState(false); + const editedAtRevisionRef = useRef(-1); + // Keystrokes taken since the engine last measured this run, and when the + // overlay's glyphs first came due because of them. + const guessedEditsRef = useRef(0); + const maskDueSinceRef = useRef(0); + const paintedSignatureRef = useRef(null); + const pointerFocusRef = useRef(false); + // The mask has to be the page's own colour, not a guess from the text: a + // run on a coloured page got a grey band. Sampled from the rendered bitmap + // once per focus, so the read never lands in the typing path. + const [maskColor, setMaskColor] = useState(null); + const [faceEpoch, setFaceEpoch] = useState(0); + // True between compositionstart and compositionend (IME). While composing + // onInput must not dispatch per-keystroke edits; we commit once on end. + const composingRef = useRef(false); + // Text content captured when the box gains focus, so blur can tell whether + // the user actually edited it (and a Wrap reflow is warranted). + const focusTextRef = useRef(""); + // Drag-to-move state. `dragOffset` is the live cursor delta applied as a + // CSS transform so the box follows the cursor during the drag. + const dragOriginRef = useRef<{ x: number; y: number } | null>(null); + const [dragging, setDragging] = useState(false); + const [dragOffset, setDragOffset] = useState<{ x: number; y: number } | null>( + null, + ); + // Which edge the pointer is over, so the cursor can advertise the gesture + // before the user commits to it. Null means the text interior. + const [edgeZone, setEdgeZone] = useState(null); + const originalBoundsWidthRef = useRef(run.bounds.width); + // Whether this run was a real (multi-line) paragraph when it first mounted. + + const fontFamily = cssFontFamilyFor(run.fontId); + const fontWeight = cssWeightFor(run.fontId); + const fontStyle = cssStyleFor(run.fontId); + const fontSizePx = Math.max(4, run.fontSize * scale); + const font = cssFontShorthand(fontStyle, fontWeight, fontSizePx, fontFamily); + const { ascent, descent } = useMemo( + () => measureFontMetrics(font, fontSizePx), + [font, fontSizePx, faceEpoch], + ); + + const lineHeightPx = + run.paragraphLineHeight && run.paragraphLineHeight > 0 + ? run.paragraphLineHeight * scale + : fontSizePx * 1.2; + + const freshExact = useMemo( + () => + computeExactLayout({ + run, + transform, + pageHeight, + scale, + font, + fontSizePx, + lineHeightPx, + ascent, + descent, + }), + [ + run, + transform, + pageHeight, + scale, + font, + fontSizePx, + lineHeightPx, + ascent, + descent, + ], + ); + + // How the run's own text axis is rotated on the page, if it is. cos/sin come + // straight from the text matrix; screen y runs the other way from PDF y, so + // the CSS angle is the negation. + const runRotation = useMemo(() => { + const norm = Math.hypot(run.matrix.a, run.matrix.b); + // The run's own slant, if any. Screen y runs opposite to PDF y, so the CSS + // angle is the negation of the matrix angle. + const own = norm + ? -Math.atan2(run.matrix.b / norm, run.matrix.a / norm) * (180 / Math.PI) + : 0; + // Plus the page's own quarter-turns. `transform.apply` already puts the + // anchor in the right place on a /Rotate page, but the box was still drawn + // along the PAGE's x-axis while the glyphs ran down it, so a box on a + // /Rotate 90 page stuck up to 247px off the right-hand edge. + const pageDeg = ((((transform.rotate ?? 0) % 4) + 4) % 4) * 90; + const deg = own + pageDeg; + if (Math.abs(deg) < 0.01) return null; + return { deg }; + }, [run.matrix.a, run.matrix.b, transform.rotate]); + + const heldExactRef = useRef(null); + if (freshExact) heldExactRef.current = freshExact; + // An exact layout is built from per-character x positions along the PAGE's + // x-axis, which stop describing a run whose own axis is rotated - the box + // came out axis-aligned over slanted glyphs and covered 38% of its own ink. + // Rotated runs use the flow geometry plus a matching CSS rotation instead. + const exact = runRotation + ? null + : (freshExact ?? (focused ? heldExactRef.current : null)); + if (!freshExact && !focused) heldExactRef.current = null; + + const advanceEm = useMemo(() => charAdvancesEm(run), [run]); + // Kept across the edit: the engine drops the pen positions the moment the + // text changes, and a token typed into needs them most right then. + const heldAdvanceEmRef = useRef | null>(null); + if (advanceEm) heldAdvanceEmRef.current = advanceEm; + + useEffect(() => { + const bump = () => { + resetTextMetricsCache(); + setFaceEpoch((n) => n + 1); + }; + const unsubscribe = onEmbeddedFaceLoaded(bump); + let cancelled = false; + if (typeof document !== "undefined" && document.fonts) { + void document.fonts.ready.then(() => { + if (!cancelled) bump(); + }); + } + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const onBeforeInput = (event: Event) => { + const inputType = (event as InputEvent).inputType ?? ""; + if (inputType.startsWith("format")) event.preventDefault(); + // The browser keeps its OWN undo stack for a contenteditable, reachable + // from the Edit menu and trackpad gestures. Letting it fire would rewrite + // the overlay behind the editor's command history, so the two disagree + // about the document. Undo/redo has to come through the command stack. + if (inputType === "historyUndo" || inputType === "historyRedo") { + event.preventDefault(); + } + }; + el.addEventListener("beforeinput", onBeforeInput); + return () => el.removeEventListener("beforeinput", onBeforeInput); + }, []); + + useEffect(() => { + if (!touched) return; + if (pageRevision === undefined) return; + if (pageRevision <= editedAtRevisionRef.current) return; + const timer = window.setTimeout(() => setTouched(false), SETTLE_MS); + return () => window.clearTimeout(timer); + }, [pageRevision, touched, editTick]); + + // No exact layout for the text now in the box: the engine only re-measures + // pen positions once typing pauses, so until it does the overlay is placing + // glyphs on the browser's advances rather than the PDF's. + const layoutIsGuessed = touched && !freshExact; + + const paintOpts = { + font, + fontSizePx, + advanceEm: heldAdvanceEmRef.current, + }; + + useEffect(() => { + if (!layoutIsGuessed) { + guessedEditsRef.current = 0; + maskDueSinceRef.current = 0; + setStalled(false); + return; + } + guessedEditsRef.current += 1; + // The mask replaces the page's own ink with a CSS approximation of it, so + // arming it mid-word visibly changes the typeface of text the user is + // typing into - and changes it back when the engine catches up. That is a + // worse artefact than the caret leading the page render, which is all it + // buys: the raster is simply slower than a fast burst, and it self-corrects + // the moment typing pauses. So it stays reserved for a run that has fallen + // BEHIND ITS OWN PAGE RENDER - not for one whose page is merely mid-flight. + if ( + pageRevision !== undefined && + pageRevision > editedAtRevisionRef.current + ) { + setStalled(false); + return; + } + if (guessedEditsRef.current < GUESSED_EDITS_BEFORE_MASK) return; + // The deadline is anchored where the mask first came due, so typing on does + // not keep pushing it out of reach. + const now = Date.now(); + if (maskDueSinceRef.current === 0) maskDueSinceRef.current = now; + const wait = Math.max(0, STALL_MS - (now - maskDueSinceRef.current)); + const timer = window.setTimeout(() => setStalled(true), wait); + return () => window.clearTimeout(timer); + }, [layoutIsGuessed, pageRevision, editTick]); + + useEffect(() => { + const el = ref.current; + if (!el || composingRef.current) return; + if (!isLinePainted(el)) return; + refitTokens(el, paintOpts); + }, [font, fontSizePx]); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const active = document.activeElement === el; + // Focus may sit on a descendant mid-edit; either way the run owns the caret + // and is entitled to re-seat it. A blurred run is not. + const ownsFocus = el.contains(document.activeElement); + if (active && composingRef.current) return; + const domText = readOverlayText(el); + // Mid-edit, the only layout allowed to repaint is one the engine has + // already measured for exactly this text. It re-seats the typed glyphs on + // the PDF's own advances - without it the overlay keeps laying them out at + // the browser's, and the caret walks off the text on the page a fraction of + // a pixel per keystroke. Any other layout would be fighting a keystroke + // still in flight. + if (active && touched && !(freshExact && domText === run.text)) return; + const wantSignature = freshExact ? freshExact.signature : ""; + if (!freshExact && isLinePainted(el) && domText === run.text) return; + if ( + domText === run.text && + paintedSignatureRef.current === wantSignature && + isLinePainted(el) === !!freshExact + ) { + return; + } + // Keyed off the selection, not the focus: replaceChildren below detaches + // whatever node the caret sits in, and a caret this run holds without being + // document.activeElement is still a caret the next insert needs. + const caret = plainCaretOffset(el); + if (freshExact) { + paintLines(el, freshExact.lines, paintOpts); + } else { + paintPlainText(el, run.text); + } + paintedSignatureRef.current = wantSignature; + // Only while the run still holds focus. A selection outlives the blur that + // ended the edit, so re-seating it into a blurred run takes focus BACK - + // and the user's next click elsewhere then fires this run's blur handler, + // dispatching a spurious wrap that also wipes the redo stack. + if (caret !== null && ownsFocus) restoreCaretOffset(el, caret); + }, [run.text, freshExact, font, fontSizePx, touched, faceEpoch]); + + const anchor = transform.apply(run.matrix.e, run.matrix.f); + const flowLeft = anchor.x * scale; + + const invisible = run.renderMode === RENDER_MODE_INVISIBLE; + const showsGlyphs = (dragging || stalled) && !invisible; + + const singleLine = (run.paragraphLineCount ?? 1) <= 1; + const fit = + !exact && showsGlyphs && singleLine + ? fitTextToWidth( + run.text, + measureMaxLineWidth(run.text, font), + run.bounds.width * scale, + fontSizePx, + ) + : NO_FIT; + + // VERTICAL PLACEMENT - anchor the first line's CSS alphabetic baseline + // exactly onto the PDF baseline (`run.matrix.f`). + const halfLeading = Math.max(0, (lineHeightPx - (ascent + descent)) / 2); + const firstBaselineFromTop = halfLeading + ascent; + const baselineScreen = (pageHeight - anchor.y) * scale; + const flowTop = baselineScreen - firstBaselineFromTop; + + // Height covers every line plus descender slack. + const lineCount = Math.max(1, run.text.split(/\r?\n/).length); + const flowHeight = lineCount * lineHeightPx + descent; + + const pdfWidth = run.bounds.width * scale; + // Widen the overlay so every source line still fits in CSS metrics, and so + // typed text wider than the original bounds isn't clipped. + const measuredWidth = measureMaxLineWidth(run.text, font); + // Width behaviour is user-controlled: - "grow": box widens to the right to + // fit the content. + const wrapMode = widthMode === "wrap"; + const wrapLockWidth = Math.max( + originalBoundsWidthRef.current * scale, + fontSizePx * 4, + ); + // The mode the user picked, and nothing else. Forcing a paragraph to wrap in + // Grow made the two modes indistinguishable for body text and contradicted + // the control's own hint ("Boxes widen to the right as you type (no + // wrapping)"). + const wantWrap = wrapMode; + const left = exact ? exact.leftPx : flowLeft; + // Wrap keeps the box on the page - that is the whole point of the mode, and + // its overflow goes onto new lines instead. Grow has nowhere to put the + // overflow, so capping it there just hides what the user is typing: it grew + // to the page edge and then clipped everything beyond, measured at 2944px of + // invisible text on a single-line run. + const pageCap = Math.max(fontSizePx * 4, pageWidth * scale - left - 4); + // Wrapping cannot break inside a word, so a box narrower than the longest one + // hides its tail however the lines are broken - 707px of a held-down key + // measured invisible, with the caret out there past the box edge. + // + // The longest token overrides even the page edge. Stopping there is right for + // text that can wrap, because the overflow has somewhere else to go; a word + // with no break in it has nowhere, so the cap stops protecting the page + // margin and just hides what the user is typing. + const longestTokenWidth = measureLongestTokenWidth(run.text, font); + const wrapWidth = Math.max( + Math.min(wrapLockWidth, pageCap), + longestTokenWidth + fontSizePx, + ); + const maxOnPageWidth = wantWrap ? pageCap : Number.POSITIVE_INFINITY; + const naturalWidth = wantWrap + ? wrapLockWidth + : Math.max(pdfWidth, measuredWidth + fontSizePx); + const flowWidth = Math.min(naturalWidth, maxOnPageWidth); + + const top = exact ? exact.topPx : flowTop; + // Width must not depend on anything that can flip between renders, or the box + // visibly pumps between two sizes while the user types. Two things could: + // room for the caret appeared only WHILE focused, and the measured fallback + // dropped out the moment `freshExact` arrived. The engine now re-measures + // every 100ms, so both flipped about ten times a second. Always keep the + // slack, always take the wider of the two - the result is a pure function of + // the layout, the text and the font, and a few pixels of margin costs + // nothing next to a box that will not sit still. + const exactWidth = exact + ? Math.max(exact.widthPx + fontSizePx * 0.5, measuredWidth + fontSizePx) + : 0; + // Capped at the page edge: an editing box hanging off the page reads as + // broken, and the glyphs under it would be off-page anyway. + // + // A line longer than that is therefore clipped while it is being typed, and + // the reflow on blur brings it back onto the page. The alternative - letting + // the box wrap the line - is what put the overlay a full line out of register + // with the bitmap: the PDF draws each line as ONE text object at one pen + // origin and cannot wrap, so an overlay that wraps stops describing the page + // underneath it. + // Wrap holds its width and pushes overflow onto new lines; widening to the + // page edge instead is Grow's job, and doing both makes the modes identical. + const width = wantWrap ? wrapWidth : exact ? exactWidth : flowWidth; + const height = exact ? exact.heightPx : flowHeight; + // An exact layout is never wrapped - its lines are the PDF's own. Only the + // plain-text fallback, where CSS flow genuinely owns the layout, may wrap. + const whiteSpace: "pre" | "pre-wrap" = + !exact && wantWrap ? "pre-wrap" : "pre"; + + // Wrap AS THE USER TYPES, not only on blur. Deferring it meant the overflow + // sat invisible past the box edge until they clicked away - over a thousand + // pixels of it - and the caret only dropped onto the new line at that point. + // The reflow shares EditTextCommand's coalesce key and ignores the time + // window, so running it mid-burst does not fragment undo. + const wrapTarget = wrapWidth; + useEffect(() => { + if (!wantWrap || !onWrap || !focused) return; + const el = ref.current; + if (!el || composingRef.current) return; + const widest = measureMaxLineWidth(readOverlayText(el), font); + if (widest <= wrapTarget + 1) return; + const timer = window.setTimeout( + () => onWrap(wrapTarget / scale), + LIVE_WRAP_MS, + ); + return () => window.clearTimeout(timer); + }, [wantWrap, onWrap, focused, editTick, wrapTarget, font, scale]); + + // Which dictionary the browser should load. "auto" falls back to the + // page's own language, which is what the element would inherit anyway. + const spellcheckLang = resolveLang( + spellcheck, + typeof document === "undefined" ? null : document.documentElement.lang, + ); + + const pristine = !showsGlyphs; + + return ( +
{ + // A caret parked on the container (a click past the text lands there) + // makes Firefox insert the keystroke as a sibling of the line blocks, + // which reads back as a line the user never typed. Seat it in the + // block it sits beside before the input applies. + const sel = window.getSelection(); + if (sel) + normalizeContainerCaret(e.currentTarget as HTMLDivElement, sel); + }} + onPaste={(e) => { + // Paste as PLAIN TEXT. + e.preventDefault(); + const sel = window.getSelection(); + if (sel) + normalizeContainerCaret(e.currentTarget as HTMLDivElement, sel); + const text = e.clipboardData?.getData("text/plain"); + if (text) document.execCommand("insertText", false, text); + }} + onPointerDown={(e) => { + // Ctrl+Shift+drag is the marquee multi-select gesture. + if ((e.ctrlKey || e.metaKey) && e.shiftKey) return; + e.stopPropagation(); + // Locked runs are inert: no select, no drag, no edit. + if (run.locked) return; + const zone = edgeZoneAt( + e.currentTarget as HTMLDivElement, + e.clientX, + e.clientY, + ); + + // Ctrl+drag still moves from anywhere inside, so existing muscle + // memory keeps working; grabbing the frame is the discoverable path. + if ((e.ctrlKey || e.metaKey || zone === "move") && onMove) { + const viaFrame = zone === "move" && !(e.ctrlKey || e.metaKey); + if (viaFrame) e.preventDefault(); + dragOriginRef.current = { x: e.clientX, y: e.clientY }; + setDragging(true); + setDragOffset({ x: 0, y: 0 }); + (e.currentTarget as HTMLDivElement).blur(); + // Pointer events (mouse/pen/touch) with a global capture so the + // drag keeps tracking even if the cursor leaves the overlay. + const onPointerMove = (ev: PointerEvent) => { + const origin = dragOriginRef.current; + if (!origin) return; + setDragOffset({ + x: ev.clientX - origin.x, + y: ev.clientY - origin.y, + }); + }; + const onPointerUp = (ev: PointerEvent) => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + setDragging(false); + setDragOffset(null); + const origin = dragOriginRef.current; + dragOriginRef.current = null; + if (!origin) return; + // Screen delta -> display-PDF delta, then invert the linear part of + // the CropBox/rotation transform to a raw-PDF delta. + const ddx = (ev.clientX - origin.x) / scale; + const ddy = -(ev.clientY - origin.y) / scale; + const v = transform.invertVector(ddx, ddy); + const dx = v.x; + const dy = v.y; + // Below the drag threshold nothing moved. From the frame that is + // a plain click (select); with Ctrl held it is the multi-select + // gesture it looks like. + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) { + onSelect(!viaFrame); + return; + } + onMove(dx, dy); + }; + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + return; + } + // Shift-click EXTENDS the multi-object selection. + if (e.shiftKey) { + e.preventDefault(); + onSelect(true); + return; + } + pointerFocusRef.current = true; + (e.currentTarget as HTMLDivElement).focus({ preventScroll: true }); + onSelect(false); + }} + onFocus={(e) => { + setFocused(true); + setTouched(false); + setMaskColor(readMaskColor(e.currentTarget as HTMLDivElement)); + const el = e.currentTarget as HTMLDivElement; + // Remember the text at focus so blur can tell if the user edited it. + focusTextRef.current = readOverlayText(el); + const fromPointer = pointerFocusRef.current; + pointerFocusRef.current = false; + const sel = window.getSelection(); + if ( + !fromPointer && + sel && + !(sel.rangeCount > 0 && el.contains(sel.anchorNode)) + ) { + caretToEnd(el, sel); + } + // Backend strategy: pre-warm the per-char charcode cache for the whole + // page in the background. + void (async () => { + try { + const [ + { getActiveCharcodeStrategy }, + { prewarmBackendCacheForPage }, + ] = await Promise.all([ + import("@app/tools/pdfTextEditor/charcode/CharcodeStrategy"), + import("@app/tools/pdfTextEditor/charcode/charcodeRegistry"), + ]); + if (getActiveCharcodeStrategy() !== "backend") return; + await prewarmBackendCacheForPage(run.pageIndex); + } catch { + /* prewarm is best-effort, never block focus */ + } + })(); + }} + onBlur={(e) => { + setTouched(false); + setMaskColor(null); + setFocused(false); + // WebKit routes keystrokes to the SELECTION even when the element has + // lost focus, so typing after a click-away landed in the run just + // left. Once focus is genuinely outside the run, its selection goes + // with it. + { + const el = e.currentTarget as HTMLDivElement; + const sel = window.getSelection(); + if ( + sel && + sel.focusNode && + el.contains(sel.focusNode) && + !(e.relatedTarget instanceof Node && el.contains(e.relatedTarget)) + ) { + sel.removeAllRanges(); + } + } + // Wrap mode: when the just-edited content overflows the locked box + // width. + if (!wantWrap || !onWrap) return; + const el = e.currentTarget as HTMLDivElement; + const domText = readOverlayText(el); + if (domText === focusTextRef.current) return; // not edited + const widest = measureMaxLineWidth(domText, font); + // Reflow to the box the user locked, NOT to `width` - with an exact + // layout that is however wide the text grew, so nothing ever overflows. + // + // Never below the locked width, though. `maxOnPageWidth` keeps a GROWN + // box on the page and holds back 4px to do it, so for a run that + // already spans most of the page it comes out a point or two under the + // width the document itself laid the text out at. Reflowing there costs + // every line its last word - "...carry out various" wraps "various" + // onto a line of its own, on lines the user never touched. The locked + // width is by definition one the text fitted in. + const target = wrapLockWidth; + // Only when something actually overflows. Reflowing a paragraph + // unconditionally re-breaks lines the user never touched: the reflow + // rebuilds every line, so a two-character edit that still fits could + // still move words between lines the moment the box lost focus. The + // base branch never reflowed here at all. + if (widest <= target + 1) return; + onWrap(target / scale); + }} + onCompositionStart={() => { + composingRef.current = true; + }} + onCompositionEnd={(e) => { + composingRef.current = false; + // Commit the composed string once, like onInput's non-IME path. + const el = e.currentTarget as HTMLDivElement; + onEdit(readOverlayText(el).replace(/\u00A0/g, " ")); + }} + onInput={(e) => { + setTouched(true); + setEditTick((n) => n + 1); + editedAtRevisionRef.current = pageRevision ?? -1; + // Skip intermediate IME steps; compositionend commits the result. + if (composingRef.current || (e.nativeEvent as InputEvent).isComposing) + return; + const el = e.currentTarget as HTMLDivElement; + // Re-fit the token the user just typed into. Its painted width is the + // PDF's advance for the ORIGINAL string, so leaving it alone lays the + // new text out at the browser's own advances and the caret drifts off + // the glyphs on the page, a pixel or so per keystroke. + if (isLinePainted(el)) refitEditedTokens(el, paintOpts); + // Always read hard breaks only - never synthesise newlines from browser + // soft-wraps. + const raw = readOverlayText(el); + const text = raw.replace(/\u00A0/g, " "); + onEdit(text); + // No per-keystroke reflow: while focused, the box is CAPPED to the page + // and wraps via CSS, so the editing view is always on-page. + }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => { + setHovered(false); + setEdgeZone(null); + }} + onPointerMove={(e) => { + // Only while idle: mid-drag the cursor is owned by the gesture. + if (run.locked || dragging) return; + setEdgeZone( + edgeZoneAt(e.currentTarget as HTMLDivElement, e.clientX, e.clientY), + ); + }} + style={{ + left, + top, + width, + minHeight: height, + // Live Ctrl+drag preview: follow the cursor via transform, and + // float above siblings + dim slightly so the move reads clearly. + // Drag preview and the width fit both live here, so compose them. + transform: + [ + dragOffset ? `translate(${dragOffset.x}px, ${dragOffset.y}px)` : "", + // Turn the box with the text. Placed before scaleX so the fit still + // stretches along the run's own axis rather than the page's. + runRotation ? `rotate(${runRotation.deg}deg)` : "", + fit.scaleX !== 1 ? `scaleX(${fit.scaleX})` : "", + ] + .filter(Boolean) + .join(" ") || undefined, + // Rotate about the text's own origin - the left end of its first + // baseline - which is the point the flow geometry positions. Otherwise + // scale from the run's own origin, never its centre. + transformOrigin: runRotation + ? `0 ${firstBaselineFromTop}px` + : fit.scaleX !== 1 + ? "0 50%" + : undefined, + opacity: dragging ? 0.75 : 1, + zIndex: dragging ? 20 : undefined, + // Only the opacity settle is animated. + transition: dragging ? "none" : "opacity 120ms ease-out", + // While focused: real glyphs in a CSS-stack approximation of the PDFium + // font, so the user sees their input before the bitmap re-renders. + fontFamily, + fontWeight, + fontStyle, + fontSize: fontSizePx, + letterSpacing: + !exact && (run.charSpacingPt || fit.letterSpacing) + ? `${(run.charSpacingPt ?? 0) * scale + fit.letterSpacing}px` + : undefined, + // Same line-height used in the baseline math above, so the CSS + // baselines land exactly where we computed `top`. + lineHeight: `${lineHeightPx}px`, + whiteSpace, + // Show the glyphs once the run is really being changed, or mid-drag so + // the Ctrl+drag preview is a visible chip that follows the cursor. + color: showsGlyphs ? toCssHex(run.fill) : "transparent", + WebkitTextStrokeColor: + showsGlyphs && run.stroke ? toCssHex(run.stroke) : undefined, + WebkitTextStrokeWidth: + showsGlyphs && run.stroke && run.strokeWidth + ? `${run.strokeWidth * scale}px` + : undefined, + backgroundColor: showsGlyphs + ? (maskColor ?? contrastingMaskFor(run.fill)) + : highlighted + ? "rgba(255,217,0,0.45)" + : selected + ? "rgba(44,123,229,0.10)" + : hovered + ? "rgba(44,123,229,0.04)" + : "transparent", + caretColor: toCssHex(run.fill), + // Selected keeps a ring: the 10% tint alone is near-invisible over a + // coloured band. Locked gets a muted ring so it does not read as + // something you can type into. + outline: run.locked + ? hovered || selected + ? "1px solid rgba(120,120,120,0.55)" + : "1px dashed transparent" + : dragging + ? "2px solid #2c7be5" + : selected + ? edgeZone + ? "2px solid #2c7be5" + : "1px solid #2c7be5" + : hovered + ? "1px dashed rgba(44,123,229,0.5)" + : "1px dashed transparent", + // The cursor is the affordance: the box advertises move/resize on the + // frame and keeps the I-beam over the text. + cursor: run.locked + ? "default" + : dragging + ? "grabbing" + : edgeZone === "move" + ? "grab" + : undefined, + overflow: "hidden", + }} + /> + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/Toolbar.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/Toolbar.tsx new file mode 100644 index 0000000000..a9cbb18a63 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/Toolbar.tsx @@ -0,0 +1,578 @@ +import { useState } from "react"; +import { + ColorInput, + Group, + Menu, + NumberInput, + Popover, + Text, + Tooltip, +} from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import UndoIcon from "@mui/icons-material/Undo"; +import RedoIcon from "@mui/icons-material/Redo"; +import DeleteIcon from "@mui/icons-material/DeleteOutlined"; +import FormatItalicIcon from "@mui/icons-material/FormatItalic"; +import TuneIcon from "@mui/icons-material/TuneOutlined"; +import LockIcon from "@mui/icons-material/LockOutlined"; +import LockOpenIcon from "@mui/icons-material/LockOpenOutlined"; +import TextFieldsIcon from "@mui/icons-material/TextFields"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import LayersIcon from "@mui/icons-material/LayersOutlined"; +import FlipToFrontIcon from "@mui/icons-material/FlipToFrontOutlined"; +import FlipToBackIcon from "@mui/icons-material/FlipToBackOutlined"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import VerticalAlignTopIcon from "@mui/icons-material/VerticalAlignTop"; +import VerticalAlignBottomIcon from "@mui/icons-material/VerticalAlignBottom"; +import VerticalAlignCenterIcon from "@mui/icons-material/VerticalAlignCenter"; +import AlignHorizontalLeftIcon from "@mui/icons-material/AlignHorizontalLeftOutlined"; +import AlignHorizontalCenterIcon from "@mui/icons-material/AlignHorizontalCenterOutlined"; +import AlignHorizontalRightIcon from "@mui/icons-material/AlignHorizontalRightOutlined"; +import LinearScaleIcon from "@mui/icons-material/LinearScaleOutlined"; +import { useTranslation } from "react-i18next"; +import { parseCssColor, toCssHex } from "@app/tools/pdfTextEditor/model/Color"; +import { familyOf } from "@app/tools/pdfTextEditor/util/fontFamily"; +import { FontFamilySelect } from "@app/tools/pdfTextEditor/components/FontFamilySelect"; +import type { useToolbarController } from "@app/tools/pdfTextEditor/hooks/useToolbarController"; + +type Controller = ReturnType; + +/** + * The canvas toolbar: undo/redo, plus formatting for the current selection. + * + * Character formatting sits here rather than in the side panel because that is + * where every document editor puts it. The group is *contextual* - it appears + * with a selection instead of standing permanently greyed - which is what + * keeps the strip to a single row. + */ +interface ToolbarProps { + controller: Controller; +} + +function ToolbarSeparator() { + return ( + + | + + ); +} + +/** Toolbar children keep their natural width; the strip scrolls if pressed. */ +const NO_SHRINK = { flexShrink: 0 } as const; + +export function Toolbar({ controller }: ToolbarProps) { + const { t } = useTranslation(); + const hasSelection = controller.selectionCount > 0; + return ( + + + + + + {t("pdfTextEditor.toolbar.order", "Order")} + } + onClick={() => onChangeZOrder("to-front")} + data-testid="pdf-editor-z-to-front" + > + {t("pdfTextEditor.toolbar.bringToFront", "Bring to front")} + + } + onClick={() => onChangeZOrder("forward")} + data-testid="pdf-editor-z-forward" + > + {t("pdfTextEditor.toolbar.bringForward", "Bring forward")} + + } + onClick={() => onChangeZOrder("backward")} + data-testid="pdf-editor-z-backward" + > + {t("pdfTextEditor.toolbar.sendBackward", "Send backward")} + + } + onClick={() => onChangeZOrder("to-back")} + data-testid="pdf-editor-z-to-back" + > + {t("pdfTextEditor.toolbar.sendToBack", "Send to back")} + + + + {t("pdfTextEditor.toolbar.alignLabel", "Align · needs 2+ objects")} + + } + disabled={hAlignDisabled} + onClick={() => onAlign("left")} + data-testid="pdf-editor-align-left" + > + {t("pdfTextEditor.toolbar.alignLeft", "Align left")} + + } + disabled={hAlignDisabled} + onClick={() => onAlign("center-h")} + data-testid="pdf-editor-align-center-h" + > + {t("pdfTextEditor.toolbar.alignCentre", "Align centre")} + + } + disabled={hAlignDisabled} + onClick={() => onAlign("right")} + data-testid="pdf-editor-align-right" + > + {t("pdfTextEditor.toolbar.alignRight", "Align right")} + + } + disabled={alignDisabled} + onClick={() => onAlign("top")} + data-testid="pdf-editor-align-top" + > + {t("pdfTextEditor.toolbar.alignTop", "Align top")} + + } + disabled={alignDisabled} + onClick={() => onAlign("middle-v")} + data-testid="pdf-editor-align-middle-v" + > + {t("pdfTextEditor.toolbar.alignMiddle", "Align middle")} + + } + disabled={alignDisabled} + onClick={() => onAlign("bottom")} + data-testid="pdf-editor-align-bottom" + > + {t("pdfTextEditor.toolbar.alignBottom", "Align bottom")} + + + + {t( + "pdfTextEditor.toolbar.distributeLabel", + "Distribute · needs 3+ objects", + )} + + } + disabled={distributeDisabled} + onClick={() => onDistribute("horizontal")} + data-testid="pdf-editor-distribute-h" + > + {t( + "pdfTextEditor.toolbar.distributeHorizontally", + "Distribute horizontally", + )} + + + } + disabled={distributeDisabled} + onClick={() => onDistribute("vertical")} + data-testid="pdf-editor-distribute-v" + > + {t( + "pdfTextEditor.toolbar.distributeVertically", + "Distribute vertically", + )} + + + + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/ZoomPill.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/ZoomPill.tsx new file mode 100644 index 0000000000..4244753682 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/ZoomPill.tsx @@ -0,0 +1,108 @@ +import { Group, Text, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { PageSnapshot } from "@app/tools/pdfTextEditor/types"; + +const Z_OUT_LIMIT = 0.25; +const Z_IN_LIMIT = 4; +const Z_STEP = 0.25; +const FIT_PAD_PX = 64; + +interface Props { + store: EditorStore; + renderScale: number; + pages: PageSnapshot[]; +} + +/** + * Zoom, floating over the pages it scales. + * + * Anchored to the canvas because it is a view control: it belongs beside what + * it acts on. Ctrl+wheel on the stage drives the same store field. + */ +export function ZoomPill({ store, renderScale, pages }: Props) { + const { t } = useTranslation(); + const zoomTo = (scale: number) => + store.setRenderScale( + +Math.min(Z_IN_LIMIT, Math.max(Z_OUT_LIMIT, scale)).toFixed(2), + ); + + return ( + + + {/* The readout doubles as the reset control: a separate "100%" button + beside a "150%" readout read as two zoom values. */} + + + + + + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentInspector.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentInspector.tsx new file mode 100644 index 0000000000..db81a9ec6e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentInspector.tsx @@ -0,0 +1,242 @@ +import { useState } from "react"; +import { Badge, Collapse, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import { + Section, + SectionLabel, + StatRow, +} from "@app/tools/pdfTextEditor/components/inspector/InspectorPrimitives"; +import { + analyzePageFonts, + type PageFont, +} from "@app/tools/pdfTextEditor/util/pageFonts"; +import { DocumentSettings } from "@app/tools/pdfTextEditor/components/inspector/DocumentSettings"; +import type { + GroupingMode, + PageSnapshot, + WidthMode, +} from "@app/tools/pdfTextEditor/types"; + +interface Props { + pages: PageSnapshot[]; + groupingMode: GroupingMode; + widthMode: WidthMode; + showRulers: boolean; + onSetGroupingMode: (mode: GroupingMode) => void; + onSetWidthMode: (mode: WidthMode) => void; + onSetShowRulers: (show: boolean) => void; +} + +/** Facts about the open document. Nothing here acts on a selection. */ +export function DocumentInspector({ pages, ...settings }: Props) { + const { t } = useTranslation(); + const runs = pages.reduce((n, p) => n + p.runs.length, 0); + const images = pages.reduce((n, p) => n + p.images.length, 0); + return ( + +
+ + {t("pdfTextEditor.inspector.document", "Document")} + + + + + + +
+ + +
+ ); +} + +const FONT_STATUS_COLOR = { + standard: "green", + embedded: "blue", + subset: "yellow", +} as const; + +/** + * Font coverage, collapsed to a single status row. + * + * The old panel banner fired on every document to say nothing was wrong. Here + * the headline is one pill; the per-font detail is one click away, and the row + * only opens itself when a font is actually missing glyphs. + */ +function FontsSection({ pages }: { pages: PageSnapshot[] }) { + const { t } = useTranslation(); + // Pure: the font list AND coverage both come from snapshot data + the cmap + // cache the loader primed during its serialized read. + const fonts = analyzePageFonts(pages); + const withGaps = fonts.filter( + (f) => f.coverage.known && f.coverage.missing.length > 0, + ); + const [open, setOpen] = useState(false); + if (fonts.length === 0) return null; + + const allConfirmedFull = + fonts.length > 0 && + fonts.every((f) => f.coverage.known && f.coverage.missing.length === 0); + const tone = withGaps.length > 0 ? "warn" : allConfirmedFull ? "ok" : "info"; + const summary = { + ok: { + color: "green", + label: t("pdfTextEditor.fonts.pill.ok", "All glyphs"), + hint: t( + "pdfTextEditor.fonts.compat.ok", + "Every font includes the full alphabet and digits - type freely.", + ), + }, + info: { + color: "blue", + label: t("pdfTextEditor.fonts.pill.info", "Embedded"), + hint: t( + "pdfTextEditor.fonts.compat.info", + "Existing text edits perfectly. A new character an embedded font doesn't include falls back to a standard font.", + ), + }, + warn: { + color: "yellow", + label: t("pdfTextEditor.fonts.pill.warn", "{{count}} with gaps", { + count: withGaps.length, + }), + hint: t( + "pdfTextEditor.fonts.compat.warnOther", + "{{count}} fonts missing some letters or numbers - typing those uses a standard fallback font.", + { count: withGaps.length }, + ), + }, + }[tone]; + + const expanded = open || tone === "warn"; + return ( +
+ + + + {fonts.map((f) => ( + + ))} + + +
+ ); +} + +/** Compact list of missing a-zA-Z0-9, e.g. "q W 7" (capped for width). */ +function formatMissing(missing: string[]): string { + const shown = missing.slice(0, 12).join(" "); + return missing.length > 12 ? `${shown} +${missing.length - 12}` : shown; +} + +function FontRow({ font }: { font: PageFont }) { + const { t } = useTranslation(); + const { known, missing } = font.coverage; + const hasGap = known && missing.length > 0; + return ( + + + + {font.name} + + + {t( + `pdfTextEditor.fonts.status.${font.status}.label`, + font.status === "standard" + ? "Standard" + : font.status === "embedded" + ? "Embedded" + : "Subset", + )} + + + {known && + (hasGap ? ( + + {t("pdfTextEditor.fonts.missing", "Missing: {{glyphs}}", { + glyphs: formatMissing(missing), + })} + + ) : ( + + {t( + "pdfTextEditor.fonts.allPresent", + "All letters & numbers present", + )} + + ))} + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentSettings.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentSettings.tsx new file mode 100644 index 0000000000..3aa6f9e55c --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentSettings.tsx @@ -0,0 +1,160 @@ +import { useState } from "react"; +import { Box, Collapse, Group, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; +import { ToggleSwitch } from "@app/ui/ToggleSwitch"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import { SpellcheckControl } from "@app/tools/pdfTextEditor/components/SpellcheckControl"; +import { + Section, + SectionLabel, +} from "@app/tools/pdfTextEditor/components/inspector/InspectorPrimitives"; +import type { GroupingMode, WidthMode } from "@app/tools/pdfTextEditor/types"; + +interface Props { + groupingMode: GroupingMode; + widthMode: WidthMode; + showRulers: boolean; + onSetGroupingMode: (mode: GroupingMode) => void; + onSetWidthMode: (mode: WidthMode) => void; + onSetShowRulers: (show: boolean) => void; +} + +/** + * Document-level preferences, split by how often they are touched. + * + * View toggles are everyday and sit in plain sight. The two parse options are + * not: they change how the document was read, and switching grouping reloads + * it and discards undo history - so they go behind a disclosure where nobody + * flips one by accident, with the consequence spelled out next to the control. + */ +export function DocumentSettings({ + groupingMode, + widthMode, + showRulers, + onSetGroupingMode, + onSetWidthMode, + onSetShowRulers, +}: Props) { + const { t } = useTranslation(); + const [advancedOpen, setAdvancedOpen] = useState(false); + + return ( + <> +
+ {t("pdfTextEditor.settings.view", "View")} + + + {/* The row's own text names the switch; passing `label` too would + print it twice, once either side of the control. */} + + {t("pdfTextEditor.sidebar.rulers", "Rulers and guides")} + + + + + +
+ +
+ + + + + + {t("pdfTextEditor.sidebar.textGrouping", "Text grouping")} + + + + + + {t( + "pdfTextEditor.sidebar.groupingAutoHint", + "Groups equal-spaced lines into paragraphs. Changing this re-reads the document and clears undo history.", + )} + + + + + {t("pdfTextEditor.sidebar.textBoxWidth", "New text box width")} + + + + + + {t( + "pdfTextEditor.sidebar.widthGrowHint", + "Grow widens a box as you type; Wrap keeps its width and flows onto new lines.", + )} + + + + +
+ + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/InspectorPrimitives.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/InspectorPrimitives.tsx new file mode 100644 index 0000000000..0cbaba11ff --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/InspectorPrimitives.tsx @@ -0,0 +1,155 @@ +import type { ReactNode } from "react"; +import { Box, Group, NumberInput, Stack, Text, Tooltip } from "@mantine/core"; +import HelpIcon from "@mui/icons-material/HelpOutlineOutlined"; + +/** Shared layout atoms for the editor's properties inspector. */ + +/** Uppercase section heading, optionally with a trailing control. */ +export function SectionLabel({ + children, + right, +}: { + children: ReactNode; + right?: ReactNode; +}) { + return ( + + + {children} + + {right} + + ); +} + +/** One bordered band. Sections stack with a hairline between them. */ +export function Section({ + children, + testId, + tinted, + first, +}: { + children: ReactNode; + testId?: string; + tinted?: boolean; + /** Topmost band in its panel: no rule above it. */ + first?: boolean; +}) { + return ( + + {children} + + ); +} + +/** Label above a control, the panel's only field layout. */ +export function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: ReactNode; +}) { + return ( + + + + {label} + + {hint && } + + {children} + + ); +} + +/** The `?` that replaced the panel's permanent explanatory paragraphs. */ +export function HintIcon({ label }: { label: string }) { + return ( + + + + ); +} + +/** Read-only key/value line used by the Document tab. */ +export function StatRow({ label, value }: { label: string; value: ReactNode }) { + return ( + + + {label} + + + {value} + + + ); +} + +/** + * A points field that only commits a real change. + * + * Geometry edits dispatch undoable commands, so re-emitting the value the + * field already shows would cost a spurious undo step on every blur. + */ +export function PointsInput({ + value, + onCommit, + label, + testId, + min, + disabled, +}: { + value: number; + onCommit: (next: number) => void; + label: string; + testId?: string; + min?: number; + disabled?: boolean; +}) { + return ( + { + const n = typeof next === "number" ? next : Number(next); + if (!Number.isFinite(n)) return; + if (Math.abs(n - value) < 0.05) return; + onCommit(n); + }} + /> + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/SelectionInspector.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/SelectionInspector.tsx new file mode 100644 index 0000000000..ae2f55bc3b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/SelectionInspector.tsx @@ -0,0 +1,400 @@ +import { useMemo } from "react"; +import { Group, Stack, Text, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import ImageIcon from "@mui/icons-material/ImageOutlined"; +import CallMergeIcon from "@mui/icons-material/CallMergeOutlined"; +import CallSplitIcon from "@mui/icons-material/CallSplitOutlined"; +import RotateLeftIcon from "@mui/icons-material/RotateLeftOutlined"; +import RotateRightIcon from "@mui/icons-material/RotateRightOutlined"; +import FlipIcon from "@mui/icons-material/FlipOutlined"; +import OpenInNewIcon from "@mui/icons-material/OpenInNewOutlined"; +import { + Field, + PointsInput, + Section, + SectionLabel, +} from "@app/tools/pdfTextEditor/components/inspector/InspectorPrimitives"; +import type { SelectionGeometry } from "@app/tools/pdfTextEditor/hooks/useSelectionGeometry"; +import type { useToolbarController } from "@app/tools/pdfTextEditor/hooks/useToolbarController"; +import type { SelectionState } from "@app/tools/pdfTextEditor/types"; + +export type InspectorController = ReturnType; + +interface Props { + controller: InspectorController; + selection: SelectionState; + geometry: SelectionGeometry; + /** Font status for the selected runs, e.g. "Embedded · full alphabet". */ + fontNote: string | null; + canGroup: boolean; + canUngroup: boolean; + onGroup: () => void; + onUngroup: () => void; +} + +/** + * Properties of whatever is selected right now. + * + * Deliberately NOT the whole of the selection's UI: character formatting and + * the arrange/lock/delete verbs sit in the canvas toolbar, where document + * editors have always put them. What lands here is what needs a label and a + * number - geometry and paragraph structure. + */ +export function SelectionInspector({ + controller, + selection, + geometry, + fontNote, + canGroup, + canUngroup, + onGroup, + onUngroup, +}: Props) { + const runCount = selection.runIds.length; + const imageCount = selection.imageIds.length; + const { hasRunSelection, hasImageSelection } = controller; + + return ( + + + + {hasRunSelection && ( + + )} + {hasImageSelection && } + + ); +} + +/** Names what is selected, and how its font will treat new characters. */ +function SelectionHeader({ + runCount, + imageCount, + fontNote, +}: { + runCount: number; + imageCount: number; + fontNote: string | null; +}) { + const { t } = useTranslation(); + let title: string; + if (runCount > 0 && imageCount > 0) { + title = t("pdfTextEditor.inspector.mixed", "{{count}} objects", { + count: runCount + imageCount, + }); + } else if (runCount > 0) { + title = + runCount === 1 + ? t("pdfTextEditor.inspector.oneText", "Text") + : t("pdfTextEditor.inspector.manyText", "Text · {{count}} boxes", { + count: runCount, + }); + } else { + title = + imageCount === 1 + ? t("pdfTextEditor.inspector.oneImage", "Image") + : t("pdfTextEditor.inspector.manyImages", "{{count}} images", { + count: imageCount, + }); + } + return ( +
+ {/* Doubles as the old sidebar's selection readout, moved from the very + bottom of the panel to the top where the user is already looking. */} + + {title} + + {fontNote && ( + + {fontNote} + + )} +
+ ); +} + +/** Merge selected runs into a paragraph, or split one back into lines. */ +function ParagraphSection({ + canGroup, + canUngroup, + onGroup, + onUngroup, +}: { + canGroup: boolean; + canUngroup: boolean; + onGroup: () => void; + onUngroup: () => void; +}) { + const { t } = useTranslation(); + return ( +
+ + {t("pdfTextEditor.sidebar.paragraph", "Paragraph")} + + + + + + + + + +
+ ); +} + +/** Position and size, in PDF points, for a single selected object. */ +function GeometrySection({ + geometry, + isImage, +}: { + geometry: SelectionGeometry; + isImage: boolean; +}) { + const { t } = useTranslation(); + if (!geometry.single) { + return ( +
+ + {t("pdfTextEditor.inspector.geometry", "Position & size")} + + + {t( + "pdfTextEditor.inspector.multiGeometry", + "Select a single object to edit its position and size.", + )} + +
+ ); + } + const { bounds, setX, setY, setWidth, setHeight } = geometry.single; + return ( +
+ + {t("pdfTextEditor.inspector.geometry", "Position & size")} + + + + + + + + + + + + + {/* Read-only for text: setting a width goes through the reflow, + which splits inside words on runs whose glyphs are positioned + individually. Until that is token-aware this must not be a + one-keystroke way to shred a heading. */} + undefined} + min={1} + disabled={!isImage} + label={t("pdfTextEditor.inspector.width", "Width")} + testId="pdf-editor-size-w" + /> + + + undefined)} + min={1} + disabled={!setHeight} + label={t("pdfTextEditor.inspector.height", "Height")} + testId="pdf-editor-size-h" + /> + + + +
+ ); +} + +/** Rotate/flip plus the two ways to swap an image's pixels. */ +function ImageSection({ controller }: { controller: InspectorController }) { + const { t } = useTranslation(); + const { + onTransformImage, + onReplaceImage, + onEditImageExternally, + externalEditSupported, + } = controller; + const transforms = useMemo( + () => + [ + { + mode: "rotate-ccw" as const, + testId: "pdf-editor-imgop-rotate-ccw", + icon: , + label: t("pdfTextEditor.toolbar.rotateLeft", "Rotate 90° left"), + }, + { + mode: "rotate-cw" as const, + testId: "pdf-editor-imgop-rotate-cw", + icon: , + label: t("pdfTextEditor.toolbar.rotateRight", "Rotate 90° right"), + }, + { + mode: "flip-h" as const, + testId: "pdf-editor-imgop-flip-h", + icon: , + label: t("pdfTextEditor.toolbar.flipHorizontal", "Flip horizontal"), + }, + { + mode: "flip-v" as const, + testId: "pdf-editor-imgop-flip-v", + icon: ( + + ), + label: t("pdfTextEditor.toolbar.flipVertical", "Flip vertical"), + }, + ] as const, + [t], + ); + + return ( +
+ {t("pdfTextEditor.inspector.image", "Image")} + + + {transforms.map((tr) => ( + + + + +
+ ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/fontAnalysis.ts b/frontend/editor/src/core/tools/pdfTextEditor/fontAnalysis.ts deleted file mode 100644 index 87b2f92d20..0000000000 --- a/frontend/editor/src/core/tools/pdfTextEditor/fontAnalysis.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { - PdfJsonDocument, - PdfJsonFont, -} from "@app/tools/pdfTextEditor/pdfTextEditorTypes"; - -export type FontStatus = - | "perfect" - | "embedded-subset" - | "system-fallback" - | "missing" - | "unknown"; - -export interface FontAnalysis { - fontId: string; - baseName: string; - status: FontStatus; - embedded: boolean; - isSubset: boolean; - isStandard14: boolean; - hasWebFormat: boolean; - webFormat?: string; - subtype?: string; - encoding?: string; - warnings: string[]; - suggestions: string[]; -} - -export interface DocumentFontAnalysis { - fonts: FontAnalysis[]; - canReproducePerfectly: boolean; - hasWarnings: boolean; - summary: { - perfect: number; - embeddedSubset: number; - systemFallback: number; - missing: number; - unknown: number; - }; -} - -/** - * Determines if a font name indicates it's a subset font. - * Subset fonts typically have a 6-character prefix like "ABCDEE+" - */ -const isSubsetFont = (baseName: string | null | undefined): boolean => { - if (!baseName) return false; - // Check for common subset patterns: ABCDEF+FontName - return /^[A-Z]{6}\+/.test(baseName); -}; - -/** - * Checks if a font is one of the standard 14 PDF fonts that are guaranteed - * to be available on all PDF readers - */ -const isStandard14Font = (font: PdfJsonFont): boolean => { - if (font.standard14Name) return true; - - const baseName = (font.baseName || "").toLowerCase().replace(/[-_\s]/g, ""); - - const standard14Patterns = [ - "timesroman", - "timesbold", - "timesitalic", - "timesbolditalic", - "helvetica", - "helveticabold", - "helveticaoblique", - "helveticaboldoblique", - "courier", - "courierbold", - "courieroblique", - "courierboldoblique", - "symbol", - "zapfdingbats", - ]; - - // Check exact matches or if the base name contains the pattern - return standard14Patterns.some((pattern) => { - // Exact match - if (baseName === pattern) return true; - // Contains pattern (e.g., "ABCDEF+Helvetica" matches "helvetica") - if (baseName.includes(pattern)) return true; - return false; - }); -}; - -/** - * Checks if a font has a fallback available on the backend. - * These fonts are embedded in the Stirling PDF backend and can be used - * for PDF export even if not in the original PDF. - * - * Based on PdfJsonFallbackFontService.java - */ -const hasBackendFallbackFont = (font: PdfJsonFont): boolean => { - const baseName = (font.baseName || "").toLowerCase().replace(/[-_\s]/g, ""); - - // Backend has these font families available (from PdfJsonFallbackFontService) - const backendFonts = [ - // Liberation fonts (metric-compatible with MS core fonts) - "arial", - "helvetica", - "arimo", - "times", - "timesnewroman", - "tinos", - "courier", - "couriernew", - "cousine", - "liberation", - "liberationsans", - "liberationserif", - "liberationmono", - // DejaVu fonts - "dejavu", - "dejavusans", - "dejavuserif", - "dejavumono", - "dejavusansmono", - // Noto fonts - "noto", - "notosans", - ]; - - return backendFonts.some((pattern) => { - if (baseName === pattern) return true; - if (baseName.includes(pattern)) return true; - return false; - }); -}; - -/** - * Extracts the base font name from a subset font name - * e.g., "ABCDEF+Arial" -> "Arial" - */ -const extractBaseFontName = ( - baseName: string | null | undefined, -): string | null => { - if (!baseName) return null; - const match = baseName.match(/^[A-Z]{6}\+(.+)$/); - return match ? match[1] : baseName; -}; - -/** - * Analyzes a single font to determine if it can be reproduced perfectly - * Takes allFonts to check if full versions of subset fonts are available - */ -export const analyzeFontReproduction = ( - font: PdfJsonFont, - allFonts?: PdfJsonFont[], -): FontAnalysis => { - const fontId = font.id || font.uid || "unknown"; - const baseName = font.baseName || "Unknown Font"; - const isSubset = isSubsetFont(font.baseName); - const isStandard14 = isStandard14Font(font); - const hasBackendFallback = hasBackendFallbackFont(font); - const embedded = font.embedded ?? false; - - // Check available web formats (ordered by preference) - const webFormats = [ - { key: "webProgram", format: font.webProgramFormat }, - { key: "pdfProgram", format: font.pdfProgramFormat }, - { key: "program", format: font.programFormat }, - ]; - - const availableWebFormat = webFormats.find((f) => f.format); - const hasWebFormat = !!availableWebFormat; - const webFormat = availableWebFormat?.format || undefined; - - const warnings: string[] = []; - const suggestions: string[] = []; - let status: FontStatus = "unknown"; - - // Check if we have the full font when this is a subset - let hasFullFontVersion = false; - if (isSubset && allFonts) { - const baseFont = extractBaseFontName(font.baseName); - if (baseFont) { - // Look for a non-subset version of this font with a web format - hasFullFontVersion = allFonts.some((f) => { - const otherBaseName = extractBaseFontName(f.baseName); - const isNotSubset = !isSubsetFont(f.baseName); - const hasFormat = !!( - f.webProgramFormat || - f.pdfProgramFormat || - f.programFormat - ); - const sameBase = - otherBaseName?.toLowerCase() === baseFont.toLowerCase(); - return sameBase && isNotSubset && hasFormat && (f.embedded ?? false); - }); - } - } - - // Analyze font status - focusing on PDF export quality - if (isStandard14) { - // Standard 14 fonts are always available in PDF readers - perfect for export! - status = "perfect"; - suggestions.push( - "Standard PDF font (Times, Helvetica, or Courier). Always available in PDF readers.", - ); - suggestions.push( - "Exported PDFs will render consistently across all PDF readers.", - ); - } else if (embedded && !isSubset) { - // Perfect: Fully embedded with complete character set - status = "perfect"; - suggestions.push( - "Font is fully embedded. Exported PDFs will reproduce text perfectly, even with edits.", - ); - } else if ( - embedded && - isSubset && - (hasFullFontVersion || hasBackendFallback) - ) { - // Subset but we have the full font or backend fallback - perfect! - status = "perfect"; - if (hasFullFontVersion) { - suggestions.push( - "Full font version is also available in the document. Exported PDFs can reproduce all characters.", - ); - } else if (hasBackendFallback) { - suggestions.push( - "Backend has the full font available. Exported PDFs can reproduce all characters, including new text.", - ); - } - } else if (embedded && isSubset) { - // Good, but subset: May have missing characters if user adds new text - status = "embedded-subset"; - warnings.push( - "This is a subset font - only specific characters are embedded in the PDF.", - ); - warnings.push( - "Exported PDFs may have missing characters if you add new text with this font.", - ); - suggestions.push( - "Existing text will export correctly. New characters may render as boxes (☐) or fallback glyphs.", - ); - } else if (!embedded && hasBackendFallback) { - // Not embedded, but backend has it - perfect for export! - status = "perfect"; - suggestions.push( - "Backend has this font available. Exported PDFs will use the backend fallback font.", - ); - suggestions.push("Text will export correctly with consistent appearance."); - } else if (!embedded) { - // Not embedded - must rely on system fonts (risky for export) - status = "missing"; - warnings.push("Font is not embedded in the PDF."); - warnings.push( - "Exported PDFs will substitute with a fallback font, which may look very different.", - ); - suggestions.push( - "Consider re-embedding fonts or accepting that the exported PDF will use fallback fonts.", - ); - } else if (embedded && !hasWebFormat) { - // Embedded but no web format available (still okay for export) - status = "perfect"; - suggestions.push( - "Font is embedded in the PDF. Exported PDFs will reproduce correctly.", - ); - suggestions.push( - "Web preview may use a fallback font, but the final PDF export will be accurate.", - ); - } - - // Additional warnings based on font properties - if (font.subtype === "Type0" && font.cidSystemInfo) { - const registry = font.cidSystemInfo.registry || ""; - const ordering = font.cidSystemInfo.ordering || ""; - if ( - registry.includes("Adobe") && - (ordering.includes("Identity") || ordering.includes("UCS")) - ) { - // CID fonts with Identity encoding are common for Asian languages - if (!embedded || !hasWebFormat) { - warnings.push("This CID font may contain Asian or Unicode characters."); - } - } - } - - if ( - font.encoding && - !font.encoding.includes("WinAnsiEncoding") && - !font.encoding.includes("MacRomanEncoding") - ) { - // Custom encodings may cause issues - if (font.encoding !== "Identity-H" && font.encoding !== "Identity-V") { - warnings.push(`Custom encoding detected: ${font.encoding}`); - } - } - - return { - fontId, - baseName, - status, - embedded, - isSubset, - isStandard14, - hasWebFormat, - webFormat, - subtype: font.subtype || undefined, - encoding: font.encoding || undefined, - warnings, - suggestions, - }; -}; - -/** - * Gets fonts used on a specific page - */ -export const getFontsForPage = ( - document: PdfJsonDocument | null, - pageIndex: number, -): PdfJsonFont[] => { - if ( - !document?.fonts || - !document?.pages || - pageIndex < 0 || - pageIndex >= document.pages.length - ) { - return []; - } - - const page = document.pages[pageIndex]; - if (!page?.textElements) { - return []; - } - - // Get unique font IDs used on this page - const fontIdsOnPage = new Set(); - page.textElements.forEach((element) => { - if (element?.fontId) { - fontIdsOnPage.add(element.fontId); - } - }); - - // Filter fonts to only those used on this page - const allFonts = document.fonts.filter( - (font): font is PdfJsonFont => font !== null && font !== undefined, - ); - - const fontsOnPage = allFonts.filter((font) => { - // Match by ID - if (font.id && fontIdsOnPage.has(font.id)) { - return true; - } - // Match by UID - if (font.uid && fontIdsOnPage.has(font.uid)) { - return true; - } - // Match by page-specific ID (pageNumber:id format) - if (font.pageNumber === pageIndex + 1 && font.id) { - const pageSpecificId = `${font.pageNumber}:${font.id}`; - if (fontIdsOnPage.has(pageSpecificId) || fontIdsOnPage.has(font.id)) { - return true; - } - } - return false; - }); - - // Deduplicate by base font name to avoid showing the same font multiple times - const uniqueFonts = new Map(); - fontsOnPage.forEach((font) => { - const baseName = - extractBaseFontName(font.baseName) || - font.baseName || - font.id || - "unknown"; - const key = baseName.toLowerCase(); - - // Keep the first occurrence, or prefer non-subset over subset - const existing = uniqueFonts.get(key); - if (!existing) { - uniqueFonts.set(key, font); - } else { - // Prefer non-subset fonts over subset fonts - const existingIsSubset = isSubsetFont(existing.baseName); - const currentIsSubset = isSubsetFont(font.baseName); - if (existingIsSubset && !currentIsSubset) { - uniqueFonts.set(key, font); - } - } - }); - - return Array.from(uniqueFonts.values()); -}; - -/** - * Analyzes all fonts in a PDF document (or just fonts for a specific page) - */ -export const analyzeDocumentFonts = ( - document: PdfJsonDocument | null, - pageIndex?: number, -): DocumentFontAnalysis => { - if (!document?.fonts || document.fonts.length === 0) { - return { - fonts: [], - canReproducePerfectly: true, - hasWarnings: false, - summary: { - perfect: 0, - embeddedSubset: 0, - systemFallback: 0, - missing: 0, - unknown: 0, - }, - }; - } - - const allFonts = document.fonts.filter( - (font): font is PdfJsonFont => font !== null && font !== undefined, - ); - - // Filter to page-specific fonts if pageIndex is provided - const fontsToAnalyze = - pageIndex !== undefined ? getFontsForPage(document, pageIndex) : allFonts; - - if (fontsToAnalyze.length === 0) { - return { - fonts: [], - canReproducePerfectly: true, - hasWarnings: false, - summary: { - perfect: 0, - embeddedSubset: 0, - systemFallback: 0, - missing: 0, - unknown: 0, - }, - }; - } - - const fontAnalyses = fontsToAnalyze.map((font) => - analyzeFontReproduction(font, allFonts), - ); - - // Calculate summary - const summary = { - perfect: fontAnalyses.filter((f) => f.status === "perfect").length, - embeddedSubset: fontAnalyses.filter((f) => f.status === "embedded-subset") - .length, - systemFallback: fontAnalyses.filter((f) => f.status === "system-fallback") - .length, - missing: fontAnalyses.filter((f) => f.status === "missing").length, - unknown: fontAnalyses.filter((f) => f.status === "unknown").length, - }; - - // Can reproduce perfectly ONLY if all fonts are truly perfect (not subsets) - const canReproducePerfectly = fontAnalyses.every( - (f) => f.status === "perfect", - ); - - // Has warnings if any font has issues (including subsets) - const hasWarnings = fontAnalyses.some( - (f) => - f.warnings.length > 0 || - f.status === "missing" || - f.status === "system-fallback" || - f.status === "embedded-subset", - ); - - return { - fonts: fontAnalyses, - canReproducePerfectly, - hasWarnings, - summary, - }; -}; - -/** - * Gets a human-readable description of the font status - */ -export const getFontStatusDescription = (status: FontStatus): string => { - switch (status) { - case "perfect": - return "Fully embedded - perfect reproduction"; - case "embedded-subset": - return "Embedded (subset) - existing text will render correctly"; - case "system-fallback": - return "Using system font - appearance may differ"; - case "missing": - return "Not embedded - will use fallback font"; - case "unknown": - return "Unknown status"; - } -}; - -/** - * Gets a color indicator for the font status - */ -export const getFontStatusColor = (status: FontStatus): string => { - switch (status) { - case "perfect": - return "green"; - case "embedded-subset": - return "blue"; - case "system-fallback": - return "yellow"; - case "missing": - return "red"; - case "unknown": - return "gray"; - } -}; diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useAutoLoadFile.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useAutoLoadFile.ts new file mode 100644 index 0000000000..14d5447f21 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useAutoLoadFile.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useAllFiles, useFileSelection } from "@app/contexts/FileContext"; +import type { FileId } from "@app/types/file"; +import { useNavigationState } from "@app/contexts/NavigationContext"; +import { useViewer } from "@app/contexts/ViewerContext"; + +type Loader = (file: File) => unknown; +// The workbench fileId is what lets save write the edit back to the +// same file rather than only producing a download. +type OnFileChosen = (name: string, fileId?: FileId) => void; + +type WorkbenchFile = File & { fileId?: FileId; quickKey?: string }; + +function fileKey(file: File): string { + const f = file as WorkbenchFile; + return f.fileId ?? f.quickKey ?? `${f.name}|${f.size}|${f.lastModified}`; +} + +interface AutoLoad { + /** Open a workbench file deliberately. */ + openFile: (file: File) => void; + /** Record a document the editor loaded by other means, so auto-open stands down. */ + adopt: (file: File) => void; +} + +/** The slice of the editor's state that decides whether it needs a file. */ +export interface EditorLoadState { + hasDocument: boolean; + loading: boolean; + error: string | null; +} + +/** + * Open the file the user most likely wants. + * + * Auto-opening only ever fires while the editor holds nothing: the selection + * moves on its own (the Active Files view trims a multi-file selection down to + * its last entry to honour the tool's one-file limit), and following it would + * swap the open document, and any unsaved edits, out from under the user. + * + * "Holds nothing" is the store's own state, not a memory of having opened + * something. The store is a module singleton that drops its document when the + * canvas unmounts, while this hook's refs belong to the panel - so the two + * disagree whenever one outlives the other, and a hook that stood down on its + * own memory left the editor empty with no way back in. + */ +export function useAutoLoadFile( + load: Loader, + onFileChosen: OnFileChosen, + currentFileId: FileId | null, + /** Saving is swapping the workbench file under us; do not re-pick mid-swap. */ + hold: boolean, + /** The editor's live state, so "is a document open" is asked, not remembered. */ + editor: EditorLoadState, +): AutoLoad { + const navigationState = useNavigationState(); + const { selectedFiles } = useFileSelection(); + const { files: allFiles } = useAllFiles(); + const { activeFileId } = useViewer(); + + const autoLoadFile = useMemo(() => { + // Prefer the open document while it is still selected so a reordering + // selection cannot nudge the editor onto a different file. + if (currentFileId) { + const held = selectedFiles.find( + (f) => (f as WorkbenchFile).fileId === currentFileId, + ); + if (held) return held; + } + if (selectedFiles[0]) return selectedFiles[0]; + if (activeFileId) { + const viewerFile = allFiles.find( + (f) => (f as WorkbenchFile).fileId === activeFileId, + ); + if (viewerFile) return viewerFile; + } + if (allFiles.length === 1) return allFiles[0]; + return null; + }, [selectedFiles, activeFileId, allFiles, currentFileId]); + + // The open document left the workbench, so the editor is free to pick again. + const documentGone = + currentFileId != null && + !allFiles.some((f) => (f as WorkbenchFile).fileId === currentFileId); + + const lastKeyRef = useRef(null); + const adopt = useCallback((file: File) => { + lastKeyRef.current = fileKey(file); + }, []); + const openFile = useCallback( + (file: File) => { + adopt(file); + onFileChosen(file.name, (file as WorkbenchFile).fileId); + void load(file); + }, + [adopt, load, onFileChosen], + ); + + useEffect(() => { + if (!autoLoadFile || hold) return; + if (navigationState.selectedTool !== "pdfTextEditor") return; + // A document is open: leave it, and the user's unsaved edits, alone. + if (editor.hasDocument && !documentGone) return; + // An open is already in flight; landing it is what clears hasDocument. + if (editor.loading) return; + + // Recovery: the store dropped a document this hook had already opened. + // Re-open THAT file, and do it quietly - no pin, no filename change. The + // canvas can be dropped because the user went to Active Files, and pinning + // it back would yank them out of the list they just asked for. + const recovering = lastKeyRef.current !== null; + if (recovering) { + const same = allFiles.find( + (f) => (f as WorkbenchFile).fileId === currentFileId, + ); + // Nothing to recover to: the file left the workbench, so fall through + // and pick a candidate the normal way. + if (same) { + if (editor.error && lastKeyRef.current === fileKey(same)) return; + adopt(same); + void load(same); + return; + } + } + + // This exact file already failed to open. Retrying it is a loop, not a fix. + if (editor.error && lastKeyRef.current === fileKey(autoLoadFile)) return; + openFile(autoLoadFile); + }, [ + autoLoadFile, + documentGone, + editor.error, + editor.hasDocument, + editor.loading, + hold, + navigationState.selectedTool, + openFile, + adopt, + allFiles, + currentFileId, + load, + ]); + + return useMemo(() => ({ openFile, adopt }), [openFile, adopt]); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useDevicePixelRatio.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useDevicePixelRatio.ts new file mode 100644 index 0000000000..672f1b3f7d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useDevicePixelRatio.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; + +/** + * The display's current devicePixelRatio, live. A `(resolution: Xdppx)` media + * query matches exactly one ratio, so each change re-arms a fresh query - + * that is what keeps the value tracking when the window moves to a monitor + * with a different scale factor, or the user changes browser zoom. + */ +export function useDevicePixelRatio(): number { + const [dpr, setDpr] = useState(() => + typeof window === "undefined" ? 1 : window.devicePixelRatio || 1, + ); + + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + let query: MediaQueryList | null = null; + let disposed = false; + const arm = () => { + if (disposed) return; + const current = window.devicePixelRatio || 1; + setDpr(current); + query?.removeEventListener("change", arm); + query = window.matchMedia(`(resolution: ${current}dppx)`); + query.addEventListener("change", arm); + }; + arm(); + return () => { + disposed = true; + query?.removeEventListener("change", arm); + }; + }, []); + + return dpr; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useDocumentLoader.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useDocumentLoader.ts new file mode 100644 index 0000000000..5472cfb825 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useDocumentLoader.ts @@ -0,0 +1,179 @@ +import { useCallback } from "react"; +import { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { PdfiumTextReader } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextReader"; +import { + FPDF_ERR_PASSWORD, + PdfiumOpenError, +} from "@app/services/pdfiumService"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { PageSnapshot } from "@app/tools/pdfTextEditor/types"; + +const EAGER_PAGE_LIMIT = 5; + +/** Yield to the event loop so the React layer can paint progress. */ +const yieldToBrowser = () => + new Promise((resolve) => setTimeout(resolve, 0)); + +/** Open a PDF in PDFium and lazily populate pages on first visibility. */ +export function useDocumentLoader(store: EditorStore) { + return useCallback( + async (file: File, password?: string): Promise => { + // Each load claims a token. + const token = store.beginLoad(); + store.setLoading(true); + store.setProgress({ + stage: `Reading ${file.name}`, + current: 0, + total: 0, + }); + try { + await yieldToBrowser(); + const bytes = new Uint8Array(await file.arrayBuffer()); + if (!store.isCurrentLoad(token)) return; + store.setProgress({ + stage: "Parsing PDF", + current: 0, + total: 0, + }); + await yieldToBrowser(); + const doc = await EditorDocument.open(bytes, password); + if (!store.isCurrentLoad(token)) { + // A newer load superseded us before we installed our doc - free + // it ourselves (setDocument never took ownership). + try { + doc.dispose(); + } catch { + /* best-effort */ + } + return; + } + await store.setDocument(doc); + + const total = doc.pageCount; + const eager = Math.min(EAGER_PAGE_LIMIT, total); + const snapshots: PageSnapshot[] = []; + for (let i = 0; i < eager; i++) { + store.setProgress({ + stage: `Reading page ${i + 1} of ${total}`, + current: i, + total, + }); + await yieldToBrowser(); + // The check + synchronous read below run in one tick, so a + // superseding load can only interpose here. + if (!store.isCurrentLoad(token)) return; + const page = doc.page(i); + PdfiumTextReader.populate(doc, page, store.groupingMode); + snapshots.push({ + pageIndex: i, + width: page.width, + height: page.height, + dirty: false, + revision: page.revision, + runs: page.runs.map((r) => r.snapshot()), + images: page.images.map((img) => img.snapshot()), + annotations: page.annotations, + display: page.display.toData(), + }); + } + for (let i = eager; i < total; i++) { + const page = doc.page(i); + snapshots.push({ + pageIndex: i, + width: page.width, + height: page.height, + dirty: false, + revision: 0, + runs: [], + images: [], + display: page.display.toData(), + }); + } + if (!store.isCurrentLoad(token)) return; + store.publishPages(snapshots); + store.setProgress({ + stage: "Ready", + current: total, + total, + }); + } catch (err) { + if (store.isCurrentLoad(token)) { + // A password-protected PDF isn't a hard error. + if ( + err instanceof PdfiumOpenError && + err.code === FPDF_ERR_PASSWORD + ) { + store.setPasswordRequired(file, password !== undefined); + } else { + store.setError(err instanceof Error ? err.message : String(err)); + } + } + } finally { + // Only the winning load owns the loading/progress UI state. + if (store.isCurrentLoad(token)) { + store.setLoading(false); + store.setProgress(null); + } + } + }, + [store], + ); +} + +/** Read EVERY not-yet-loaded page in one pass and publish once. */ +export function ensureAllPagesRead(store: EditorStore): void { + const doc = store.document; + if (!doc) return; + let any = false; + for (const p of store.getState().pages) { + const page = doc.page(p.pageIndex); + if (page.loaded) continue; + try { + // Lazy reads must surface failures like the eager path, not throw out of the observer. + PdfiumTextReader.populate(doc, page, store.groupingMode); + any = true; + } catch (err) { + store.setError(err instanceof Error ? err.message : String(err)); + } + } + if (!any) return; + const next = store.getState().pages.map((p) => { + const page = doc.page(p.pageIndex); + return { + ...p, + revision: page.revision, + runs: page.runs.map((r) => r.snapshot()), + images: page.images.map((img) => img.snapshot()), + annotations: page.annotations, + }; + }); + store.publishPages(next); +} + +/** Ensure a page's runs/images are loaded. */ +export function ensurePageRead(store: EditorStore, pageIndex: number): void { + const doc = store.document; + if (!doc) return; + const page = doc.page(pageIndex); + if (page.loaded) return; + try { + // Lazy reads must surface failures like the eager path, not throw out of the observer. + PdfiumTextReader.populate(doc, page, store.groupingMode); + } catch (err) { + store.setError(err instanceof Error ? err.message : String(err)); + return; + } + const state = store.getState(); + const next = state.pages.map((p) => + p.pageIndex === pageIndex + ? { + ...p, + revision: page.revision, + runs: page.runs.map((r) => r.snapshot()), + images: page.images.map((img) => img.snapshot()), + annotations: page.annotations, + } + : p, + ); + store.publishPages(next); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorClipboard.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorClipboard.ts new file mode 100644 index 0000000000..7ecafbdc10 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorClipboard.ts @@ -0,0 +1,175 @@ +import { useEffect, useRef } from "react"; +import { isFocusInContentEditable } from "@app/tools/pdfTextEditor/util/dom"; + +export interface EditorClipboardCallbacks { + /** True when any run or image is selected (images cut without carrying text). */ + hasSelection: () => boolean; + /** Text of the selected runs, or null when the selection carries none. */ + getSelectedText: () => string | null; + deleteSelection: () => void; + insertPastedText: (text: string, stripFormatting: boolean) => void; +} + +const SINK_ID = "pdf-editor-clipboard-sink"; + +/** The off-screen textarea, created on first use. */ +function ensureSink(): HTMLTextAreaElement { + let sink = document.getElementById(SINK_ID) as HTMLTextAreaElement | null; + if (!sink) { + sink = document.createElement("textarea"); + sink.id = SINK_ID; + sink.tabIndex = -1; + sink.setAttribute("aria-hidden", "true"); + sink.style.cssText = + "position:fixed;top:0;left:-9999px;width:1px;height:1px;padding:0;border:0;opacity:0;"; + document.body.appendChild(sink); + } + return sink; +} + +function getSink(): HTMLTextAreaElement | null { + return document.getElementById(SINK_ID) as HTMLTextAreaElement | null; +} + +/** Object-level cut/copy/paste for the editor. */ +export function useEditorClipboard(cbs: EditorClipboardCallbacks) { + const ref = useRef(cbs); + ref.current = cbs; + + useEffect(() => { + // ClipboardEvent carries no modifier state, so Ctrl+Shift+V is remembered + // from the keystroke that triggered it. + let pastePlain = false; + // Set by the native `cut` of the sink - i.e. proof the browser actually + // took the text to the system clipboard. + let sinkCutObserved = false; + // Deferred cleanup for the in-flight clipboard keystroke. + let pendingRelease: (() => void) | null = null; + let pendingTimer: ReturnType | null = null; + + /** Finish the previous clipboard keystroke NOW. */ + function flushPending(): void { + if (pendingTimer !== null) clearTimeout(pendingTimer); + pendingTimer = null; + const run = pendingRelease; + pendingRelease = null; + run?.(); + } + + // Runs after the keystroke's default action, so the browser's own + // cut/copy/paste of the sink has already happened. + function scheduleRelease(fn: () => void): void { + pendingRelease = fn; + pendingTimer = setTimeout(() => { + pendingTimer = null; + pendingRelease = null; + fn(); + }, 0); + } + + /** Empty the sink and hand focus back to whatever had it. */ + function releaseSink(restoreTo: HTMLElement | null): void { + const sink = getSink(); + if (!sink) return; + sink.value = ""; + if (document.activeElement !== sink) return; + // blur() first: focus() on is a no-op, so without this the sink + // keeps focus and the next keystroke is treated as an in-run edit. + sink.blur(); + if (restoreTo && restoreTo !== document.body) { + restoreTo.focus?.({ preventScroll: true }); + } + } + + function onCut(e: ClipboardEvent): void { + if (e.target === getSink()) sinkCutObserved = true; + } + + function onPaste(e: ClipboardEvent) { + // Consume the modifier state captured by the keystroke that opened this + // paste, whoever ends up handling it. + const stripFormatting = pastePlain; + pastePlain = false; + const sink = getSink(); + // Our own sink IS an editable element, so the guard below would eat the + // very paste we set it up to receive. + const intoSink = + sink !== null && (e.target === sink || document.activeElement === sink); + // A caret inside a run (or in Find/Replace/password) keeps native paste. + if (!intoSink && isFocusInContentEditable()) return; + const text = e.clipboardData?.getData("text/plain"); + if (!text) return; + e.preventDefault(); + ref.current.insertPastedText(text, stripFormatting); + } + + function onKeyDown(e: KeyboardEvent) { + if (!e.ctrlKey && !e.metaKey) return; + const key = e.key.toLowerCase(); + if (key === "v") { + // Recorded before any bail, or Ctrl+Shift+V would leave the flag set + // for whatever pastes next. + pastePlain = e.shiftKey; + // A caret inside a run (or in Find/Replace/password) pastes natively + // into that field - don't pull focus out from under it. + if (isFocusInContentEditable()) return; + flushPending(); + const restoreTo = document.activeElement as HTMLElement | null; + const sink = ensureSink(); + sink.value = ""; + // Synchronous, and deliberately NOT preventDefault: the keystroke's own + // default action is the paste. + sink.focus({ preventScroll: true }); + scheduleRelease(() => { + // No paste arrived (empty clipboard, image-only, engine declined): + // drop the modifier state so it can't leak into the next paste. + pastePlain = false; + releaseSink(restoreTo); + }); + return; + } + if (key !== "c" && key !== "x") return; + // A caret inside a run (or in Find/Replace/password) keeps native + // copy/cut over its own text. + if (isFocusInContentEditable()) return; + if (!ref.current.hasSelection()) return; + flushPending(); + const text = ref.current.getSelectedText(); + const restoreTo = document.activeElement as HTMLElement | null; + sinkCutObserved = false; + // Deliberately NOT preventDefault: the browser's own copy/cut of the + // sink's selection is what reaches the system clipboard. + if (text !== null) { + const sink = ensureSink(); + sink.value = text; + sink.focus({ preventScroll: true }); + sink.select(); + } + scheduleRelease(() => { + // Read the evidence before releaseSink() wipes the sink. + const clipboardWritten = sinkCutObserved; + releaseSink(restoreTo); + window.getSelection()?.removeAllRanges(); + // Only destroy the selection once the text is safely on the clipboard. + if (key === "x" && (text === null || clipboardWritten)) { + ref.current.deleteSelection(); + } + }); + } + + window.addEventListener("cut", onCut); + window.addEventListener("paste", onPaste); + window.addEventListener("keydown", onKeyDown); + return () => { + // Drop the pending release rather than flushing it: a cut's + // deleteSelection() must not fire into a tree that is unmounting. + if (pendingTimer !== null) clearTimeout(pendingTimer); + pendingTimer = null; + pendingRelease = null; + window.removeEventListener("cut", onCut); + window.removeEventListener("paste", onPaste); + window.removeEventListener("keydown", onKeyDown); + document.getElementById(SINK_ID)?.remove(); + }; + }, []); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorKeyboardShortcuts.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorKeyboardShortcuts.ts new file mode 100644 index 0000000000..d88d93e407 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorKeyboardShortcuts.ts @@ -0,0 +1,185 @@ +import { useEffect } from "react"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import { + findVisiblePageIndex, + isFocusInContentEditable, + isFocusInFormField, + pageElements, +} from "@app/tools/pdfTextEditor/util/dom"; + +interface KeyboardShortcutCallbacks { + store: EditorStore; + onUndo: () => void; + onRedo: () => void; + onSave: () => void; + onDelete: () => void; + onDuplicate: () => void; + onSelectAll: () => void; + onToggleHelp: () => void; + onOpenFind: () => void; + onFindNext: (reverse: boolean) => void; + onEscape: () => void; + onMergeSelection: () => void; +} + +/** Bind every editor-level keyboard shortcut to `window` for the session. */ +export function useEditorKeyboardShortcuts(cbs: KeyboardShortcutCallbacks) { + const { + store, + onUndo, + onRedo, + onSave, + onDelete, + onDuplicate, + onSelectAll, + onToggleHelp, + onOpenFind, + onFindNext, + onEscape, + onMergeSelection, + } = cbs; + + useEffect(() => { + function onMetaKey(e: KeyboardEvent) { + const meta = e.ctrlKey || e.metaKey; + if (!meta) return; + // Normalise: with Shift or CapsLock the letter arrives UPPERCASE. + switch (e.key.toLowerCase()) { + case "z": + // Form fields (Find/Replace/password) keep their NATIVE undo. + if (isFocusInFormField()) return; + // Blur an active editable before history so the overlay sync + // effect can rewrite the DOM from the reverted model. + if (isFocusInContentEditable()) + (document.activeElement as HTMLElement | null)?.blur(); + if (e.shiftKey) { + e.preventDefault(); + onRedo(); + } else { + e.preventDefault(); + onUndo(); + } + return; + case "y": + if (isFocusInFormField()) return; + if (isFocusInContentEditable()) + (document.activeElement as HTMLElement | null)?.blur(); + e.preventDefault(); + onRedo(); + return; + case "s": + e.preventDefault(); + // Commit the in-progress edit first: blur bakes the pending + // text + wrap reflow, otherwise the download misses them. + if (isFocusInContentEditable()) + (document.activeElement as HTMLElement | null)?.blur(); + onSave(); + return; + case "d": + // No focus guard: duplicate must work while a run's editable is + // focused. + e.preventDefault(); + if (store.selection.value.runIds.length === 0) return; + onDuplicate(); + return; + case "a": + // Guard covers contenteditable AND Find/password inputs (dom.ts). + if (isFocusInContentEditable()) return; + e.preventDefault(); + onSelectAll(); + return; + // c / x / v are deliberately NOT handled here. + case "f": + e.preventDefault(); + onOpenFind(); + return; + case "g": + e.preventDefault(); + onFindNext(e.shiftKey); + return; + case "m": + if (store.selection.value.runIds.length < 2) return; + e.preventDefault(); + if (isFocusInContentEditable()) + (document.activeElement as HTMLElement | null)?.blur(); + onMergeSelection(); + return; + default: + return; + } + } + + function onPlainKey(e: KeyboardEvent) { + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (e.key === "?" || e.key === "F1") { + if (isFocusInContentEditable()) return; + e.preventDefault(); + onToggleHelp(); + return; + } + if (e.key === "F3") { + e.preventDefault(); + onFindNext(e.shiftKey); + return; + } + if (e.key === "Escape") { + if (isFocusInContentEditable()) return; + e.preventDefault(); + onEscape(); + return; + } + if (e.key === "Delete") { + if (isFocusInContentEditable()) return; + const sel = store.selection.value; + if (sel.runIds.length === 0 && sel.imageIds.length === 0) return; + e.preventDefault(); + onDelete(); + return; + } + } + + function onPageNav(e: KeyboardEvent) { + if (isFocusInContentEditable()) return; + const isHome = e.key === "Home" && (e.ctrlKey || e.metaKey); + const isEnd = e.key === "End" && (e.ctrlKey || e.metaKey); + if (e.key !== "PageDown" && e.key !== "PageUp" && !isHome && !isEnd) { + return; + } + if (store.getState().pageCount === 0) return; + const pages = pageElements(); + if (pages.length === 0) return; + const current = findVisiblePageIndex(); + let target = current; + if (e.key === "PageDown") + target = Math.min(pages.length - 1, current + 1); + else if (e.key === "PageUp") target = Math.max(0, current - 1); + else if (isHome) target = 0; + else if (isEnd) target = pages.length - 1; + if (target === current) return; + e.preventDefault(); + pages[target]?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + window.addEventListener("keydown", onMetaKey); + window.addEventListener("keydown", onPlainKey); + window.addEventListener("keydown", onPageNav); + return () => { + window.removeEventListener("keydown", onMetaKey); + window.removeEventListener("keydown", onPlainKey); + window.removeEventListener("keydown", onPageNav); + }; + }, [ + store, + onUndo, + onRedo, + onSave, + onDelete, + onDuplicate, + onSelectAll, + onToggleHelp, + onOpenFind, + onFindNext, + onEscape, + onMergeSelection, + ]); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorStore.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorStore.ts new file mode 100644 index 0000000000..ec847d9b2e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorStore.ts @@ -0,0 +1,59 @@ +import { useEffect, useMemo, useState } from "react"; +import { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; + +let __singleton: EditorStore | null = null; +let __disposeTimer: ReturnType | null = null; + +// The store is a module-level singleton, so a hot update to EditorStore.ts +// swaps the CLASS but leaves this instance - built from the old code - running. +// Every timing constant and method on it stays as it was, which makes a fix look +// like it changed nothing. Take the full reload instead. +if (import.meta.hot) { + import.meta.hot.accept(() => { + window.location.reload(); + }); +} + +/** Grace period before a fully-unmounted editor frees its PDFium document. */ +const DISPOSE_GRACE_MS = 1500; + +/** Returns the singleton editor store, plus the current view state. */ +export function useEditorStore(): { + store: EditorStore; + state: ReturnType; +} { + const store = useMemo(() => { + if (!__singleton) __singleton = new EditorStore(); + return __singleton; + }, []); + const [state, setState] = useState(store.getState()); + useEffect(() => { + // A pending disposal means we just remounted within the grace window + // (StrictMode / sidebar toggle) - cancel it so the open doc survives. + if (__disposeTimer) { + clearTimeout(__disposeTimer); + __disposeTimer = null; + } + setState(store.getState()); + const unsubscribe = store.subscribe(setState); + return () => { + unsubscribe(); + // Defer disposal: if the component remounts (the effect above runs again) + // the timer is cancelled. + if (__disposeTimer) clearTimeout(__disposeTimer); + __disposeTimer = setTimeout(() => { + __disposeTimer = null; + __singleton?.clearDocument(); + }, DISPOSE_GRACE_MS); + }; + }, [store]); + return { store, state }; +} + +/** Test-only - drop the singleton so the next mount starts fresh. */ +export function __resetEditorStoreForTests(): void { + if (__singleton) { + __singleton.dispose(); + __singleton = null; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorTestGlobal.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorTestGlobal.ts new file mode 100644 index 0000000000..c7b47783ab --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorTestGlobal.ts @@ -0,0 +1,14 @@ +import { useEffect } from "react"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; + +const KEY = "__editor_store"; + +/** Expose the editor store on `window` for Playwright. */ +export function useEditorTestGlobal(store: EditorStore): void { + useEffect(() => { + (window as unknown as Record)[KEY] = store; + return () => { + delete (window as unknown as Record)[KEY]; + }; + }, [store]); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionActions.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionActions.ts new file mode 100644 index 0000000000..a2f5da5e42 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionActions.ts @@ -0,0 +1,276 @@ +import { useCallback } from "react"; +import { DeleteImageCommand } from "@app/tools/pdfTextEditor/commands/DeleteImageCommand"; +import { ReplaceImageCommand } from "@app/tools/pdfTextEditor/commands/ReplaceImageCommand"; +import type { DecodedImage } from "@app/utils/pdfiumBitmapUtils"; +import { DeleteObjectCommand } from "@app/tools/pdfTextEditor/commands/DeleteObjectCommand"; +import { DuplicateRunCommand } from "@app/tools/pdfTextEditor/commands/DuplicateRunCommand"; +import { SetColourCommand } from "@app/tools/pdfTextEditor/commands/SetColourCommand"; +import { SetTextOutlineCommand } from "@app/tools/pdfTextEditor/commands/SetTextOutlineCommand"; +import { SetFontFamilyCommand } from "@app/tools/pdfTextEditor/commands/SetFontFamilyCommand"; +import { SetFontSizeCommand } from "@app/tools/pdfTextEditor/commands/SetFontSizeCommand"; +import { parseCssColor } from "@app/tools/pdfTextEditor/model/Color"; +import { ensureDeviceFontReady } from "@app/tools/pdfTextEditor/util/deviceFontEmbed"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import { isItalicFamily } from "@app/tools/pdfTextEditor/util/fontFamily"; +import { italicCapability } from "@app/tools/pdfTextEditor/util/fontCapability"; +import { loadedLocalFonts } from "@app/tools/pdfTextEditor/util/localFonts"; +import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand"; +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; + +/** The fields the selection actions read off a run they are about to change. */ +interface SelectedRun { + id: string; + pageIndex: number; + fontId: string; + fill: { r: number; g: number; b: number; a: number }; +} + +/** Bundle of callbacks that operate on the current selection. */ +export function useSelectionActions(store: EditorStore) { + const forEachSelectedRun = useCallback( + (visit: (run: SelectedRun) => void) => { + const sel = store.selection.value; + const doc = store.document; + if (!doc || sel.runIds.length === 0) return; + // Pre-index the selection for O(1) membership in the nested walk. + const selIds = new Set(sel.runIds); + for (const page of doc.loadedPages()) { + for (const run of page.runs) { + // Locked runs are selectable but must not mutate. + if (selIds.has(run.id) && !run.locked) visit(run); + } + } + }, + [store], + ); + + // One command per run, dispatched as ONE undo step - same reason + // `deleteSelection` groups its deletes. Select-all now reaches the whole + // document, so a per-run dispatch left the user hundreds of undos behind and + // the first Ctrl+Z looked like the restyle had only covered part of the file. + const dispatchPerRun = useCallback( + (build: (run: SelectedRun) => Command | null) => { + const cmds: Command[] = []; + forEachSelectedRun((run) => { + const cmd = build(run); + if (cmd) cmds.push(cmd); + }); + if (cmds.length === 1) store.dispatch(cmds[0]); + else if (cmds.length > 1) store.dispatch(new CompositeCommand(cmds)); + }, + [store, forEachSelectedRun], + ); + + const changeFontSize = useCallback( + (size: number) => { + dispatchPerRun( + (run) => + new SetFontSizeCommand({ + pageIndex: run.pageIndex, + runId: run.id, + nextSize: size, + }), + ); + }, + [dispatchPerRun], + ); + + const changeFill = useCallback( + (hex: string) => { + const fill = parseCssColor(hex); + if (!fill) return; + dispatchPerRun( + (run) => + new SetColourCommand({ + pageIndex: run.pageIndex, + runId: run.id, + // The picker edits RGB only; keep each run's OWN alpha so + // recolouring semi-transparent text doesn't force it opaque. + nextFill: { ...fill, a: run.fill.a }, + }), + ); + }, + [dispatchPerRun], + ); + + const changeOutline = useCallback( + (hex: string | null, width: number) => { + const stroke = hex ? parseCssColor(hex) : null; + dispatchPerRun( + (run) => + new SetTextOutlineCommand({ + pageIndex: run.pageIndex, + runId: run.id, + stroke: stroke ? { ...stroke, a: 255 } : null, + width, + }), + ); + }, + [dispatchPerRun], + ); + + const changeFontFamily = useCallback( + async (family: string) => { + // Embedding is async and Command.apply is not, so warm the bytes first. + // A no-op for the built-in families. + await ensureDeviceFontReady(family); + dispatchPerRun( + (run) => + new SetFontFamilyCommand({ + pageIndex: run.pageIndex, + runId: run.id, + nextFamily: family, + }), + ); + }, + [dispatchPerRun], + ); + + const toggleItalic = useCallback(async () => { + const fonts = loadedLocalFonts(); + const targets: Array<{ + pageIndex: number; + runId: string; + family: string; + device: boolean; + }> = []; + forEachSelectedRun((run) => { + const cap = italicCapability( + run.fontId, + !isItalicFamily(run.fontId), + fonts, + ); + // No real italic cut for this face. Leave it alone: swapping the + // document's own font for Helvetica-Oblique is not making it italic. + if (!cap.family) return; + targets.push({ + pageIndex: run.pageIndex, + runId: run.id, + family: cap.family, + device: cap.source === "device", + }); + }); + // Embedding is async and Command.apply is not, so warm the bytes first. + for (const target of targets) { + if (target.device) await ensureDeviceFontReady(target.family); + } + const cmds = targets.map( + (target) => + new SetFontFamilyCommand({ + pageIndex: target.pageIndex, + runId: target.runId, + nextFamily: target.family, + }), + ); + if (cmds.length === 1) store.dispatch(cmds[0]); + else if (cmds.length > 1) store.dispatch(new CompositeCommand(cmds)); + }, [store, forEachSelectedRun]); + + const deleteSelection = useCallback(() => { + const sel = store.selection.value; + const doc = store.document; + if (!doc) return; + if (sel.runIds.length === 0 && sel.imageIds.length === 0) return; + // Collect one command per object but dispatch them as ONE composite: a + // 30-object delete must be a single undo step, not 30. + const cmds: Array = []; + for (const page of doc.loadedPages()) { + for (const run of page.runs) { + if (sel.runIds.includes(run.id) && !run.locked) { + cmds.push( + new DeleteObjectCommand({ + pageIndex: run.pageIndex, + runId: run.id, + }), + ); + } + } + for (const img of page.images) { + if (sel.imageIds.includes(img.id) && !img.locked) { + cmds.push( + new DeleteImageCommand({ + pageIndex: img.pageIndex, + imageId: img.id, + }), + ); + } + } + } + if (cmds.length === 1) store.dispatch(cmds[0]); + else if (cmds.length > 1) store.dispatch(new CompositeCommand(cmds)); + store.selection.clear(); + }, [store]); + + const replaceImageById = useCallback( + ( + pageIndex: number, + imageId: string, + image: DecodedImage, + jpegBytes?: Uint8Array, + ) => { + const doc = store.document; + if (!doc) return; + // By id, not the live selection: an external edit can land long after + // the user selected something else, or opened another document. + const page = doc.loadedPages().find((p) => p.index === pageIndex); + const img = page?.images.find((i) => i.id === imageId); + if (!img || img.locked) return; + store.dispatch( + new ReplaceImageCommand({ + pageIndex: img.pageIndex, + imageId: img.id, + image, + jpegBytes, + }), + ); + }, + [store], + ); + + const replaceSelectedImage = useCallback( + (image: DecodedImage, jpegBytes?: Uint8Array) => { + const sel = store.selection.value; + if (sel.imageIds.length !== 1) return; + const doc = store.document; + const img = doc + ?.loadedPages() + .flatMap((p) => p.images) + .find((i) => i.id === sel.imageIds[0]); + if (!img) return; + replaceImageById(img.pageIndex, img.id, image, jpegBytes); + }, + [store, replaceImageById], + ); + + const duplicateFirstSelected = useCallback(() => { + const sel = store.selection.value; + if (sel.runIds.length === 0) return; + const doc = store.document; + if (!doc) return; + const targetId = sel.runIds[0]; + for (const page of doc.loadedPages()) { + for (const r of page.runs) { + if (r.id !== targetId) continue; + const cmd = new DuplicateRunCommand({ + pageIndex: r.pageIndex, + runId: targetId, + }); + store.dispatch(cmd); + if (cmd.insertedRunId) store.selection.selectOne(cmd.insertedRunId); + return; + } + } + }, [store]); + + return { + changeFontSize, + changeFill, + changeOutline, + changeFontFamily, + toggleItalic, + deleteSelection, + replaceSelectedImage, + replaceImageById, + duplicateFirstSelected, + }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionGeometry.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionGeometry.ts new file mode 100644 index 0000000000..3ecbadf038 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionGeometry.ts @@ -0,0 +1,110 @@ +import { useMemo } from "react"; +import { MoveTextRunCommand } from "@app/tools/pdfTextEditor/commands/MoveTextRunCommand"; +import { ReflowWrapCommand } from "@app/tools/pdfTextEditor/commands/ReflowWrapCommand"; +import { SetImageTransformCommand } from "@app/tools/pdfTextEditor/commands/SetImageTransformCommand"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { EditorViewState } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { PageRect, SelectionState } from "@app/tools/pdfTextEditor/types"; + +export interface SingleSelectionGeometry { + bounds: PageRect; + setX: (next: number) => void; + setY: (next: number) => void; + setWidth: (next: number) => void; + /** Absent for text runs: their height follows the type, not a handle. */ + setHeight?: (next: number) => void; +} + +export interface SelectionGeometry { + /** Null unless exactly one object is selected. */ + single: SingleSelectionGeometry | null; +} + +/** + * Numeric position/size for the inspector, in PDF points. + * + * Only meaningful for a single object - the fields would have to invent a + * value for a mixed selection, so the panel shows a hint instead. + */ +export function useSelectionGeometry( + store: EditorStore, + state: EditorViewState, + selection: SelectionState, +): SelectionGeometry { + return useMemo(() => { + const runId = selection.runIds[0]; + const imageId = selection.imageIds[0]; + const total = selection.runIds.length + selection.imageIds.length; + if (total !== 1) return { single: null }; + + if (runId) { + for (const page of state.pages) { + const run = page.runs.find((r) => r.id === runId); + if (!run) continue; + const pageIndex = page.pageIndex; + const bounds = run.bounds; + return { + single: { + bounds, + setX: (next) => + store.dispatch( + new MoveTextRunCommand({ + pageIndex, + runId, + dx: next - bounds.x, + dy: 0, + }), + ), + setY: (next) => + store.dispatch( + new MoveTextRunCommand({ + pageIndex, + runId, + dx: 0, + dy: next - bounds.y, + }), + ), + // Narrowing a run is exactly the wrap gesture, so it reuses the + // same command the canvas resize handle drives. + setWidth: (next) => + store.dispatch( + new ReflowWrapCommand({ + pageIndex, + runId, + maxWidthPt: Math.max(1, next), + }), + ), + }, + }; + } + return { single: null }; + } + + if (imageId) { + for (const page of state.pages) { + const img = page.images.find((i) => i.id === imageId); + if (!img) continue; + const pageIndex = page.pageIndex; + const bounds = img.bounds; + const set = (patch: Partial) => + store.dispatch( + new SetImageTransformCommand({ + pageIndex, + imageId, + nextBounds: { ...bounds, ...patch }, + }), + ); + return { + single: { + bounds, + setX: (next) => set({ x: next }), + setY: (next) => set({ y: next }), + setWidth: (next) => set({ width: Math.max(1, next) }), + setHeight: (next) => set({ height: Math.max(1, next) }), + }, + }; + } + } + return { single: null }; + }, [store, state.pages, selection]); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useToolbarController.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useToolbarController.ts new file mode 100644 index 0000000000..e32b0aa042 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useToolbarController.ts @@ -0,0 +1,529 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from "react"; +import { useSelectionActions } from "@app/tools/pdfTextEditor/hooks/useSelectionActions"; +import { deriveToolbarState } from "@app/tools/pdfTextEditor/util/toolbarState"; +import { warmDocumentDeviceFonts } from "@app/tools/pdfTextEditor/util/fontCapability"; +import { + loadedLocalFonts, + subscribeLocalFonts, +} from "@app/tools/pdfTextEditor/util/localFonts"; +import { + ChangeZOrderCommand, + type ZOrderMode, +} from "@app/tools/pdfTextEditor/commands/ChangeZOrderCommand"; +import { EditTextCommand } from "@app/tools/pdfTextEditor/commands/EditTextCommand"; +import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand"; +import { MoveTextRunCommand } from "@app/tools/pdfTextEditor/commands/MoveTextRunCommand"; +import { SetImageTransformCommand } from "@app/tools/pdfTextEditor/commands/SetImageTransformCommand"; +import { SetLockCommand } from "@app/tools/pdfTextEditor/commands/SetLockCommand"; +import { AlignParagraphLinesCommand } from "@app/tools/pdfTextEditor/commands/AlignParagraphLinesCommand"; +import { + TransformImageObjectCommand, + type ImageTransformMode, +} from "@app/tools/pdfTextEditor/commands/TransformImageObjectCommand"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { EditorViewState } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { SelectionState } from "@app/tools/pdfTextEditor/types"; +import { + isExternalImageEditSupported, + startExternalImageEdit, + type ExternalEditWatch, +} from "@app/tools/pdfTextEditor/util/externalImageEdit"; +import { + decodeBytesForEmbed, + decodeImageForEmbed, + pickImageFile, +} from "@app/tools/pdfTextEditor/util/imagePicking"; +import { readImageObjectPixels } from "@app/tools/pdfTextEditor/util/imagePixels"; + +type AlignMode = "left" | "center-h" | "right" | "top" | "middle-v" | "bottom"; + +// Everything the contextual `Toolbar` needs, derived from the shared +// `EditorStore`. +export function useToolbarController( + store: EditorStore, + state: EditorViewState, + selection: SelectionState, +) { + const sel = useSelectionActions(store); + + const onToggleLock = useCallback(() => { + const doc = store.document; + if (!doc) return; + const selRuns = new Set(store.selection.value.runIds); + const selImages = new Set(store.selection.value.imageIds); + if (selRuns.size === 0 && selImages.size === 0) return; + let allLocked = true; + for (const p of doc.loadedPages()) { + for (const r of p.runs) + if (selRuns.has(r.id) && !r.locked) allLocked = false; + for (const im of p.images) + if (selImages.has(im.id) && !im.locked) allLocked = false; + } + const nextLocked = !allLocked; + for (const p of doc.loadedPages()) { + for (const r of p.runs) + if (selRuns.has(r.id) && r.locked !== nextLocked) { + store.dispatch( + new SetLockCommand({ + pageIndex: p.index, + runId: r.id, + locked: nextLocked, + }), + ); + } + for (const im of p.images) + if (selImages.has(im.id) && im.locked !== nextLocked) { + store.dispatch( + new SetLockCommand({ + pageIndex: p.index, + imageId: im.id, + locked: nextLocked, + }), + ); + } + } + }, [store]); + + const onChangeZOrder = useCallback( + (mode: ZOrderMode) => { + const doc = store.document; + if (!doc) return; + const selRuns = new Set(store.selection.value.runIds); + const selImages = new Set(store.selection.value.imageIds); + if (selRuns.size === 0 && selImages.size === 0) return; + for (const p of doc.loadedPages()) { + for (const r of p.runs) { + if (!selRuns.has(r.id)) continue; + store.dispatch( + new ChangeZOrderCommand({ pageIndex: p.index, runId: r.id, mode }), + ); + } + for (const im of p.images) { + if (!selImages.has(im.id)) continue; + store.dispatch( + new ChangeZOrderCommand({ + pageIndex: p.index, + imageId: im.id, + mode, + }), + ); + } + } + }, + [store], + ); + + const onAlign = useCallback( + (mode: AlignMode) => { + const doc = store.document; + if (!doc) return; + const selRuns = new Set(store.selection.value.runIds); + const selImages = new Set(store.selection.value.imageIds); + // Single multi-line paragraph + a horizontal mode: align the lines + // WITHIN that paragraph instead of requiring a 2+ object selection. + if ( + selRuns.size === 1 && + selImages.size === 0 && + (mode === "left" || mode === "center-h" || mode === "right") + ) { + const runId = [...selRuns][0]; + for (const p of doc.loadedPages()) { + const run = p.runs.find((r) => r.id === runId); + if (!run) continue; + if (AlignParagraphLinesCommand.canAlign(run)) { + store.dispatch( + new AlignParagraphLinesCommand({ + pageIndex: p.index, + runId, + mode, + }), + ); + } + return; + } + } + if (selRuns.size + selImages.size < 2) return; + // One gesture must be one undo step, not one per object. + const moves: Array = []; + for (const p of doc.loadedPages()) { + const items: Array<{ + kind: "run" | "image"; + id: string; + bounds: { x: number; y: number; width: number; height: number }; + }> = []; + // Locked objects stay selectable but must never be moved, the + // same rule every other bulk path applies. + for (const r of p.runs) { + if (!selRuns.has(r.id) || r.locked) continue; + items.push({ kind: "run", id: r.id, bounds: r.bounds }); + } + for (const im of p.images) { + if (!selImages.has(im.id) || im.locked) continue; + items.push({ kind: "image", id: im.id, bounds: im.bounds }); + } + if (items.length < 2) continue; + const lefts = items.map((it) => it.bounds.x); + const rights = items.map((it) => it.bounds.x + it.bounds.width); + const bottoms = items.map((it) => it.bounds.y); + const tops = items.map((it) => it.bounds.y + it.bounds.height); + const minLeft = Math.min(...lefts); + const maxRight = Math.max(...rights); + const minBottom = Math.min(...bottoms); + const maxTop = Math.max(...tops); + const centreX = (minLeft + maxRight) / 2; + const centreY = (minBottom + maxTop) / 2; + for (const it of items) { + const b = it.bounds; + let dx = 0; + let dy = 0; + switch (mode) { + case "left": + dx = minLeft - b.x; + break; + case "right": + dx = maxRight - (b.x + b.width); + break; + case "center-h": + dx = centreX - (b.x + b.width / 2); + break; + case "bottom": + dy = minBottom - b.y; + break; + case "top": + dy = maxTop - (b.y + b.height); + break; + case "middle-v": + dy = centreY - (b.y + b.height / 2); + break; + } + if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) continue; + if (it.kind === "run") { + moves.push( + new MoveTextRunCommand({ + pageIndex: p.index, + runId: it.id, + dx, + dy, + }), + ); + } else { + moves.push( + new SetImageTransformCommand({ + pageIndex: p.index, + imageId: it.id, + nextBounds: { + x: b.x + dx, + y: b.y + dy, + width: b.width, + height: b.height, + }, + }), + ); + } + } + } + if (moves.length === 1) store.dispatch(moves[0]); + else if (moves.length > 1) store.dispatch(new CompositeCommand(moves)); + }, + [store], + ); + + const onDistribute = useCallback( + (axis: "horizontal" | "vertical") => { + const doc = store.document; + if (!doc) return; + const selRuns = new Set(store.selection.value.runIds); + const selImages = new Set(store.selection.value.imageIds); + if (selRuns.size + selImages.size < 3) return; + // One gesture must be one undo step, not one per object. + const moves: Array = []; + for (const p of doc.loadedPages()) { + const items: Array<{ + kind: "run" | "image"; + id: string; + bounds: { x: number; y: number; width: number; height: number }; + }> = []; + // Locked objects stay selectable but must never be moved, the + // same rule every other bulk path applies. + for (const r of p.runs) { + if (!selRuns.has(r.id) || r.locked) continue; + items.push({ kind: "run", id: r.id, bounds: r.bounds }); + } + for (const im of p.images) { + if (!selImages.has(im.id) || im.locked) continue; + items.push({ kind: "image", id: im.id, bounds: im.bounds }); + } + if (items.length < 3) continue; + items.sort((a, b) => + axis === "horizontal" + ? a.bounds.x - b.bounds.x + : a.bounds.y - b.bounds.y, + ); + const first = items[0].bounds; + const last = items[items.length - 1].bounds; + const totalSize = + axis === "horizontal" + ? last.x + last.width - first.x + : last.y + last.height - first.y; + const sumSize = items.reduce( + (acc, it) => + acc + (axis === "horizontal" ? it.bounds.width : it.bounds.height), + 0, + ); + const gap = (totalSize - sumSize) / (items.length - 1); + let cursor = + axis === "horizontal" + ? first.x + first.width + gap + : first.y + first.height + gap; + for (let i = 1; i < items.length - 1; i++) { + const it = items[i]; + const b = it.bounds; + let dx = 0; + let dy = 0; + if (axis === "horizontal") { + dx = cursor - b.x; + cursor += b.width + gap; + } else { + dy = cursor - b.y; + cursor += b.height + gap; + } + if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) continue; + if (it.kind === "run") { + moves.push( + new MoveTextRunCommand({ + pageIndex: p.index, + runId: it.id, + dx, + dy, + }), + ); + } else { + moves.push( + new SetImageTransformCommand({ + pageIndex: p.index, + imageId: it.id, + nextBounds: { + x: b.x + dx, + y: b.y + dy, + width: b.width, + height: b.height, + }, + }), + ); + } + } + } + if (moves.length === 1) store.dispatch(moves[0]); + else if (moves.length > 1) store.dispatch(new CompositeCommand(moves)); + }, + [store], + ); + + const onTransformImage = useCallback( + (mode: ImageTransformMode) => { + const doc = store.document; + if (!doc) return; + const selImages = new Set(store.selection.value.imageIds); + if (selImages.size === 0) return; + for (const p of doc.loadedPages()) { + for (const im of p.images) { + if (!selImages.has(im.id)) continue; + store.dispatch( + new TransformImageObjectCommand({ + pageIndex: p.index, + imageId: im.id, + mode, + }), + ); + } + } + }, + [store], + ); + + const onChangeCase = useCallback( + (mode: "upper" | "lower" | "title" | "sentence") => { + const doc = store.document; + if (!doc) return; + const selIds = new Set(store.selection.value.runIds); + if (selIds.size === 0) return; + const transform = (s: string): string => { + switch (mode) { + case "upper": + return s.toUpperCase(); + case "lower": + return s.toLowerCase(); + case "title": + // \p{L}/u instead of \b\w: ASCII word chars mis-cased accented + // and non-Latin letters ("elan" with acute became "eLan"). + return s.replace( + /(^|[^\p{L}\p{N}'])([\p{L}\p{N}][\p{L}\p{N}']*)/gu, + (_m, sep: string, w: string) => + sep + w[0].toUpperCase() + w.slice(1).toLowerCase(), + ); + case "sentence": + return s.replace(/(^\s*\p{L}|[.!?]\s+\p{L})/gu, (m) => + m.toUpperCase(), + ); + } + }; + // One composite = one undo step for the whole selection, and locked + // runs are exempt like every other bulk mutation. + const cmds: EditTextCommand[] = []; + for (const p of doc.loadedPages()) { + for (const r of p.runs) { + if (!selIds.has(r.id) || r.locked) continue; + const next = transform(r.text); + if (next === r.text) continue; + cmds.push( + new EditTextCommand({ + pageIndex: p.index, + runId: r.id, + nextText: next, + }), + ); + } + } + if (cmds.length === 1) store.dispatch(cmds[0]); + else if (cmds.length > 1) store.dispatch(new CompositeCommand(cmds)); + }, + [store], + ); + + // Null until the user loads their device fonts, and it must re-render when + // they do - the italic control's availability is derived from it. + const localFonts = useSyncExternalStore( + subscribeLocalFonts, + loadedLocalFonts, + loadedLocalFonts, + ); + + const documentFontIds = useMemo( + () => [...new Set(state.pages.flatMap((p) => p.runs.map((r) => r.fontId)))], + [state.pages], + ); + + // Match the document's own families against the installed ones, so an edit + // that outgrows an embedded subset completes from the real face. + useEffect(() => { + if (!localFonts) return; + void warmDocumentDeviceFonts(documentFontIds); + }, [localFonts, documentFontIds]); + + const toolbarState = useMemo( + () => deriveToolbarState(state.pages, selection, localFonts), + [state.pages, selection, localFonts], + ); + + const selectionAllLocked = useMemo(() => { + const runs = new Set(selection.runIds); + const images = new Set(selection.imageIds); + if (runs.size === 0 && images.size === 0) return false; + for (const p of state.pages) { + for (const r of p.runs) if (runs.has(r.id) && !r.locked) return false; + for (const im of p.images) + if (images.has(im.id) && !im.locked) return false; + } + return true; + }, [state.pages, selection]); + + const canAlignLines = useMemo(() => { + if (selection.runIds.length !== 1 || selection.imageIds.length > 0) + return false; + const run = state.pages + .flatMap((p) => p.runs) + .find((r) => r.id === selection.runIds[0]); + // Mirrors AlignParagraphLinesCommand.canAlign, which gates on SLOTS: line + // count enabled the item for paragraphs the command then refused. + return !!run && (run.paragraphSlotCount ?? 0) >= 2; + }, [state.pages, selection]); + + const onReplaceImage = useCallback(() => { + void (async () => { + const file = await pickImageFile(); + if (!file) return; + try { + const picked = await decodeImageForEmbed(file); + sel.replaceSelectedImage(picked.decoded, picked.jpegBytes); + } catch (err) { + store.setError(err instanceof Error ? err.message : String(err)); + } + })(); + }, [sel, store]); + + const watchRef = useRef(null); + useEffect(() => () => watchRef.current?.stop(), []); + + const onEditImageExternally = useCallback(() => { + void (async () => { + const doc = store.document; + const imageId = selection.imageIds[0]; + if (!doc || !imageId) return; + const target = doc + .loadedPages() + .flatMap((page) => page.images) + .find((img) => img.id === imageId); + if (!target?.pdfiumObjPtr) return; + const pixels = readImageObjectPixels( + doc, + target.pageIndex, + target.pdfiumObjPtr, + ); + if (!pixels) return; + // Only one image can be watched at a time; starting a second replaces + // the first rather than leaving two pollers racing over the document. + watchRef.current?.stop(); + const outcome = await startExternalImageEdit({ + pixels, + suggestedName: "pdf-image.png", + onChange: (bytes) => { + void decodeBytesForEmbed(bytes) + .then((decoded) => + sel.replaceImageById(target.pageIndex, imageId, decoded), + ) + .catch(() => undefined); + }, + }); + if (outcome.status === "watching") watchRef.current = outcome.watch; + })(); + }, [sel, selection.imageIds, store]); + + return { + state: toolbarState, + canUndo: store.history.canUndo, + canRedo: store.history.canRedo, + onUndo: () => store.undo(), + onRedo: () => store.redo(), + onChangeFontSize: sel.changeFontSize, + onChangeFill: sel.changeFill, + onChangeOutline: sel.changeOutline, + onChangeFontFamily: (family: string) => { + void sel.changeFontFamily(family); + }, + onToggleItalic: sel.toggleItalic, + onDelete: sel.deleteSelection, + onToggleLock, + onChangeCase, + onChangeZOrder, + onAlign, + onDistribute, + onTransformImage, + onReplaceImage, + onEditImageExternally, + externalEditSupported: isExternalImageEditSupported(), + selectionAllLocked, + hasRunSelection: selection.runIds.length > 0, + hasImageSelection: selection.imageIds.length > 0, + selectionCount: selection.runIds.length + selection.imageIds.length, + canAlignLines, + disabled: + !state.hasDocument || + (selection.runIds.length === 0 && selection.imageIds.length === 0), + }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useUnsavedChangesGuard.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useUnsavedChangesGuard.ts new file mode 100644 index 0000000000..be3d80fdbd --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useUnsavedChangesGuard.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; +import { useNavigationActions } from "@app/contexts/NavigationContext"; + +// Guard unsaved edits on BOTH exit routes. +// +// `beforeunload` only covers a full-page unload (tab close / reload / external +// navigation). Switching tools inside the SPA never triggers it, so on its own +// this hook let the editor drop every edit silently. NavigationContext is the +// app's own in-app guard - it is what PageEditor uses - and it drives +// NavigationWarningModal. +export function useUnsavedChangesGuard(dirty: boolean): void { + const { actions } = useNavigationActions(); + const setHasUnsavedChanges = actions.setHasUnsavedChanges; + + useEffect(() => { + if (!dirty) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [dirty]); + + useEffect(() => { + setHasUnsavedChanges(dirty); + // Clear on unmount so a stale flag cannot block navigation after the + // editor is gone. + return () => setHasUnsavedChanges(false); + }, [dirty, setHasUnsavedChanges]); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useWorkbenchPin.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useWorkbenchPin.ts new file mode 100644 index 0000000000..e621c67581 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useWorkbenchPin.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useRef } from "react"; +import { + useNavigationActions, + useNavigationState, +} from "@app/contexts/NavigationContext"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import type { CustomWorkbenchViewRegistration } from "@app/contexts/ToolWorkflowContext"; + +interface PinOptions { + workbenchId: CustomWorkbenchViewRegistration["workbenchId"]; + workbenchViewId: string; + label: string; + icon: React.ReactNode; + component: CustomWorkbenchViewRegistration["component"]; +} + +// Register the custom workbench view and open it when the editor tool is +// selected. Returns a `pin` that brings the canvas back on demand. +export function useWorkbenchPin({ + workbenchId, + workbenchViewId, + label, + icon, + component, +}: PinOptions): () => void { + const { + registerCustomWorkbenchView, + unregisterCustomWorkbenchView, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + setLeftPanelView, + } = useToolWorkflow(); + const { actions: navigationActions } = useNavigationActions(); + const navigationState = useNavigationState(); + + // Stash the per-render values that aren't dependable identities so the effect + // can read them on mount without re-running on every parent render. + const viewRef = useRef({ + workbenchId, + workbenchViewId, + label, + icon, + component, + }); + viewRef.current = { workbenchId, workbenchViewId, label, icon, component }; + useEffect(() => { + const v = viewRef.current; + registerCustomWorkbenchView({ + id: v.workbenchViewId, + workbenchId: v.workbenchId, + label: v.label, + icon: v.icon, + component: v.component, + }); + setCustomWorkbenchViewData(v.workbenchViewId, { kind: "pdfTextEditor" }); + setLeftPanelView("toolContent"); + return () => { + clearCustomWorkbenchViewData(v.workbenchViewId); + unregisterCustomWorkbenchView(v.workbenchViewId); + }; + }, [ + registerCustomWorkbenchView, + unregisterCustomWorkbenchView, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + setLeftPanelView, + ]); + + const actionsRef = useRef(navigationActions); + actionsRef.current = navigationActions; + + const pin = useCallback(() => { + actionsRef.current.setWorkbench(workbenchId); + }, [workbenchId]); + + // Open the canvas once, when the tool is picked. Re-pinning on every + // workbench change would bounce the user straight back here the moment they + // switch to Active Files to choose a different file. + const pinnedRef = useRef(false); + useEffect(() => { + if (navigationState.selectedTool !== "pdfTextEditor") { + pinnedRef.current = false; + return; + } + if (pinnedRef.current) return; + pinnedRef.current = true; + if (navigationState.workbench === workbenchId) return; + actionsRef.current.setWorkbench(workbenchId); + }, [navigationState.selectedTool, navigationState.workbench, workbenchId]); + + return pin; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/AnnotationBox.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/AnnotationBox.ts new file mode 100644 index 0000000000..6c164854b8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/AnnotationBox.ts @@ -0,0 +1,22 @@ +/** Text-carrying annotation that the editor renders but cannot edit. */ + +/** PDFium FPDF_ANNOTATION_SUBTYPE values the editor cares about. */ +export const ANNOT_SUBTYPE_FREETEXT = 3; +export const ANNOT_SUBTYPE_STAMP = 13; +export const ANNOT_SUBTYPE_WIDGET = 20; + +export type AnnotationKind = "freetext" | "widget" | "stamp"; + +export interface AnnotationBox { + id: string; + kind: AnnotationKind; + /** Raw PDF page-space rect (y-up), pre-DisplayTransform. */ + rect: { x: number; y: number; width: number; height: number }; +} + +export function annotationKindFor(subtype: number): AnnotationKind | null { + if (subtype === ANNOT_SUBTYPE_FREETEXT) return "freetext"; + if (subtype === ANNOT_SUBTYPE_WIDGET) return "widget"; + if (subtype === ANNOT_SUBTYPE_STAMP) return "stamp"; + return null; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/Color.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/Color.ts new file mode 100644 index 0000000000..b11c1dcf55 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/Color.ts @@ -0,0 +1,46 @@ +import type { RGBA } from "@app/tools/pdfTextEditor/types"; + +export const BLACK: RGBA = { r: 0, g: 0, b: 0, a: 255 }; +export const WHITE: RGBA = { r: 255, g: 255, b: 255, a: 255 }; + +/** Parse a `#rrggbb`, `#rrggbbaa`, or `rgb(...)` string. Returns null on failure. */ +export function parseCssColor(value: string): RGBA | null { + const trimmed = value.trim(); + if (trimmed.startsWith("#")) { + const hex = trimmed.slice(1); + if (hex.length === 6 || hex.length === 8) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255; + if ([r, g, b, a].every((c) => Number.isFinite(c))) { + return { r, g, b, a }; + } + } + return null; + } + const m = trimmed.match( + /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*(\d*\.?\d+))?\s*\)$/i, + ); + if (m) { + const r = Number(m[1]); + const g = Number(m[2]); + const b = Number(m[3]); + const a = m[4] === undefined ? 255 : Math.round(Number(m[4]) * 255); + return { r, g, b, a }; + } + return null; +} + +/** Format an RGBA as `#rrggbb` (ignoring alpha). */ +export function toCssHex(color: RGBA): string { + const hex = (n: number) => + Math.max(0, Math.min(255, Math.round(n))) + .toString(16) + .padStart(2, "0"); + return `#${hex(color.r)}${hex(color.g)}${hex(color.b)}`; +} + +export function equalsRGBA(a: RGBA, b: RGBA): boolean { + return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/DisplayTransform.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/DisplayTransform.ts new file mode 100644 index 0000000000..1435cb0b12 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/DisplayTransform.ts @@ -0,0 +1,357 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; + +/** Maps a page's raw PDF object coordinates to "display-PDF" space. */ +export interface DisplayTransformData { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; + cropLeft: number; + cropBottom: number; + cropWidth: number; + cropHeight: number; + /** PDFium rotation quarter-turns clockwise: 0|1|2|3 (= 0/90/180/270 deg). */ + rotate: number; + /** Displayed page size in PDF points (rotation-applied; == page width/height). */ + displayWidth: number; + displayHeight: number; +} + +type BoxReader = ( + page: number, + left: number, + bottom: number, + right: number, + top: number, +) => number | boolean; + +interface CropBoxModule { + FPDFPage_GetCropBox?: BoxReader; + FPDFPage_GetMediaBox?: BoxReader; + FPDF_GetPageBoundingBox?: (page: number, rect: number) => number | boolean; + FPDFPage_GetRotation?: (page: number) => number; +} + +interface PageBox { + left: number; + bottom: number; + right: number; + top: number; +} + +export class DisplayTransform implements DisplayTransformData { + readonly a: number; + readonly b: number; + readonly c: number; + readonly d: number; + readonly e: number; + readonly f: number; + readonly cropLeft: number; + readonly cropBottom: number; + readonly cropWidth: number; + readonly cropHeight: number; + readonly rotate: number; + readonly displayWidth: number; + readonly displayHeight: number; + readonly isIdentity: boolean; + + constructor(d: DisplayTransformData) { + // Normalise -0 to 0 so identity coefficients compare cleanly (-0 === 0 is + // true, but Object.is / toEqual distinguish them). + const nz = (x: number): number => (x === 0 ? 0 : x); + this.a = nz(d.a); + this.b = nz(d.b); + this.c = nz(d.c); + this.d = nz(d.d); + this.e = nz(d.e); + this.f = nz(d.f); + this.cropLeft = d.cropLeft; + this.cropBottom = d.cropBottom; + this.cropWidth = d.cropWidth; + this.cropHeight = d.cropHeight; + this.rotate = d.rotate; + this.displayWidth = d.displayWidth; + this.displayHeight = d.displayHeight; + this.isIdentity = + this.a === 1 && + this.b === 0 && + this.c === 0 && + this.d === 1 && + this.e === 0 && + this.f === 0; + } + + /** Identity for a page of the given display size (CropBox==MediaBox, no rotate). */ + static identity( + displayWidth: number, + displayHeight: number, + ): DisplayTransform { + const dw = Number.isFinite(displayWidth) ? displayWidth : 0; + const dh = Number.isFinite(displayHeight) ? displayHeight : 0; + return new DisplayTransform({ + a: 1, + b: 0, + c: 0, + d: 1, + e: 0, + f: 0, + cropLeft: 0, + cropBottom: 0, + cropWidth: dw, + cropHeight: dh, + rotate: 0, + displayWidth: dw, + displayHeight: dh, + }); + } + + /** Reconstruct from the serializable plain-data shape (e.g. a PageSnapshot). */ + static fromData(d: DisplayTransformData): DisplayTransform { + return new DisplayTransform(d); + } + + toData(): DisplayTransformData { + return { + a: this.a, + b: this.b, + c: this.c, + d: this.d, + e: this.e, + f: this.f, + cropLeft: this.cropLeft, + cropBottom: this.cropBottom, + cropWidth: this.cropWidth, + cropHeight: this.cropHeight, + rotate: this.rotate, + displayWidth: this.displayWidth, + displayHeight: this.displayHeight, + }; + } + + /** Raw PDF point -> display-PDF point (y-up). */ + apply(px: number, py: number): { x: number; y: number } { + return { + x: this.a * px + this.c * py + this.e, + y: this.b * px + this.d * py + this.f, + }; + } + + /** Display-PDF point -> raw PDF point (inverse of apply). */ + invert(xd: number, yd: number): { x: number; y: number } { + const det = this.a * this.d - this.b * this.c; + if (det === 0) return { x: xd, y: yd }; + const ia = this.d / det; + const ib = -this.b / det; + const ic = -this.c / det; + const id = this.a / det; + const ie = -(ia * this.e + ic * this.f); + const iff = -(ib * this.e + id * this.f); + return { x: ia * xd + ic * yd + ie, y: ib * xd + id * yd + iff }; + } + + /** Raw direction vector -> display direction (linear part only, no translation). */ + applyVector(vx: number, vy: number): { x: number; y: number } { + return { x: this.a * vx + this.c * vy, y: this.b * vx + this.d * vy }; + } + + /** Display direction vector -> raw direction (inverse linear part only). */ + invertVector(vx: number, vy: number): { x: number; y: number } { + const det = this.a * this.d - this.b * this.c; + if (det === 0) return { x: vx, y: vy }; + const ia = this.d / det; + const ib = -this.b / det; + const ic = -this.c / det; + const id = this.a / det; + return { x: ia * vx + ic * vy, y: ib * vx + id * vy }; + } + + // Build the transform for a page by reading its CropBox + rotation from + // PDFium. + static fromPage( + m: WrappedPdfiumModule, + pagePtr: number, + displayWidth: number, + displayHeight: number, + ): DisplayTransform { + const mod = m as unknown as CropBoxModule; + const box = readBox(m, mod, pagePtr); + if (!box) return DisplayTransform.identity(displayWidth, displayHeight); + const rotate = callSafely( + () => (mod.FPDFPage_GetRotation?.(pagePtr) ?? 0) & 3, + 0, + ); + return DisplayTransform.fromCropAndRotate( + box.left, + box.bottom, + box.right - box.left, + box.top - box.bottom, + rotate, + displayWidth, + displayHeight, + ); + } + + // Pure constructor from CropBox extents + rotation (exposed for tests). + // `rotate` is quarter-turns clockwise (0..3). + static fromCropAndRotate( + cl: number, + cb: number, + cw: number, + ch: number, + rotate: number, + displayWidth: number, + displayHeight: number, + ): DisplayTransform { + const box = normaliseBox(cl, cb, cl + cw, cb + ch); + if (!box) return DisplayTransform.identity(displayWidth, displayHeight); + const left = box.left; + const bottom = box.bottom; + const width = box.right - box.left; + const height = box.top - box.bottom; + let a = 1, + b = 0, + c = 0, + d = 1, + e = -left, + f = -bottom; + switch (rotate & 3) { + case 0: + a = 1; + b = 0; + c = 0; + d = 1; + e = -left; + f = -bottom; + break; + case 1: // 90 CW - proper rotation (det +1), verified vs PDFium ground truth + a = 0; + b = -1; + c = 1; + d = 0; + e = -bottom; + f = width + left; + break; + case 2: // 180 + a = -1; + b = 0; + c = 0; + d = -1; + e = width + left; + f = height + bottom; + break; + case 3: // 270 CW - proper rotation (det +1), verified vs PDFium ground truth + a = 0; + b = 1; + c = -1; + d = 0; + e = height + bottom; + f = -left; + break; + } + return new DisplayTransform({ + a, + b, + c, + d, + e, + f, + cropLeft: left, + cropBottom: bottom, + cropWidth: width, + cropHeight: height, + rotate: rotate & 3, + displayWidth, + displayHeight, + }); + } +} + +function callSafely(run: () => T, fallback: T): T { + try { + return run(); + } catch { + return fallback; + } +} + +function boxOrNull( + left: number, + bottom: number, + right: number, + top: number, +): PageBox | null { + if ( + !Number.isFinite(left) || + !Number.isFinite(bottom) || + !Number.isFinite(right) || + !Number.isFinite(top) + ) { + return null; + } + if (right - left <= 0 || top - bottom <= 0) return null; + return { left, bottom, right, top }; +} + +function normaliseBox( + left: number, + bottom: number, + right: number, + top: number, +): PageBox | null { + return boxOrNull( + Math.min(left, right), + Math.min(bottom, top), + Math.max(left, right), + Math.max(bottom, top), + ); +} + +function intersectBoxes(a: PageBox, b: PageBox): PageBox | null { + return boxOrNull( + Math.max(a.left, b.left), + Math.max(a.bottom, b.bottom), + Math.min(a.right, b.right), + Math.min(a.top, b.top), + ); +} + +function readBox( + m: WrappedPdfiumModule, + mod: CropBoxModule, + pagePtr: number, +): PageBox | null { + const exports = m.pdfium.wasmExports as unknown as { + malloc: (n: number) => number; + free: (p: number) => void; + }; + const buf = exports.malloc(16); + if (!buf) return null; + try { + const slot = (i: number): number => m.pdfium.getValue(buf + i * 4, "float"); + const bounding = mod.FPDF_GetPageBoundingBox; + if (bounding) { + const ok = callSafely(() => !!bounding(pagePtr, buf), false); + const effective = ok + ? normaliseBox(slot(0), slot(3), slot(2), slot(1)) + : null; + if (effective) return effective; + } + const readRect = (fn?: BoxReader): PageBox | null => { + if (!fn) return null; + const ok = callSafely( + () => !!fn(pagePtr, buf, buf + 4, buf + 8, buf + 12), + false, + ); + if (!ok) return null; + return normaliseBox(slot(0), slot(1), slot(2), slot(3)); + }; + const crop = readRect(mod.FPDFPage_GetCropBox); + const media = readRect(mod.FPDFPage_GetMediaBox); + if (crop && media) return intersectBoxes(crop, media) ?? media; + return crop ?? media; + } finally { + exports.free(buf); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/EditorDocument.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/EditorDocument.ts new file mode 100644 index 0000000000..240e2dd7b8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/EditorDocument.ts @@ -0,0 +1,188 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { + closeDocAndFreeBuffer, + getPdfiumModule, + openRawDocument, +} from "@app/services/pdfiumService"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; +import { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import { FontRef } from "@app/tools/pdfTextEditor/model/FontRef"; +import { prepareForEditing } from "@app/tools/pdfTextEditor/pdfdoc/prepareForEditing"; + +// Lifetime-managed PDFium document wrapper for the PDF text editor. - Opens a +// raw PDFium document pointer from bytes. +// Above this the save-time repairs are skipped rather than keeping a second +// full copy of the file alive for the session. +const MAX_RETAINED_BYTES = 64 * 1024 * 1024; +const EMPTY = new Uint8Array(0); + +export class EditorDocument { + readonly module: WrappedPdfiumModule; + readonly docPtr: number; + /** Exactly the bytes PDFium was handed: the save-time repairs re-read them. */ + /** Empty when the file was too large to keep a second copy of. */ + readonly openedBytes: Uint8Array; + private readonly pageCache: Map; + private readonly ownedFonts: Map; + private _disposed: boolean; + // Form-fill environment. Widgets with no appearance stream are drawn ONLY by + // this layer, so without it such fields are invisible in the editor while + // being visible everywhere else in the app. Created lazily and left null when + // the build lacks the entry points. + private formEnvPtr: number | null = null; + private formEnvTried = false; + private readonly formLoadedPages = new Set(); + + private constructor( + module: WrappedPdfiumModule, + docPtr: number, + openedBytes: Uint8Array, + ) { + this.module = module; + this.docPtr = docPtr; + this.openedBytes = openedBytes; + this.pageCache = new Map(); + this.ownedFonts = new Map(); + this._disposed = false; + } + + static async open( + data: ArrayBuffer | Uint8Array, + password?: string, + ): Promise { + const module = await getPdfiumModule(); + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + const prepared = await prepareForEditing(bytes); + const docPtr = await openRawDocument(prepared, password); + // PDFium already holds its own heap copy, so retaining these doubles the + // footprint; past a point the gradient repair is not worth that. + const keep = prepared.length <= MAX_RETAINED_BYTES ? prepared : EMPTY; + return new EditorDocument(module, docPtr, keep); + } + + /** Page indices whose content stream has been regenerated this session. */ + regeneratedPages(): number[] { + return this.loadedPages() + .filter((p) => p.regenerated) + .map((p) => p.index); + } + + get pageCount(): number { + return this.module.FPDF_GetPageCount(this.docPtr); + } + + get disposed(): boolean { + return this._disposed; + } + + // Form-fill environment for this document, or null when unavailable. The + // caller must pair it with `notifyFormPageLoaded` before drawing a page. + formEnvironment(): number | null { + if (this.formEnvTried) return this.formEnvPtr; + this.formEnvTried = true; + const m = this.module as unknown as { + PDFiumExt_OpenFormFillInfo?: () => number; + PDFiumExt_InitFormFillEnvironment?: (doc: number, info: number) => number; + }; + if (!m.PDFiumExt_OpenFormFillInfo || !m.PDFiumExt_InitFormFillEnvironment) { + return null; + } + try { + const info = m.PDFiumExt_OpenFormFillInfo(); + const env = m.PDFiumExt_InitFormFillEnvironment(this.docPtr, info); + this.formEnvPtr = env || null; + } catch { + this.formEnvPtr = null; + } + return this.formEnvPtr; + } + + /** Tell the form layer about a page once, before its first form draw. */ + notifyFormPageLoaded(page: Page): void { + const env = this.formEnvironment(); + if (!env || this.formLoadedPages.has(page.pagePtr)) return; + const m = this.module as unknown as { + FORM_OnAfterLoadPage?: (pagePtr: number, env: number) => void; + }; + if (!m.FORM_OnAfterLoadPage) return; + try { + m.FORM_OnAfterLoadPage(page.pagePtr, env); + this.formLoadedPages.add(page.pagePtr); + } catch { + /* best-effort: the page still renders without the form layer */ + } + } + + page(index: number): Page { + const cached = this.pageCache.get(index); + if (cached) return cached; + const pagePtr = this.module.FPDF_LoadPage(this.docPtr, index); + if (!pagePtr) { + throw new Error(`EditorDocument: failed to load page ${index}`); + } + const width = this.module.FPDF_GetPageWidthF(pagePtr); + const height = this.module.FPDF_GetPageHeightF(pagePtr); + // CropBox/rotation transform for the screen boundary; identity for normal + // pages (CropBox==MediaBox, /Rotate==0) so behaviour is unchanged there. + const display = DisplayTransform.fromPage( + this.module, + pagePtr, + width, + height, + ); + const page = new Page({ index, pagePtr, width, height, display }); + this.pageCache.set(index, page); + return page; + } + + registerOwnedFont(font: FontRef): void { + this.ownedFonts.set(font.id, font); + } + + ownedFont(id: string): FontRef | undefined { + return this.ownedFonts.get(id); + } + + /** Iterate loaded pages without forcing more page loads. */ + loadedPages(): Page[] { + return Array.from(this.pageCache.values()); + } + + dispose(): void { + if (this._disposed) return; + this._disposed = true; + if (this.formEnvPtr) { + const m = this.module as unknown as { + FORM_OnBeforeClosePage?: (pagePtr: number, env: number) => void; + FPDFDOC_ExitFormFillEnvironment?: (env: number) => void; + }; + for (const pagePtr of this.formLoadedPages) { + try { + m.FORM_OnBeforeClosePage?.(pagePtr, this.formEnvPtr); + } catch { + /* best-effort */ + } + } + try { + m.FPDFDOC_ExitFormFillEnvironment?.(this.formEnvPtr); + } catch { + /* best-effort */ + } + this.formEnvPtr = null; + } + this.formLoadedPages.clear(); + for (const page of this.pageCache.values()) { + try { + this.module.FPDF_ClosePage(page.pagePtr); + } catch { + /* best-effort */ + } + } + this.pageCache.clear(); + for (const font of this.ownedFonts.values()) { + font.dispose(); + } + this.ownedFonts.clear(); + closeDocAndFreeBuffer(this.module, this.docPtr); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/FontRef.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/FontRef.ts new file mode 100644 index 0000000000..25bf8afd74 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/FontRef.ts @@ -0,0 +1,32 @@ +import type { FontDescriptor } from "@app/tools/pdfTextEditor/types"; + +// A handle to a font inside a PDFium document. `pointer` is the FPDF_FONT +// handle. `owned` decides whether `dispose` should call `FPDFFont_Close`. +export class FontRef { + readonly id: string; + readonly descriptor: FontDescriptor; + readonly pointer: number; + private readonly owned: boolean; + private closeFn: ((ptr: number) => void) | null; + + constructor(opts: { + id: string; + descriptor: FontDescriptor; + pointer: number; + owned: boolean; + closeFn?: (ptr: number) => void; + }) { + this.id = opts.id; + this.descriptor = opts.descriptor; + this.pointer = opts.pointer; + this.owned = opts.owned; + this.closeFn = opts.closeFn ?? null; + } + + dispose(): void { + if (this.owned && this.closeFn && this.pointer) { + this.closeFn(this.pointer); + } + this.closeFn = null; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/ImageObject.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/ImageObject.ts new file mode 100644 index 0000000000..57e70c55f1 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/ImageObject.ts @@ -0,0 +1,44 @@ +import type { + Affine, + ImageObjectSnapshot, + PageRect, +} from "@app/tools/pdfTextEditor/types"; + +export class ImageObject { + readonly id: string; + readonly pageIndex: number; + pdfiumObjPtr: number; + /** Owning form XObject, or 0 when the image sits on the page. */ + containerPtr: number; + bounds: PageRect; + matrix: Affine; + dirty: boolean; + /** Session-only lock; see TextRun.locked. */ + locked: boolean; + + constructor( + init: ImageObjectSnapshot & { + pdfiumObjPtr: number; + containerPtr?: number; + }, + ) { + this.id = init.id; + this.pageIndex = init.pageIndex; + this.pdfiumObjPtr = init.pdfiumObjPtr; + this.containerPtr = init.containerPtr ?? 0; + this.bounds = init.bounds; + this.matrix = init.matrix; + this.dirty = false; + this.locked = init.locked ?? false; + } + + snapshot(): ImageObjectSnapshot { + return { + id: this.id, + pageIndex: this.pageIndex, + bounds: { ...this.bounds }, + matrix: { ...this.matrix }, + locked: this.locked || undefined, + }; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/Page.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/Page.ts new file mode 100644 index 0000000000..7eab70b45b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/Page.ts @@ -0,0 +1,117 @@ +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import type { AnnotationBox } from "@app/tools/pdfTextEditor/model/AnnotationBox"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; + +/** Wraps one PDFium page pointer. */ +export class Page { + readonly index: number; + readonly pagePtr: number; + readonly width: number; + readonly height: number; + // Maps this page's raw PDF object coords (MediaBox, y-up) to the rendered + // bitmap's display space (CropBox-cropped + /Rotate-applied). + readonly display: DisplayTransform; + runs: TextRun[]; + images: ImageObject[]; + /** Text-carrying annotations: rendered by the canvas, not editable. */ + annotations: AnnotationBox[]; + /** True if any object on this page has uncommitted mutation. */ + dirty: boolean; + /** True if the lazy reader has populated runs/images. */ + loaded: boolean; + /** Monotonic version counter, bumped on every commit. */ + revision: number; + // True when commands have mutated PDFium objects on this page but + // `FPDFPage_GenerateContent` hasn't been called yet. + needsGenerateContent: boolean; + // Sticky: regenerated at least once. Regeneration is what drops shadings, so + // the save-time repair needs this long after `dirty` was cleared. + regenerated: boolean; + + constructor(opts: { + index: number; + pagePtr: number; + width: number; + height: number; + display?: DisplayTransform; + }) { + this.index = opts.index; + this.pagePtr = opts.pagePtr; + this.width = opts.width; + this.height = opts.height; + this.display = + opts.display ?? DisplayTransform.identity(opts.width, opts.height); + this.runs = []; + this.images = []; + this.annotations = []; + this.dirty = false; + this.loaded = false; + this.revision = 0; + this.needsGenerateContent = false; + this.regenerated = false; + } + + setRuns(runs: TextRun[]): void { + this.runs = runs; + } + + setImages(images: ImageObject[]): void { + this.images = images; + } + + setAnnotations(annotations: AnnotationBox[]): void { + this.annotations = annotations; + } + + markDirty(): void { + this.dirty = true; + this.revision += 1; + } + + /** Bump the snapshot revision WITHOUT marking the page dirty. */ + bumpRevision(): void { + this.revision += 1; + } + + clearDirty(): void { + this.dirty = false; + this.runs.forEach((r) => { + r.dirty = false; + }); + this.images.forEach((i) => { + i.dirty = false; + }); + } + + // Record that this page's PDFium content stream is stale and needs a future + // GenerateContent before render or save. + markNeedsGenerate(): void { + this.needsGenerateContent = true; + } + + /** Run `FPDFPage_GenerateContent` if there are pending mutations. */ + flushGenerate(m: WrappedPdfiumModule): void { + if (!this.needsGenerateContent) return; + this.needsGenerateContent = false; + this.regenerated = true; + // PDFium reports regeneration failure by RETURN VALUE, not by throwing. + // Discarding it let a page that regenerated to nothing serialize its stale + // pre-edit stream while the UI reported a clean save. Throwing routes it + // into PdfiumSave's failedPages guard, which aborts the save. + if (!m.FPDFPage_GenerateContent(this.pagePtr)) { + throw new Error( + `FPDFPage_GenerateContent failed for page ${this.index + 1}`, + ); + } + } + + findRun(id: string): TextRun | undefined { + return this.runs.find((r) => r.id === id); + } + + findImage(id: string): ImageObject | undefined { + return this.images.find((i) => i.id === id); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/TextRun.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/TextRun.ts new file mode 100644 index 0000000000..cc0d3b2f1b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/TextRun.ts @@ -0,0 +1,208 @@ +import type { + Affine, + PageRect, + RGBA, + TextRunSnapshot, +} from "@app/tools/pdfTextEditor/types"; + +/** One line's worth of sub-run data inside a paragraph. */ +export interface ParagraphLineSlot { + startChar: number; + endChar: number; + baselineY: number; + matrixE: number; + containerPtr: number; + fontId: string; + fontSize: number; + fontSubset: boolean; + mergedFromPtrs: number[]; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + /** Char-start positions RELATIVE to the line's text (0..lineText.length). */ + mergedFromCharStarts: number[]; +} + +/** Deep-clone a slot so the copy shares NO nested arrays with the source. */ +export function cloneParagraphLineSlot( + s: ParagraphLineSlot, +): ParagraphLineSlot { + return { + ...s, + mergedFromPtrs: [...s.mergedFromPtrs], + mergedFromTexts: [...s.mergedFromTexts], + mergedFromBounds: s.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...s.mergedFromCharStarts], + }; +} + +/** One PDF text object. */ +export class TextRun { + readonly id: string; + readonly pageIndex: number; + /** PDFium object pointer (page-relative). Zero means "newly created, not yet inserted". */ + pdfiumObjPtr: number; + bounds: PageRect; + matrix: Affine; + text: string; + fontId: string; + fontSize: number; + fill: RGBA; + fontSubset: boolean; + // PDF text render mode (Tr): 0 fill, 1/2 stroke variants, 3 invisible (OCR + // layers over scans), 4-7 clipping. + renderMode: number; + // Glyph outline (PDF stroke state), carried even when the render mode hides + // it, so a re-emit cannot silently drop an outlined heading's outline. + stroke: RGBA | null; + strokeWidth: number; + // Engine pen origins/ends per code unit of `text`, raw page points. Valid + // only while `charPositionsText` still equals `text`, so edits invalidate. + charStartsX: number[] | null; + charEndsX: number[] | null; + charPositionsKey: string | null; + /** Effective extra advance per glyph in PDF points. */ + charSpacingPt: number; + /** True when the run has uncommitted mutation. */ + dirty: boolean; + // If the LineGrouper merged multiple PDFium objects into this run, the + // original object pointers (in left-to-right order). + mergedFromPtrs: number[]; + /** Per-sub-run text (parallel to `mergedFromPtrs`). */ + mergedFromTexts: string[]; + /** Per-sub-run bounds (parallel to `mergedFromPtrs`). */ + mergedFromBounds: Array<{ x: number; right: number }>; + // Per-sub-run starting position in `run.text` (parallel to `mergedFromPtrs`). + mergedFromCharStarts: number[]; + // If this run was extracted from inside a form xobject, the PDFium pointer of + // the immediate parent form. + containerPtr: number; + /** If the run was extracted from a form xobject. */ + topLevelContainerPtr: number; + // When ParagraphGrouper merged multiple line groups into this run, the + // average vertical distance between consecutive baselines (in PDF points). + paragraphLineHeight: number; + /** PDFium pointers for each constituent line, top-down. */ + paragraphMemberPtrs: number[]; + /** Form-xobject containers (parallel array) for each member. */ + paragraphMemberContainers: number[]; + /** Baseline f-values for each member, top-down. */ + paragraphMemberFs: number[]; + // Every leaf PDFium pointer that backs this paragraph - includes each line's + // own `mergedFromPtrs` flattened. + paragraphLeafPtrs: number[]; + /** Parallel form-xobject containers for every leaf ptr. */ + paragraphLeafContainers: number[]; + // Pointer to the LATEST background cover-rect emitted on the page for this + // run. + coverRectPtr: number; + /** Per-line sub-run snapshots for paragraph-aware partial edits. */ + paragraphLineSlots: ParagraphLineSlot[]; + // Which visual lines start at a break the WRAP put there rather than one the + // user typed. run.text spells both as a newline - it has to, or the line + // count the painter and the box height read disagrees with the ink on the + // page - so the difference lives here. Without it a reflow re-reads its own + // soft breaks as forced ones and the paragraph can never re-flow again. + paragraphSoftStarts: boolean[]; + // Session-only lock: when true the run is skipped by all hit-tests (mouse, + // marquee, Ctrl+A) and edit gestures are no-ops. + locked: boolean; + + constructor( + init: TextRunSnapshot & { + pdfiumObjPtr: number; + containerPtr?: number; + topLevelContainerPtr?: number; + }, + ) { + this.id = init.id; + this.pageIndex = init.pageIndex; + this.pdfiumObjPtr = init.pdfiumObjPtr; + this.bounds = init.bounds; + this.matrix = init.matrix; + this.text = init.text; + this.fontId = init.fontId; + this.fontSize = init.fontSize; + this.fill = init.fill; + this.fontSubset = init.fontSubset; + this.renderMode = init.renderMode ?? 0; + this.stroke = init.stroke ?? null; + this.strokeWidth = init.strokeWidth ?? 0; + this.charStartsX = null; + this.charEndsX = null; + this.charPositionsKey = null; + this.charSpacingPt = 0; + this.dirty = false; + this.mergedFromPtrs = []; + this.mergedFromTexts = []; + this.mergedFromBounds = []; + this.mergedFromCharStarts = []; + this.containerPtr = init.containerPtr ?? 0; + this.topLevelContainerPtr = init.topLevelContainerPtr ?? 0; + this.paragraphLineHeight = 0; + this.paragraphMemberPtrs = []; + this.paragraphMemberContainers = []; + this.paragraphMemberFs = []; + this.paragraphLeafPtrs = []; + this.paragraphLeafContainers = []; + this.paragraphLineSlots = []; + this.paragraphSoftStarts = []; + this.coverRectPtr = 0; + this.locked = init.locked ?? false; + } + + // Captured pen positions are only valid for the text AND face they were + // measured from; a size or family change moves every glyph. + positionsKey(): string { + return `${this.text}\u0000${this.fontId}\u0000${this.fontSize}`; + } + + private positionsCurrent(): boolean { + return this.charPositionsKey === this.positionsKey(); + } + + // Display/serialization projection only. + snapshot(): TextRunSnapshot { + return { + id: this.id, + pageIndex: this.pageIndex, + bounds: { ...this.bounds }, + matrix: { ...this.matrix }, + text: this.text, + fontId: this.fontId, + fontSize: this.fontSize, + fill: { ...this.fill }, + fontSubset: this.fontSubset, + renderMode: this.renderMode || undefined, + stroke: this.stroke ? { ...this.stroke } : undefined, + strokeWidth: this.strokeWidth || undefined, + charStartsX: this.positionsCurrent() + ? (this.charStartsX ?? undefined) + : undefined, + charEndsX: this.positionsCurrent() + ? (this.charEndsX ?? undefined) + : undefined, + charSpacingPt: this.charSpacingPt || undefined, + paragraphLineHeight: this.paragraphLineHeight, + paragraphLineCount: this.paragraphMemberPtrs.length || undefined, + paragraphSlotCount: this.paragraphLineSlots.length || undefined, + paragraphBaselines: this.lineBaselines(), + paragraphLineLefts: this.lineLefts(), + locked: this.locked || undefined, + }; + } + + private lineBaselines(): number[] | undefined { + if (this.paragraphLineSlots.length > 0) { + return this.paragraphLineSlots.map((s) => s.baselineY); + } + return this.paragraphMemberFs.length > 0 + ? [...this.paragraphMemberFs] + : undefined; + } + + private lineLefts(): number[] | undefined { + return this.paragraphLineSlots.length > 0 + ? this.paragraphLineSlots.map((s) => s.matrixE) + : undefined; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/affine.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/affine.ts new file mode 100644 index 0000000000..982f3d7c13 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/affine.ts @@ -0,0 +1,96 @@ +import type { Affine, PageRect } from "@app/tools/pdfTextEditor/types"; + +const IDENTITY: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + +/** Map a point through an affine: (x,y) -> (a·x + c·y + e, b·x + d·y + f). */ +export function applyAffine( + t: Affine, + x: number, + y: number, +): { x: number; y: number } { + return { x: t.a * x + t.c * y + t.e, y: t.b * x + t.d * y + t.f }; +} + +/** Compose two affines: `parent ∘ child` (child applied first, then parent). */ +export function composeAffine(parent: Affine, child: Affine): Affine { + return { + a: parent.a * child.a + parent.c * child.b, + b: parent.b * child.a + parent.d * child.b, + c: parent.a * child.c + parent.c * child.d, + d: parent.b * child.c + parent.d * child.d, + e: parent.a * child.e + parent.c * child.f + parent.e, + f: parent.b * child.e + parent.d * child.f + parent.f, + }; +} + +/** Transform a rect by an affine and return the new AABB (4 corners, min/max). */ +export function transformRectAABB(t: Affine, r: PageRect): PageRect { + const cs = [ + applyAffine(t, r.x, r.y), + applyAffine(t, r.x + r.width, r.y), + applyAffine(t, r.x, r.y + r.height), + applyAffine(t, r.x + r.width, r.y + r.height), + ]; + const xs = cs.map((c) => c.x); + const ys = cs.map((c) => c.y); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + return { + x: minX, + y: minY, + width: Math.max(...xs) - minX, + height: Math.max(...ys) - minY, + }; +} + +/** Inverse of an affine, or identity when singular (degenerate linear part). */ +export function invertAffine(t: Affine): Affine { + const det = t.a * t.d - t.b * t.c; + if (!det || !Number.isFinite(det)) return { ...IDENTITY }; + const a = t.d / det; + const b = -t.b / det; + const c = -t.c / det; + const d = t.a / det; + return { a, b, c, d, e: -(a * t.e + c * t.f), f: -(b * t.e + d * t.f) }; +} + +/** Axis-aligned bounds of an image's projected 1x1 unit square under `m`. */ +export function imageMatrixBounds(m: Affine): PageRect { + const xs = [m.e, m.e + m.a, m.e + m.c, m.e + m.a + m.c]; + const ys = [m.f, m.f + m.b, m.f + m.d, m.f + m.b + m.d]; + const minX = Math.min(...xs); + const minY = Math.min(...ys); + return { + x: minX, + y: minY, + width: Math.max(...xs) - minX, + height: Math.max(...ys) - minY, + }; +} + +// New RAW image matrix when the user moves/resizes the image's display-space +// AABB from `prevBounds` to `nextBounds`. +export function remapImageMatrix( + prev: Affine, + prevBounds: PageRect, + nextBounds: PageRect, + display: Affine, +): Affine { + const A = display; + const Ainv = invertAffine(A); + const origDisp = transformRectAABB(A, prevBounds); + const targetDisp = transformRectAABB(A, nextBounds); + const sx = origDisp.width > 1e-6 ? targetDisp.width / origDisp.width : 1; + const sy = origDisp.height > 1e-6 ? targetDisp.height / origDisp.height : 1; + // Display-space scale+translate mapping origDisp -> targetDisp (axis-aligned). + const S: Affine = { + a: sx, + b: 0, + c: 0, + d: sy, + e: targetDisp.x - sx * origDisp.x, + f: targetDisp.y - sy * origDisp.y, + }; + // raw' = A⁻¹ ∘ S ∘ A ∘ prev + return composeAffine(Ainv, composeAffine(S, composeAffine(A, prev))); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts deleted file mode 100644 index 3bb5087a8a..0000000000 --- a/frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts +++ /dev/null @@ -1,233 +0,0 @@ -export interface PdfJsonFontCidSystemInfo { - registry?: string | null; - ordering?: string | null; - supplement?: number | null; -} - -export interface PdfJsonTextColor { - colorSpace?: string | null; - components?: number[] | null; -} - -export interface PdfJsonCosValue { - type?: string | null; - value?: unknown; - items?: PdfJsonCosValue[] | null; - entries?: Record | null; - stream?: PdfJsonStream | null; -} - -export interface PdfJsonFont { - id?: string; - pageNumber?: number | null; - uid?: string | null; - baseName?: string | null; - subtype?: string | null; - encoding?: string | null; - cidSystemInfo?: PdfJsonFontCidSystemInfo | null; - embedded?: boolean | null; - program?: string | null; - programFormat?: string | null; - webProgram?: string | null; - webProgramFormat?: string | null; - pdfProgram?: string | null; - pdfProgramFormat?: string | null; - toUnicode?: string | null; - standard14Name?: string | null; - fontDescriptorFlags?: number | null; - ascent?: number | null; - descent?: number | null; - capHeight?: number | null; - xHeight?: number | null; - italicAngle?: number | null; - unitsPerEm?: number | null; - cosDictionary?: PdfJsonCosValue | null; -} - -export interface PdfJsonTextElement { - text?: string | null; - fontId?: string | null; - fontSize?: number | null; - fontMatrixSize?: number | null; - fontSizeInPt?: number | null; - characterSpacing?: number | null; - wordSpacing?: number | null; - spaceWidth?: number | null; - zOrder?: number | null; - horizontalScaling?: number | null; - leading?: number | null; - rise?: number | null; - renderingMode?: number | null; - x?: number | null; - y?: number | null; - width?: number | null; - height?: number | null; - textMatrix?: number[] | null; - fillColor?: PdfJsonTextColor | null; - strokeColor?: PdfJsonTextColor | null; - charCodes?: number[] | null; - fallbackUsed?: boolean | null; -} - -export interface PdfJsonImageElement { - id?: string | null; - objectName?: string | null; - inlineImage?: boolean | null; - nativeWidth?: number | null; - nativeHeight?: number | null; - x?: number | null; - y?: number | null; - width?: number | null; - height?: number | null; - left?: number | null; - right?: number | null; - top?: number | null; - bottom?: number | null; - transform?: number[] | null; - zOrder?: number | null; - imageData?: string | null; - imageFormat?: string | null; -} - -export interface PdfJsonStream { - dictionary?: Record | null; - rawData?: string | null; -} - -export interface PdfJsonPage { - pageNumber?: number | null; - width?: number | null; - height?: number | null; - rotation?: number | null; - mediaBox?: number[] | null; - cropBox?: number[] | null; - textElements?: PdfJsonTextElement[] | null; - imageElements?: PdfJsonImageElement[] | null; - resources?: unknown; - contentStreams?: PdfJsonStream[] | null; -} - -export interface PdfJsonMetadata { - title?: string | null; - author?: string | null; - subject?: string | null; - keywords?: string | null; - creator?: string | null; - producer?: string | null; - creationDate?: string | null; - modificationDate?: string | null; - trapped?: string | null; - numberOfPages?: number | null; -} - -export interface PdfJsonDocument { - metadata?: PdfJsonMetadata | null; - xmpMetadata?: string | null; - fonts?: PdfJsonFont[] | null; - pages?: PdfJsonPage[] | null; - lazyImages?: boolean | null; -} - -export interface PdfJsonPageDimension { - pageNumber?: number | null; - width?: number | null; - height?: number | null; - rotation?: number | null; -} - -export interface PdfJsonDocumentMetadata { - metadata?: PdfJsonMetadata | null; - xmpMetadata?: string | null; - fonts?: PdfJsonFont[] | null; - pageDimensions?: PdfJsonPageDimension[] | null; - formFields?: unknown[] | null; - lazyImages?: boolean | null; -} - -export interface BoundingBox { - left: number; - right: number; - top: number; - bottom: number; -} - -export interface TextGroup { - id: string; - pageIndex: number; - fontId?: string | null; - fontSize?: number | null; - fontMatrixSize?: number | null; - lineSpacing?: number | null; - lineElementCounts?: number[] | null; - color?: string | null; - fontWeight?: number | "normal" | "bold" | null; - rotation?: number | null; - anchor?: { x: number; y: number } | null; - baselineLength?: number | null; - baseline?: number | null; - elements: PdfJsonTextElement[]; - originalElements: PdfJsonTextElement[]; - text: string; - originalText: string; - bounds: BoundingBox; - childLineGroups?: TextGroup[] | null; -} - -export const DEFAULT_PAGE_WIDTH = 612; -export const DEFAULT_PAGE_HEIGHT = 792; - -export interface ConversionProgress { - percent: number; - stage: string; - message: string; - current?: number; - total?: number; -} - -export interface PdfTextEditorViewData { - document: PdfJsonDocument | null; - groupsByPage: TextGroup[][]; - imagesByPage: PdfJsonImageElement[][]; - pagePreviews: Map; - selectedPage: number; - dirtyPages: boolean[]; - hasDocument: boolean; - hasVectorPreview: boolean; - fileName: string; - errorMessage: string | null; - isGeneratingPdf: boolean; - isConverting: boolean; - conversionProgress: ConversionProgress | null; - hasChanges: boolean; - forceSingleTextElement: boolean; - groupingMode: "auto" | "paragraph" | "singleLine"; - autoScaleText: boolean; - onAutoScaleTextChange: (value: boolean) => void; - requestPagePreview: (pageIndex: number, scale: number) => void; - onSelectPage: (pageIndex: number) => void; - onGroupEdit: (pageIndex: number, groupId: string, value: string) => void; - onGroupDelete: (pageIndex: number, groupId: string) => void; - onImageTransform: ( - pageIndex: number, - imageId: string, - next: { - left: number; - bottom: number; - width: number; - height: number; - transform: number[]; - }, - ) => void; - onImageReset: (pageIndex: number, imageId: string) => void; - onReset: () => void; - onDownloadJson: () => void; - onGeneratePdf: () => void; - onGeneratePdfForNavigation: () => Promise; - onSaveToWorkbench: () => Promise; - isSavingToWorkbench: boolean; - onForceSingleTextElementChange: (value: boolean) => void; - onGroupingModeChange: (value: "auto" | "paragraph" | "singleLine") => void; - onMergeGroups: (pageIndex: number, groupIds: string[]) => boolean; - onUngroupGroup: (pageIndex: number, groupId: string) => boolean; - onLoadFile: (file: File) => void; -} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorUtils.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorUtils.ts deleted file mode 100644 index 522e25684f..0000000000 --- a/frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorUtils.ts +++ /dev/null @@ -1,1525 +0,0 @@ -import { - BoundingBox, - PdfJsonDocument, - PdfJsonPage, - PdfJsonTextElement, - PdfJsonImageElement, - TextGroup, - DEFAULT_PAGE_HEIGHT, - DEFAULT_PAGE_WIDTH, -} from "@app/tools/pdfTextEditor/pdfTextEditorTypes"; - -const LINE_TOLERANCE = 2; -const GAP_FACTOR = 0.6; -const SPACE_MIN_GAP = 1.5; -const MIN_CHAR_WIDTH_FACTOR = 0.35; -const MAX_CHAR_WIDTH_FACTOR = 1.25; -const EXTRA_GAP_RATIO = 0.8; - -type FontMetrics = { - unitsPerEm: number; - ascent: number; - descent: number; -}; - -type FontMetricsMap = Map; - -const sanitizeParagraphText = (text: string | undefined | null): string => { - if (!text) { - return ""; - } - return text.replace(/\r?\n/g, ""); -}; - -const splitParagraphIntoLines = (text: string | undefined | null): string[] => { - if (text === null || text === undefined) { - return [""]; - } - return text.replace(/\r/g, "").split("\n"); -}; - -const extractElementBaseline = (element: PdfJsonTextElement): number | null => { - if (!element) { - return null; - } - if (element.textMatrix && element.textMatrix.length >= 6) { - const baseline = element.textMatrix[5]; - return typeof baseline === "number" ? baseline : null; - } - if (typeof element.y === "number") { - return element.y; - } - return null; -}; - -const shiftElementsBy = ( - elements: PdfJsonTextElement[], - delta: number, -): PdfJsonTextElement[] => { - if (delta === 0) { - return elements.map(cloneTextElement); - } - return elements.map((element) => { - const clone = cloneTextElement(element); - if (clone.textMatrix && clone.textMatrix.length >= 6) { - const matrix = [...clone.textMatrix]; - matrix[5] = (matrix[5] ?? 0) + delta; - clone.textMatrix = matrix; - } - if (typeof clone.y === "number") { - clone.y += delta; - } else if (clone.y === null || clone.y === undefined) { - clone.y = delta; - } - return clone; - }); -}; - -const countGraphemes = (text: string): number => { - if (!text) { - return 0; - } - return Array.from(text).length; -}; - -const metricsFor = ( - metrics: FontMetricsMap | undefined, - fontId?: string | null, -): FontMetrics | undefined => { - if (!metrics || !fontId) { - return undefined; - } - return metrics.get(fontId) ?? undefined; -}; - -const buildFontMetrics = ( - document: PdfJsonDocument | null | undefined, -): FontMetricsMap => { - const metrics: FontMetricsMap = new Map(); - document?.fonts?.forEach((font) => { - if (!font) { - return; - } - const unitsPerEm = - font.unitsPerEm && font.unitsPerEm > 0 ? font.unitsPerEm : 1000; - const ascent = font.ascent ?? unitsPerEm * 0.8; - const descent = font.descent ?? -(unitsPerEm * 0.2); - const metric: FontMetrics = { unitsPerEm, ascent, descent }; - if (font.id) { - metrics.set(font.id, metric); - } - if (font.uid) { - metrics.set(font.uid, metric); - } - }); - return metrics; -}; - -export const valueOr = ( - value: number | null | undefined, - fallback = 0, -): number => { - if (value === null || value === undefined || Number.isNaN(value)) { - return fallback; - } - return value; -}; - -export const cloneTextElement = ( - element: PdfJsonTextElement, -): PdfJsonTextElement => ({ - ...element, - textMatrix: element.textMatrix - ? [...element.textMatrix] - : (element.textMatrix ?? undefined), -}); - -const clearGlyphHints = (element: PdfJsonTextElement): void => { - if (!element) { - return; - } - element.charCodes = undefined; -}; - -export const cloneImageElement = ( - element: PdfJsonImageElement, -): PdfJsonImageElement => ({ - ...element, - transform: element.transform - ? [...element.transform] - : (element.transform ?? undefined), -}); - -const getBaseline = (element: PdfJsonTextElement): number => { - if (element.textMatrix && element.textMatrix.length === 6) { - return valueOr(element.textMatrix[5]); - } - return valueOr(element.y); -}; - -const getX = (element: PdfJsonTextElement): number => { - if (element.textMatrix && element.textMatrix.length === 6) { - return valueOr(element.textMatrix[4]); - } - return valueOr(element.x); -}; - -const getWidth = ( - element: PdfJsonTextElement, - metrics?: FontMetricsMap, -): number => { - const width = valueOr(element.width, 0); - if (width > 0) { - return width; - } - - const text = element.text ?? ""; - const glyphCount = Math.max(1, countGraphemes(text)); - const spacingFallback = Math.max( - valueOr(element.spaceWidth, 0), - valueOr(element.wordSpacing, 0), - valueOr(element.characterSpacing, 0), - ); - - if (spacingFallback > 0 && text.trim().length === 0) { - return spacingFallback; - } - - const fontSize = getFontSize(element); - const fontMetrics = metricsFor(metrics, element.fontId); - if (fontMetrics) { - const unitsPerEm = - fontMetrics.unitsPerEm > 0 ? fontMetrics.unitsPerEm : 1000; - const ascentUnits = fontMetrics.ascent ?? unitsPerEm * 0.8; - const descentUnits = Math.abs(fontMetrics.descent ?? -(unitsPerEm * 0.2)); - const combinedUnits = Math.max( - unitsPerEm * 0.8, - ascentUnits + descentUnits, - ); - const averageAdvanceUnits = Math.max( - unitsPerEm * 0.5, - combinedUnits / Math.max(1, glyphCount), - ); - const fallbackWidth = - (averageAdvanceUnits / unitsPerEm) * glyphCount * fontSize; - if (fallbackWidth > 0) { - return fallbackWidth; - } - } - - return fontSize * glyphCount * 0.5; -}; - -const getFontSize = (element: PdfJsonTextElement): number => - valueOr(element.fontMatrixSize ?? element.fontSize, 12); - -const getHeight = ( - element: PdfJsonTextElement, - metrics?: FontMetricsMap, -): number => { - const height = valueOr(element.height, 0); - if (height > 0) { - return height; - } - const fontSize = getFontSize(element); - const fontMetrics = metricsFor(metrics, element.fontId); - if (fontMetrics) { - const unitsPerEm = - fontMetrics.unitsPerEm > 0 ? fontMetrics.unitsPerEm : 1000; - const ascentUnits = fontMetrics.ascent ?? unitsPerEm * 0.8; - const descentUnits = Math.abs(fontMetrics.descent ?? -(unitsPerEm * 0.2)); - const totalUnits = Math.max(unitsPerEm, ascentUnits + descentUnits); - if (totalUnits > 0) { - return (totalUnits / unitsPerEm) * fontSize; - } - } - return fontSize; -}; - -const getElementBounds = ( - element: PdfJsonTextElement, - metrics?: FontMetricsMap, -): BoundingBox => { - const left = getX(element); - const width = getWidth(element, metrics); - const baseline = getBaseline(element); - const height = getHeight(element, metrics); - - let ascentRatio = 0.8; - let descentRatio = 0.2; - const fontMetrics = metricsFor(metrics, element.fontId); - if (fontMetrics) { - const unitsPerEm = - fontMetrics.unitsPerEm > 0 ? fontMetrics.unitsPerEm : 1000; - const ascentUnits = fontMetrics.ascent ?? unitsPerEm * 0.8; - const descentUnits = Math.abs(fontMetrics.descent ?? -(unitsPerEm * 0.2)); - const totalUnits = Math.max(unitsPerEm, ascentUnits + descentUnits); - if (totalUnits > 0) { - ascentRatio = ascentUnits / totalUnits; - descentRatio = descentUnits / totalUnits; - } - } - - const bottom = baseline + height * ascentRatio; - const top = baseline - height * descentRatio; - return { - left, - right: left + width, - top, - bottom, - }; -}; - -export const getImageBounds = (element: PdfJsonImageElement): BoundingBox => { - const left = valueOr(element.left ?? element.x, 0); - const computedWidth = valueOr( - element.width, - Math.max(valueOr(element.right, left) - left, 0), - ); - const right = valueOr( - element.right ?? left + computedWidth, - left + computedWidth, - ); - const bottom = valueOr(element.bottom ?? element.y, 0); - const computedHeight = valueOr( - element.height, - Math.max(valueOr(element.top, bottom) - bottom, 0), - ); - const top = valueOr( - element.top ?? bottom + computedHeight, - bottom + computedHeight, - ); - return { - left, - right, - bottom, - top, - }; -}; - -const getSpacingHint = (element: PdfJsonTextElement): number => { - const spaceWidth = valueOr(element.spaceWidth, 0); - if (spaceWidth > 0) { - return spaceWidth; - } - const wordSpacing = valueOr(element.wordSpacing, 0); - if (wordSpacing > 0) { - return wordSpacing; - } - const characterSpacing = valueOr(element.characterSpacing, 0); - return Math.max(characterSpacing, 0); -}; - -const estimateCharWidth = ( - element: PdfJsonTextElement, - avgFontSize: number, - metrics?: FontMetricsMap, -): number => { - const rawWidth = getWidth(element, metrics); - const minWidth = avgFontSize * MIN_CHAR_WIDTH_FACTOR; - const maxWidth = avgFontSize * MAX_CHAR_WIDTH_FACTOR; - return Math.min(Math.max(rawWidth, minWidth), maxWidth); -}; - -const mergeBounds = (bounds: BoundingBox[]): BoundingBox => { - if (bounds.length === 0) { - return { left: 0, right: 0, top: 0, bottom: 0 }; - } - return bounds.reduce( - (acc, current) => ({ - left: Math.min(acc.left, current.left), - right: Math.max(acc.right, current.right), - top: Math.min(acc.top, current.top), - bottom: Math.max(acc.bottom, current.bottom), - }), - { ...bounds[0] }, - ); -}; - -const shouldInsertSpace = ( - prev: PdfJsonTextElement, - current: PdfJsonTextElement, - metrics?: FontMetricsMap, -): boolean => { - const prevRight = getX(prev) + getWidth(prev, metrics); - const trailingGap = Math.max(0, getX(current) - prevRight); - const avgFontSize = (getFontSize(prev) + getFontSize(current)) / 2; - const baselineAdvance = Math.max(0, getX(current) - getX(prev)); - const charWidthEstimate = estimateCharWidth(prev, avgFontSize, metrics); - const inferredGap = Math.max(0, baselineAdvance - charWidthEstimate); - const spacingHint = Math.max( - SPACE_MIN_GAP, - getSpacingHint(prev), - getSpacingHint(current), - avgFontSize * GAP_FACTOR, - ); - - if (trailingGap > spacingHint) { - return true; - } - - if (inferredGap > spacingHint * EXTRA_GAP_RATIO) { - return true; - } - - const prevText = (prev.text ?? "").trimEnd(); - if (prevText.endsWith("-")) { - return false; - } - - return false; -}; - -const buildGroupText = ( - elements: PdfJsonTextElement[], - metrics?: FontMetricsMap, -): string => { - let result = ""; - elements.forEach((element, index) => { - const value = element.text ?? ""; - if (index === 0) { - result += value; - return; - } - - const previous = elements[index - 1]; - const needsSpace = shouldInsertSpace(previous, element, metrics); - const startsWithWhitespace = /^\s/u.test(value); - - if (needsSpace && !startsWithWhitespace) { - result += " "; - } - result += value; - }); - return result; -}; - -const rgbToCss = (components: number[]): string => { - if (components.length >= 3) { - const r = Math.round(Math.max(0, Math.min(1, components[0])) * 255); - const g = Math.round(Math.max(0, Math.min(1, components[1])) * 255); - const b = Math.round(Math.max(0, Math.min(1, components[2])) * 255); - return `rgb(${r}, ${g}, ${b})`; - } - return "rgb(0, 0, 0)"; -}; - -const cmykToCss = (components: number[]): string => { - if (components.length >= 4) { - const c = Math.max(0, Math.min(1, components[0])); - const m = Math.max(0, Math.min(1, components[1])); - const y = Math.max(0, Math.min(1, components[2])); - const k = Math.max(0, Math.min(1, components[3])); - const r = Math.round(255 * (1 - c) * (1 - k)); - const g = Math.round(255 * (1 - m) * (1 - k)); - const b = Math.round(255 * (1 - y) * (1 - k)); - return `rgb(${r}, ${g}, ${b})`; - } - return "rgb(0, 0, 0)"; -}; - -const grayToCss = (components: number[]): string => { - if (components.length >= 1) { - const gray = Math.round(Math.max(0, Math.min(1, components[0])) * 255); - return `rgb(${gray}, ${gray}, ${gray})`; - } - return "rgb(0, 0, 0)"; -}; - -const extractColor = (element: PdfJsonTextElement): string | null => { - const fillColor = element.fillColor; - if ( - !fillColor || - !fillColor.components || - fillColor.components.length === 0 - ) { - return null; - } - - const colorSpace = (fillColor.colorSpace ?? "").toLowerCase(); - - if (colorSpace.includes("rgb") || colorSpace.includes("srgb")) { - return rgbToCss(fillColor.components); - } - if (colorSpace.includes("cmyk")) { - return cmykToCss(fillColor.components); - } - if (colorSpace.includes("gray") || colorSpace.includes("grey")) { - return grayToCss(fillColor.components); - } - - // Default to RGB interpretation - if (fillColor.components.length >= 3) { - return rgbToCss(fillColor.components); - } - if (fillColor.components.length === 1) { - return grayToCss(fillColor.components); - } - - return null; -}; - -const RAD_TO_DEG = 180 / Math.PI; - -const normalizeAngle = (angle: number): number => { - let normalized = angle % 360; - if (normalized > 180) { - normalized -= 360; - } else if (normalized <= -180) { - normalized += 360; - } - return normalized; -}; - -const extractElementRotation = (element: PdfJsonTextElement): number | null => { - const matrix = element.textMatrix; - if (!matrix || matrix.length !== 6) { - return null; - } - const a = matrix[0]; - const b = matrix[1]; - if (Math.abs(a) < 1e-6 && Math.abs(b) < 1e-6) { - return null; - } - const angle = Math.atan2(b, a) * RAD_TO_DEG; - if (Math.abs(angle) < 0.5) { - return null; - } - return normalizeAngle(angle); -}; - -const computeGroupRotation = ( - elements: PdfJsonTextElement[], -): number | null => { - const angles = elements - .map(extractElementRotation) - .filter((angle): angle is number => angle !== null); - if (angles.length === 0) { - return null; - } - const vector = angles.reduce( - (acc, angle) => { - const radians = (angle * Math.PI) / 180; - acc.x += Math.cos(radians); - acc.y += Math.sin(radians); - return acc; - }, - { x: 0, y: 0 }, - ); - if (Math.abs(vector.x) < 1e-6 && Math.abs(vector.y) < 1e-6) { - return null; - } - const average = Math.atan2(vector.y, vector.x) * RAD_TO_DEG; - const normalized = normalizeAngle(average); - return Math.abs(normalized) < 0.5 ? null : normalized; -}; - -const getAnchorPoint = ( - element: PdfJsonTextElement, -): { x: number; y: number } => { - if (element.textMatrix && element.textMatrix.length === 6) { - return { - x: valueOr(element.textMatrix[4]), - y: valueOr(element.textMatrix[5]), - }; - } - return { - x: valueOr(element.x), - y: valueOr(element.y), - }; -}; - -const computeBaselineLength = ( - elements: PdfJsonTextElement[], - metrics?: FontMetricsMap, -): number => - elements.reduce((acc, current) => acc + getWidth(current, metrics), 0); - -const computeAverageBaseline = ( - elements: PdfJsonTextElement[], -): number | null => { - if (elements.length === 0) { - return null; - } - let sum = 0; - elements.forEach((element) => { - sum += getBaseline(element); - }); - return sum / elements.length; -}; - -const createGroup = ( - pageIndex: number, - idSuffix: number, - elements: PdfJsonTextElement[], - metrics?: FontMetricsMap, -): TextGroup => { - const clones = elements.map(cloneTextElement); - const originalClones = clones.map(cloneTextElement); - const bounds = mergeBounds( - elements.map((element) => getElementBounds(element, metrics)), - ); - const firstElement = elements[0]; - const rotation = computeGroupRotation(elements); - const anchor = rotation !== null ? getAnchorPoint(firstElement) : null; - const baselineLength = computeBaselineLength(elements, metrics); - const baseline = computeAverageBaseline(elements); - - return { - id: `${pageIndex}-${idSuffix}`, - pageIndex, - fontId: firstElement?.fontId, - fontSize: firstElement?.fontSize, - fontMatrixSize: firstElement?.fontMatrixSize, - color: firstElement ? extractColor(firstElement) : null, - fontWeight: null, // Will be determined from font descriptor - rotation, - anchor, - baselineLength, - baseline, - elements: clones, - originalElements: originalClones, - text: buildGroupText(elements, metrics), - originalText: buildGroupText(elements, metrics), - bounds, - }; -}; - -const cloneLineTemplate = (line: TextGroup): TextGroup => ({ - ...line, - childLineGroups: null, - lineElementCounts: null, - lineSpacing: null, - elements: line.elements.map(cloneTextElement), - originalElements: line.originalElements.map(cloneTextElement), -}); - -const groupLinesIntoParagraphs = ( - lineGroups: TextGroup[], - pageWidth: number, - metrics?: FontMetricsMap, -): TextGroup[] => { - if (lineGroups.length === 0) { - return []; - } - - const paragraphs: TextGroup[][] = []; - let currentParagraph: TextGroup[] = [lineGroups[0]]; - const bulletFlags = new Map(); - bulletFlags.set(lineGroups[0].id, false); - - for (let i = 1; i < lineGroups.length; i++) { - const prevLine = lineGroups[i - 1]; - const currentLine = lineGroups[i]; - - // Calculate line spacing - const prevBaseline = prevLine.baseline ?? 0; - const currentBaseline = currentLine.baseline ?? 0; - const lineSpacing = Math.abs(prevBaseline - currentBaseline); - - // Calculate average font size - const prevFontSize = prevLine.fontSize ?? 12; - const currentFontSize = currentLine.fontSize ?? 12; - const avgFontSize = (prevFontSize + currentFontSize) / 2; - - // Check horizontal alignment (left edge) - const prevLeft = prevLine.bounds.left; - const currentLeft = currentLine.bounds.left; - const leftAlignmentTolerance = avgFontSize * 0.3; - const isLeftAligned = - Math.abs(prevLeft - currentLeft) <= leftAlignmentTolerance; - - // Check if fonts match - const sameFont = prevLine.fontId === currentLine.fontId; - - // Check for consistent spacing rather than expected spacing - // Line spacing in PDFs can range from 1.0x to 3.0x font size - // We just want to ensure spacing is consistent between consecutive lines - // and not excessively large (which would indicate a paragraph break) - const maxReasonableSpacing = avgFontSize * 3.0; // Max ~3x font size for normal line spacing - const hasReasonableSpacing = lineSpacing <= maxReasonableSpacing; - - // Check if current line looks like a bullet/list item - const prevRight = prevLine.bounds.right; - const currentRight = currentLine.bounds.right; - const prevWidth = prevRight - prevLeft; - const currentWidth = currentRight - currentLeft; - - // Count word count to help identify bullets (typically short) - const prevWords = (prevLine.text ?? "") - .split(/\s+/) - .filter((w) => w.length > 0).length; - const currentWords = (currentLine.text ?? "") - .split(/\s+/) - .filter((w) => w.length > 0).length; - const prevText = (prevLine.text ?? "").trim(); - const currentText = (currentLine.text ?? "").trim(); - - // Bullet detection - look for bullet markers or very short lines - const bulletMarkerRegex = - /^[\u2022\u2023\u25E6\u2043\u2219•·◦‣⁃\-*]\s|^\d+[.)]\s|^[a-z][.)]\s/i; - const prevHasBulletMarker = bulletMarkerRegex.test(prevText); - const currentHasBulletMarker = bulletMarkerRegex.test(currentText); - - // True bullets are: - // 1. Have bullet markers/numbers OR - // 2. Very short (< 10 words) AND much narrower than average (< 60% of page width) - const headingKeywords = [ - "action items", - "next steps", - "notes", - "logistics", - "tasks", - ]; - const normalizedPageWidth = pageWidth > 0 ? pageWidth : avgFontSize * 70; - const maxReferenceWidth = - normalizedPageWidth > 0 ? normalizedPageWidth : avgFontSize * 70; - const indentDelta = currentLeft - prevLeft; - const indentThreshold = Math.max(avgFontSize * 0.6, 8); - const hasIndent = indentDelta > indentThreshold; - const currentWidthRatio = - maxReferenceWidth > 0 ? currentWidth / maxReferenceWidth : 0; - const prevWidthRatio = - maxReferenceWidth > 0 ? prevWidth / maxReferenceWidth : 0; - const prevLooksLikeHeading = - prevText.endsWith(":") || - (prevWords <= 4 && prevWidthRatio < 0.4) || - headingKeywords.some((keyword) => - prevText.toLowerCase().includes(keyword), - ); - - const wrapCandidate = - !currentHasBulletMarker && - !hasIndent && - !prevLooksLikeHeading && - currentWords <= 12 && - currentWidthRatio < 0.45 && - Math.abs(prevLeft - currentLeft) <= leftAlignmentTolerance && - currentWidth < prevWidth * 0.85; - - const currentIsBullet = wrapCandidate - ? false - : currentHasBulletMarker || - (hasIndent && (currentWords <= 14 || currentWidthRatio <= 0.65)) || - (prevLooksLikeHeading && - (currentWords <= 16 || - currentWidthRatio <= 0.8 || - prevWidthRatio < 0.35)) || - (currentWords <= 8 && - currentWidthRatio <= 0.45 && - prevWidth - currentWidth > avgFontSize * 4); - - const prevIsBullet = bulletFlags.get(prevLine.id) ?? prevHasBulletMarker; - bulletFlags.set(currentLine.id, currentIsBullet); - - // Detect paragraph→bullet transition - const likelyBulletStart = !prevIsBullet && currentIsBullet; - - // Don't merge two consecutive bullets - const bothAreBullets = prevIsBullet && currentIsBullet; - - // Merge into paragraph if: - // 1. Left aligned - // 2. Same font - // 3. Reasonable line spacing - // 4. NOT transitioning to bullets - // 5. NOT both are bullets - const shouldMerge = - isLeftAligned && - sameFont && - hasReasonableSpacing && - !likelyBulletStart && - !bothAreBullets && - !currentIsBullet; - - if (i < 10 || likelyBulletStart || bothAreBullets || !shouldMerge) { - console.log(` Line ${i}:`); - console.log( - ` prev: "${prevText.substring(0, 40)}" (${prevWords}w, ${prevWidth.toFixed(0)}pt, marker:${prevHasBulletMarker}, bullet:${prevIsBullet})`, - ); - console.log( - ` curr: "${currentText.substring(0, 40)}" (${currentWords}w, ${currentWidth.toFixed(0)}pt, marker:${currentHasBulletMarker}, bullet:${currentIsBullet})`, - ); - console.log( - ` checks: leftAlign:${isLeftAligned} (${Math.abs(prevLeft - currentLeft).toFixed(1)}pt), sameFont:${sameFont}, spacing:${hasReasonableSpacing} (${lineSpacing.toFixed(1)}pt/${maxReasonableSpacing.toFixed(1)}pt)`, - ); - console.log( - ` decision: merge=${shouldMerge} (bulletStart:${likelyBulletStart}, bothBullets:${bothAreBullets})`, - ); - } - - if (shouldMerge) { - currentParagraph.push(currentLine); - } else { - paragraphs.push(currentParagraph); - currentParagraph = [currentLine]; - } - } - - // Don't forget the last paragraph - if (currentParagraph.length > 0) { - paragraphs.push(currentParagraph); - } - - // Merge line groups into single paragraph groups - return paragraphs.map((lines, _paragraphIndex) => { - if (lines.length === 1) { - return lines[0]; - } - - // Combine all elements from all lines - const lineTemplates = lines.map((line) => cloneLineTemplate(line)); - const flattenedLineTemplates = lineTemplates.flatMap((line) => - line.childLineGroups && line.childLineGroups.length > 0 - ? line.childLineGroups - : [line], - ); - const allLines = - flattenedLineTemplates.length > 0 - ? flattenedLineTemplates - : lineTemplates; - const allElements = allLines.flatMap((line) => line.originalElements); - const pageIndex = lines[0].pageIndex; - const lineElementCounts = allLines.map( - (line) => line.originalElements.length, - ); - - // Create merged group with newlines between lines - const paragraphText = allLines.map((line) => line.text).join("\n"); - const mergedBounds = mergeBounds(allLines.map((line) => line.bounds)); - const spacingValues: number[] = []; - for (let i = 1; i < allLines.length; i++) { - const prevBaseline = - allLines[i - 1].baseline ?? allLines[i - 1].bounds.bottom; - const currentBaseline = allLines[i].baseline ?? allLines[i].bounds.bottom; - const spacing = Math.abs(prevBaseline - currentBaseline); - if (spacing > 0) { - spacingValues.push(spacing); - } - } - const averageSpacing = - spacingValues.length > 0 - ? spacingValues.reduce((sum, value) => sum + value, 0) / - spacingValues.length - : null; - - const firstElement = allElements[0]; - const rotation = computeGroupRotation(allElements); - const anchor = rotation !== null ? getAnchorPoint(firstElement) : null; - const baselineLength = computeBaselineLength(allElements, metrics); - const baseline = computeAverageBaseline(allElements); - - return { - id: lines[0].id, // Keep the first line's ID - pageIndex, - fontId: firstElement?.fontId, - fontSize: firstElement?.fontSize, - fontMatrixSize: firstElement?.fontMatrixSize, - lineSpacing: averageSpacing, - lineElementCounts: lines.length > 1 ? lineElementCounts : null, - color: firstElement ? extractColor(firstElement) : null, - fontWeight: null, - rotation, - anchor, - baselineLength, - baseline, - elements: allElements.map(cloneTextElement), - originalElements: allElements.map(cloneTextElement), - text: paragraphText, - originalText: paragraphText, - bounds: mergedBounds, - childLineGroups: allLines, - }; - }); -}; - -export const groupPageTextElements = ( - page: PdfJsonPage | null | undefined, - pageIndex: number, - metrics?: FontMetricsMap, - groupingMode: "auto" | "paragraph" | "singleLine" = "auto", -): TextGroup[] => { - if (!page?.textElements || page.textElements.length === 0) { - return []; - } - - const pageWidth = valueOr(page.width, DEFAULT_PAGE_WIDTH); - - const elements = page.textElements - .map(cloneTextElement) - .filter((element) => element.text !== null && element.text !== undefined); - - elements.sort((a, b) => getBaseline(b) - getBaseline(a)); - - const lines: { baseline: number; elements: PdfJsonTextElement[] }[] = []; - - elements.forEach((element) => { - const baseline = getBaseline(element); - const fontSize = getFontSize(element); - const tolerance = Math.max(LINE_TOLERANCE, fontSize * 0.12); - - const existingLine = lines.find( - (line) => Math.abs(line.baseline - baseline) <= tolerance, - ); - - if (existingLine) { - existingLine.elements.push(element); - } else { - lines.push({ baseline, elements: [element] }); - } - }); - - lines.forEach((line) => { - line.elements.sort((a, b) => getX(a) - getX(b)); - }); - - let groupCounter = 0; - const lineGroups: TextGroup[] = []; - - lines.forEach((line) => { - let currentBucket: PdfJsonTextElement[] = []; - - line.elements.forEach((element) => { - if (currentBucket.length === 0) { - currentBucket.push(element); - return; - } - - const previous = currentBucket[currentBucket.length - 1]; - const gap = - getX(element) - (getX(previous) + getWidth(previous, metrics)); - const avgFontSize = (getFontSize(previous) + getFontSize(element)) / 2; - const splitThreshold = Math.max(SPACE_MIN_GAP, avgFontSize * GAP_FACTOR); - - const sameFont = previous.fontId === element.fontId; - let shouldSplit = gap > splitThreshold * (sameFont ? 1.4 : 1.0); - - if (shouldSplit) { - const prevBaseline = getBaseline(previous); - const currentBaseline = getBaseline(element); - const baselineDelta = Math.abs(prevBaseline - currentBaseline); - const prevEndX = getX(previous) + getWidth(previous, metrics); - const _prevEndY = prevBaseline; - const diagonalGap = Math.hypot( - Math.max(0, getX(element) - prevEndX), - baselineDelta, - ); - const diagonalThreshold = Math.max(avgFontSize * 0.8, splitThreshold); - if (diagonalGap <= diagonalThreshold) { - shouldSplit = false; - } - } - - const previousRotation = extractElementRotation(previous); - const currentRotation = extractElementRotation(element); - if ( - shouldSplit && - previousRotation !== null && - currentRotation !== null && - Math.abs(normalizeAngle(previousRotation - currentRotation)) < 1 - ) { - shouldSplit = false; - } - - if (shouldSplit) { - lineGroups.push( - createGroup(pageIndex, groupCounter, currentBucket, metrics), - ); - groupCounter += 1; - currentBucket = [element]; - } else { - currentBucket.push(element); - } - }); - - if (currentBucket.length > 0) { - lineGroups.push( - createGroup(pageIndex, groupCounter, currentBucket, metrics), - ); - groupCounter += 1; - } - }); - - // Apply paragraph grouping based on mode - if (groupingMode === "singleLine") { - // Single line mode: skip paragraph grouping - return lineGroups; - } - - if (groupingMode === "paragraph") { - // Paragraph mode: always apply grouping - return groupLinesIntoParagraphs(lineGroups, pageWidth, metrics); - } - - // Auto mode: use heuristic to determine if we should group - // Analyze the page content to decide - let multiLineGroups = 0; - let totalWords = 0; - let longTextGroups = 0; - let totalGroups = 0; - const wordCounts: number[] = []; - let fullWidthLines = 0; - - // Define "full width" as extending to at least 70% of page width - const fullWidthThreshold = pageWidth * 0.7; - - lineGroups.forEach((group) => { - const text = (group.text || "").trim(); - if (text.length === 0) return; - - totalGroups++; - const lines = text.split("\n"); - const lineCount = lines.length; - const wordCount = text.split(/\s+/).filter((w) => w.length > 0).length; - - totalWords += wordCount; - wordCounts.push(wordCount); - - if (lineCount > 1) { - multiLineGroups++; - } - - if (wordCount >= 10 || text.length >= 50) { - longTextGroups++; - } - - // Check if this line extends close to the right margin (paragraph-like) - const rightEdge = group.bounds.right; - if (rightEdge >= fullWidthThreshold) { - fullWidthLines++; - } - }); - - if (totalGroups === 0) { - return lineGroups; - } - - const avgWordsPerGroup = totalWords / totalGroups; - const longTextRatio = longTextGroups / totalGroups; - const fullWidthRatio = fullWidthLines / totalGroups; - - // Calculate variance in line lengths (paragraphs have varying lengths, lists are uniform) - const variance = - wordCounts.reduce((sum, count) => { - const diff = count - avgWordsPerGroup; - return sum + diff * diff; - }, 0) / totalGroups; - const stdDev = Math.sqrt(variance); - const coefficientOfVariation = - avgWordsPerGroup > 0 ? stdDev / avgWordsPerGroup : 0; - - // Check each criterion - const criterion1 = avgWordsPerGroup > 5; - const criterion2 = longTextRatio > 0.4; - const criterion3 = coefficientOfVariation > 0.5 || fullWidthRatio > 0.6; // High variance OR many full-width lines = paragraph text - - const isParagraphPage = criterion1 && criterion2 && criterion3; - - // Log detection stats - console.log( - `📄 Page ${pageIndex} Grouping Analysis (mode: ${groupingMode}):`, - ); - console.log(` Stats:`); - console.log( - ` • Page width: ${pageWidth.toFixed(1)}pt (full-width threshold: ${fullWidthThreshold.toFixed(1)}pt)`, - ); - console.log(` • Multi-line groups: ${multiLineGroups}`); - console.log(` • Total groups: ${totalGroups}`); - console.log(` • Total words: ${totalWords}`); - console.log( - ` • Long text groups (≥10 words or ≥50 chars): ${longTextGroups}`, - ); - console.log(` • Full-width lines (≥70% page width): ${fullWidthLines}`); - console.log(` • Avg words per group: ${avgWordsPerGroup.toFixed(2)}`); - console.log(` • Long text ratio: ${(longTextRatio * 100).toFixed(1)}%`); - console.log(` • Full-width ratio: ${(fullWidthRatio * 100).toFixed(1)}%`); - console.log(` • Std deviation: ${stdDev.toFixed(2)}`); - console.log( - ` • Coefficient of variation: ${coefficientOfVariation.toFixed(2)}`, - ); - console.log(` Criteria:`); - console.log( - ` 1. Avg Words Per Group: ${criterion1 ? "✅ PASS" : "❌ FAIL"}`, - ); - console.log(` (${avgWordsPerGroup.toFixed(2)} > 5)`); - console.log(` 2. Long Text Ratio: ${criterion2 ? "✅ PASS" : "❌ FAIL"}`); - console.log(` (${(longTextRatio * 100).toFixed(1)}% > 40%)`); - console.log( - ` 3. Line Width Pattern: ${criterion3 ? "✅ PASS" : "❌ FAIL"}`, - ); - console.log( - ` (CV ${coefficientOfVariation.toFixed(2)} > 0.5 OR ${(fullWidthRatio * 100).toFixed(1)}% > 60%)`, - ); - console.log( - ` ${coefficientOfVariation > 0.5 ? "✓ High variance (varying line lengths)" : "✗ Low variance"} ${fullWidthRatio > 0.6 ? "✓ Many full-width lines (paragraph-like)" : "✗ Few full-width lines (list-like)"}`, - ); - console.log( - ` Decision: ${isParagraphPage ? "📝 PARAGRAPH MODE" : "📋 LINE MODE"}`, - ); - if (isParagraphPage) { - console.log(` Reason: All three criteria passed (AND logic)`); - } else { - const failedReasons = []; - if (!criterion1) failedReasons.push("low average words per group"); - if (!criterion2) failedReasons.push("low ratio of long text groups"); - if (!criterion3) - failedReasons.push( - "low variance and few full-width lines (list-like structure)", - ); - console.log(` Reason: ${failedReasons.join(", ")}`); - } - console.log(""); - - // Only apply paragraph grouping if it looks like a paragraph-heavy page - if (isParagraphPage) { - console.log(`🔀 Applying paragraph grouping to page ${pageIndex}`); - return groupLinesIntoParagraphs(lineGroups, pageWidth, metrics); - } - - // For sparse pages, keep lines separate - console.log(`📋 Keeping lines separate for page ${pageIndex}`); - return lineGroups; -}; - -export const groupDocumentText = ( - document: PdfJsonDocument | null | undefined, - groupingMode: "auto" | "paragraph" | "singleLine" = "auto", -): TextGroup[][] => { - const pages = document?.pages ?? []; - const metrics = buildFontMetrics(document); - return pages.map((page, index) => - groupPageTextElements(page, index, metrics, groupingMode), - ); -}; - -export const extractPageImages = ( - page: PdfJsonPage | null | undefined, - pageIndex: number, -): PdfJsonImageElement[] => { - const images = page?.imageElements ?? []; - return images.map((image, imageIndex) => { - const clone = cloneImageElement(image); - if (!clone.id || clone.id.trim().length === 0) { - clone.id = `page-${pageIndex}-image-${imageIndex}`; - } - return clone; - }); -}; - -export const extractDocumentImages = ( - document: PdfJsonDocument | null | undefined, -): PdfJsonImageElement[][] => { - const pages = document?.pages ?? []; - return pages.map((page, index) => extractPageImages(page, index)); -}; - -export const deepCloneDocument = ( - document: PdfJsonDocument, -): PdfJsonDocument => { - if (typeof structuredClone === "function") { - return structuredClone(document); - } - return JSON.parse(JSON.stringify(document)); -}; - -export const pageDimensions = ( - page: PdfJsonPage | null | undefined, -): { width: number; height: number } => { - const width = valueOr(page?.width, DEFAULT_PAGE_WIDTH); - const height = valueOr(page?.height, DEFAULT_PAGE_HEIGHT); - - console.log(`📏 [pageDimensions] Calculating page size:`, { - hasPage: !!page, - rawWidth: page?.width, - rawHeight: page?.height, - mediaBox: page?.mediaBox, - cropBox: page?.cropBox, - rotation: page?.rotation, - calculatedWidth: width, - calculatedHeight: height, - DEFAULT_PAGE_WIDTH, - DEFAULT_PAGE_HEIGHT, - commonFormats: { - "US Letter": "612 × 792 pt", - A4: "595 × 842 pt", - Legal: "612 × 1008 pt", - }, - }); - - return { width, height }; -}; - -export const createMergedElement = (group: TextGroup): PdfJsonTextElement => { - const reference = group.originalElements[0]; - const merged = cloneTextElement(reference); - merged.text = sanitizeParagraphText(group.text); - clearGlyphHints(merged); - if (reference.textMatrix && reference.textMatrix.length === 6) { - merged.textMatrix = [...reference.textMatrix]; - } - return merged; -}; - -const distributeTextAcrossElements = ( - text: string | undefined, - elements: PdfJsonTextElement[], -): boolean => { - if (elements.length === 0) { - return true; - } - - const normalizedText = sanitizeParagraphText(text); - const targetChars = Array.from(normalizedText); - if (targetChars.length === 0) { - elements.forEach((element) => { - element.text = ""; - clearGlyphHints(element); - }); - return true; - } - - const capacities = elements.map((element) => { - const originalText = element.text ?? ""; - const graphemeCount = Array.from(originalText).length; - return graphemeCount > 0 ? graphemeCount : 1; - }); - - let cursor = 0; - elements.forEach((element, index) => { - const remaining = targetChars.length - cursor; - let sliceLength = 0; - if (remaining > 0) { - if (index === elements.length - 1) { - sliceLength = remaining; - } else { - const capacity = Math.max(capacities[index], 1); - const minRemainingForRest = Math.max(elements.length - index - 1, 0); - sliceLength = Math.min( - capacity, - Math.max(remaining - minRemainingForRest, 1), - ); - } - } - - element.text = - sliceLength > 0 - ? targetChars.slice(cursor, cursor + sliceLength).join("") - : ""; - clearGlyphHints(element); - cursor += sliceLength; - }); - - elements.forEach((element) => { - if (element.text == null) { - element.text = ""; - } - }); - - return true; -}; - -const sliceElementsByLineCounts = ( - group: TextGroup, -): PdfJsonTextElement[][] => { - const counts = group.lineElementCounts; - if (!counts || counts.length === 0) { - if (!group.originalElements.length) { - return []; - } - return [group.originalElements]; - } - - const result: PdfJsonTextElement[][] = []; - let cursor = 0; - counts.forEach((count) => { - if (count <= 0) { - return; - } - const slice = group.originalElements.slice(cursor, cursor + count); - if (slice.length > 0) { - result.push(slice); - } - cursor += count; - }); - return result; -}; - -const rebuildParagraphLineElements = ( - group: TextGroup, -): PdfJsonTextElement[] | null => { - if (!group.text || !group.text.includes("\n")) { - return null; - } - - const lineTexts = splitParagraphIntoLines(group.text); - if (lineTexts.length === 0) { - return []; - } - - const lineElementGroups = sliceElementsByLineCounts(group); - if (!lineElementGroups.length) { - return null; - } - - const lineBaselines = lineElementGroups.map((elements) => { - for (const element of elements) { - const baseline = extractElementBaseline(element); - if (baseline !== null) { - return baseline; - } - } - return group.baseline ?? null; - }); - - const spacingFromBaselines = (() => { - for (let i = 1; i < lineBaselines.length; i += 1) { - const prev = lineBaselines[i - 1]; - const current = lineBaselines[i]; - if (prev !== null && current !== null) { - const diff = Math.abs(prev - current); - if (diff > 0) { - return diff; - } - } - } - return null; - })(); - - const spacing = - (group.lineSpacing && group.lineSpacing > 0 - ? group.lineSpacing - : spacingFromBaselines) ?? - Math.max(group.fontMatrixSize ?? group.fontSize ?? 12, 6) * 1.2; - - let direction = -1; - for (let i = 1; i < lineBaselines.length; i += 1) { - const prev = lineBaselines[i - 1]; - const current = lineBaselines[i]; - if (prev !== null && current !== null && Math.abs(prev - current) > 0.05) { - direction = current < prev ? -1 : 1; - break; - } - } - - const templateCount = lineElementGroups.length; - const lastTemplateIndex = Math.max(templateCount - 1, 0); - const rebuilt: PdfJsonTextElement[] = []; - - for (let index = 0; index < lineTexts.length; index += 1) { - const templateIndex = Math.min(index, lastTemplateIndex); - const templateElements = lineElementGroups[templateIndex]; - if (!templateElements || templateElements.length === 0) { - return null; - } - - const shiftSteps = index - templateIndex; - const delta = shiftSteps * spacing * direction; - const clones = shiftElementsBy(templateElements, delta); - const normalizedLine = sanitizeParagraphText(lineTexts[index]); - const distributed = distributeTextAcrossElements(normalizedLine, clones); - - if (!distributed) { - const primary = clones[0]; - primary.text = normalizedLine; - clearGlyphHints(primary); - for (let i = 1; i < clones.length; i += 1) { - clones[i].text = ""; - clearGlyphHints(clones[i]); - } - } - - rebuilt.push(...clones); - } - - return rebuilt; -}; - -export const restoreGlyphElements = ( - source: PdfJsonDocument, - groupsByPage: TextGroup[][], - imagesByPage: PdfJsonImageElement[][], - originalImagesByPage: PdfJsonImageElement[][], - forceMergedGroups: boolean = false, -): PdfJsonDocument => { - const updated = deepCloneDocument(source); - const pages = updated.pages ?? []; - - updated.pages = pages.map((page, pageIndex) => { - const groups = groupsByPage[pageIndex] ?? []; - const images = imagesByPage[pageIndex] ?? []; - const _baselineImages = originalImagesByPage[pageIndex] ?? []; - - if (!groups.length) { - return { - ...page, - imageElements: images.map(cloneImageElement), - }; - } - - const rebuiltElements: PdfJsonTextElement[] = []; - - groups.forEach((group) => { - if (group.text !== group.originalText) { - // Always try to rebuild paragraph lines if text has newlines - const paragraphElements = rebuildParagraphLineElements(group); - if (paragraphElements && paragraphElements.length > 0) { - rebuiltElements.push(...paragraphElements); - return; - } - // If no newlines or rebuilding failed, check if we should force merge - if (forceMergedGroups) { - rebuiltElements.push(createMergedElement(group)); - return; - } - const originalGlyphCount = group.originalElements.reduce( - (sum, element) => sum + countGraphemes(element.text ?? ""), - 0, - ); - const normalizedText = sanitizeParagraphText(group.text); - const targetGlyphCount = countGraphemes(normalizedText); - - if (targetGlyphCount !== originalGlyphCount) { - rebuiltElements.push(createMergedElement(group)); - return; - } - - const originals = group.originalElements.map(cloneTextElement); - const distributed = distributeTextAcrossElements( - normalizedText, - originals, - ); - if (distributed) { - rebuiltElements.push(...originals); - } else { - rebuiltElements.push(createMergedElement(group)); - } - return; - } - - rebuiltElements.push(...group.originalElements.map(cloneTextElement)); - }); - - return { - ...page, - textElements: rebuiltElements, - imageElements: images.map(cloneImageElement), - contentStreams: page.contentStreams ?? null, - }; - }); - - return updated; -}; - -const approxEqual = ( - a: number | null | undefined, - b: number | null | undefined, - tolerance = 0.25, -): boolean => { - const first = typeof a === "number" && Number.isFinite(a) ? a : 0; - const second = typeof b === "number" && Number.isFinite(b) ? b : 0; - return Math.abs(first - second) <= tolerance; -}; - -const arrayApproxEqual = ( - first: number[] | null | undefined, - second: number[] | null | undefined, - tolerance = 0.25, -): boolean => { - if (!first && !second) { - return true; - } - if (!first || !second) { - return false; - } - if (first.length !== second.length) { - return false; - } - for (let index = 0; index < first.length; index += 1) { - if (!approxEqual(first[index], second[index], tolerance)) { - return false; - } - } - return true; -}; - -const areImageElementsEqual = ( - current: PdfJsonImageElement, - original: PdfJsonImageElement, -): boolean => { - if (current === original) { - return true; - } - if (!current || !original) { - return false; - } - - const sameData = (current.imageData ?? null) === (original.imageData ?? null); - const sameFormat = - (current.imageFormat ?? null) === (original.imageFormat ?? null); - - return ( - sameData && - sameFormat && - approxEqual(current.x, original.x) && - approxEqual(current.y, original.y) && - approxEqual(current.width, original.width) && - approxEqual(current.height, original.height) && - approxEqual(current.left, original.left) && - approxEqual(current.right, original.right) && - approxEqual(current.top, original.top) && - approxEqual(current.bottom, original.bottom) && - (current.zOrder ?? null) === (original.zOrder ?? null) && - arrayApproxEqual(current.transform, original.transform) - ); -}; - -export const areImageListsDifferent = ( - current: PdfJsonImageElement[], - original: PdfJsonImageElement[], -): boolean => { - if (current.length !== original.length) { - return true; - } - for (let index = 0; index < current.length; index += 1) { - if (!areImageElementsEqual(current[index], original[index])) { - return true; - } - } - return false; -}; - -export const getDirtyPages = ( - groupsByPage: TextGroup[][], - imagesByPage: PdfJsonImageElement[][], - originalGroupsByPage: TextGroup[][], - originalImagesByPage: PdfJsonImageElement[][], -): boolean[] => { - return groupsByPage.map((groups, index) => { - // Check if any text was modified - const textDirty = groups.some((group) => group.text !== group.originalText); - - // Check if any groups were deleted by comparing with original groups - const originalGroups = originalGroupsByPage[index] ?? []; - const groupCountChanged = groups.length !== originalGroups.length; - - const imageDirty = areImageListsDifferent( - imagesByPage[index] ?? [], - originalImagesByPage[index] ?? [], - ); - - const isDirty = textDirty || groupCountChanged || imageDirty; - - if (groupCountChanged || textDirty) { - console.log(`📄 Page ${index} dirty check:`, { - textDirty, - groupCountChanged, - originalGroupsLength: originalGroups.length, - currentGroupsLength: groups.length, - imageDirty, - isDirty, - }); - } - - return isDirty; - }); -}; diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/bytes.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/bytes.ts new file mode 100644 index 0000000000..48d85bc87e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/bytes.ts @@ -0,0 +1,145 @@ +/** + * Byte <-> latin1-string helpers for the raw-PDF layer. + * + * The surgery passes all want the file as a string so they can use the + * regex engine on it, but a 12 MB book costs real time to convert - and + * several passes run back to back over the same buffer. Memoise on the + * buffer identity so it converts once per document, not once per pass. + */ + +const cache = new WeakMap(); + +/** Chunked so `String.fromCharCode.apply` never blows the argument limit. */ +export function toLatin1(bytes: Uint8Array): string { + const hit = cache.get(bytes); + if (hit !== undefined) return hit; + let out = ""; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + out += String.fromCharCode.apply( + null, + bytes.subarray( + i, + Math.min(i + CHUNK, bytes.length), + ) as unknown as number[], + ); + } + cache.set(bytes, out); + return out; +} + +export function fromLatin1(text: string): Uint8Array { + const out = new Uint8Array(text.length); + for (let i = 0; i < text.length; i += 1) out[i] = text.charCodeAt(i) & 0xff; + return out; +} + +export function concatBytes(parts: Uint8Array[]): Uint8Array { + let total = 0; + for (const p of parts) total += p.length; + const out = new Uint8Array(total); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; +} + +/** + * Undo a PNG predictor (`/DecodeParms << /Predictor 12 ... >>`). + * + * Cross-reference streams almost always use predictor 12, so this is on the + * critical path for reading any PDF 1.5+ file. + */ +export function undoPngPredictor( + data: Uint8Array, + colors: number, + bpc: number, + columns: number, +): Uint8Array { + const bpp = Math.max(1, Math.ceil((colors * bpc) / 8)); + const rowLen = Math.ceil((colors * bpc * columns) / 8); + const rows = Math.floor(data.length / (rowLen + 1)); + const out = new Uint8Array(rows * rowLen); + let prev = new Uint8Array(rowLen); + for (let r = 0; r < rows; r += 1) { + const tag = data[r * (rowLen + 1)]; + const src = data.subarray(r * (rowLen + 1) + 1, (r + 1) * (rowLen + 1)); + const cur = new Uint8Array(rowLen); + for (let i = 0; i < rowLen; i += 1) { + const raw = src[i] ?? 0; + const left = i >= bpp ? cur[i - bpp] : 0; + const up = prev[i]; + const upLeft = i >= bpp ? prev[i - bpp] : 0; + switch (tag) { + case 0: + cur[i] = raw; + break; + case 1: + cur[i] = (raw + left) & 0xff; + break; + case 2: + cur[i] = (raw + up) & 0xff; + break; + case 3: + cur[i] = (raw + ((left + up) >> 1)) & 0xff; + break; + case 4: { + const p = left + up - upLeft; + const pa = Math.abs(p - left); + const pb = Math.abs(p - up); + const pc = Math.abs(p - upLeft); + const pred = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft; + cur[i] = (raw + pred) & 0xff; + break; + } + default: + cur[i] = raw; + break; + } + } + out.set(cur, r * rowLen); + prev = cur; + } + return out; +} + +async function throughStream( + data: Uint8Array, + format: CompressionFormat, + kind: "inflate" | "deflate", +): Promise { + const src = new Blob([data as BlobPart]).stream(); + const piped = + kind === "inflate" + ? src.pipeThrough(new DecompressionStream(format)) + : src.pipeThrough(new CompressionStream(format)); + const buf = await new Response(piped).arrayBuffer(); + return new Uint8Array(buf); +} + +/** + * Inflate a `/FlateDecode` stream. PDF's Flate is zlib-wrapped, but real + * files in the wild ship raw deflate often enough that the fallback earns + * its keep - a single malformed stream must not fail a whole document. + */ +export async function inflate(data: Uint8Array): Promise { + try { + return await throughStream(data, "deflate", "inflate"); + } catch { + try { + return await throughStream(data, "deflate-raw", "inflate"); + } catch { + return null; + } + } +} + +export async function deflate(data: Uint8Array): Promise { + try { + return await throughStream(data, "deflate", "deflate"); + } catch { + return null; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/contentOps.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/contentOps.ts new file mode 100644 index 0000000000..4816db6382 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/contentOps.ts @@ -0,0 +1,180 @@ +/** + * Minimal content-stream tokeniser. + * + * Just enough structure to find operators and their operands, treating + * strings, dictionaries and arrays as opaque single tokens so a `(` inside a + * text string can never be mistaken for syntax. + */ + +const WHITESPACE = new Set([" ", "\t", "\r", "\n", "\f", "\0"]); +const DELIMITER = new Set(["(", ")", "<", ">", "[", "]", "{", "}", "/", "%"]); + +export interface ContentToken { + text: string; + start: number; + end: number; +} + +export interface ContentOp { + /** Operator name, e.g. `Tj`, `cm`, `sh`. */ + op: string; + operands: string[]; + /** Byte offset of the first operand (or the operator when it has none). */ + start: number; + /** Byte offset one past the operator. */ + end: number; +} + +/** + * Hand-scanned rather than regex-driven: PDF literal strings nest their + * parentheses, which no regular expression can follow, and getting that + * wrong turns the rest of a stream into nonsense. + */ +export function tokenize(content: string): ContentToken[] { + const out: ContentToken[] = []; + let i = 0; + while (i < content.length) { + const ch = content[i]; + if (WHITESPACE.has(ch)) { + i += 1; + continue; + } + const start = i; + if (ch === "%") { + while (i < content.length && content[i] !== "\n" && content[i] !== "\r") { + i += 1; + } + continue; + } + if (ch === "(") { + i += 1; + let depth = 1; + while (i < content.length && depth > 0) { + const c = content[i]; + if (c === "\\") { + i += 2; + continue; + } + if (c === "(") depth += 1; + else if (c === ")") depth -= 1; + i += 1; + } + } else if (ch === "<" && content[i + 1] === "<") { + i += 2; + } else if (ch === ">" && content[i + 1] === ">") { + i += 2; + } else if (ch === "<") { + const close = content.indexOf(">", i); + i = close < 0 ? content.length : close + 1; + } else if (ch === "/") { + i += 1; + while ( + i < content.length && + !WHITESPACE.has(content[i]) && + !DELIMITER.has(content[i]) + ) { + i += 1; + } + } else if (DELIMITER.has(ch)) { + i += 1; + } else { + while ( + i < content.length && + !WHITESPACE.has(content[i]) && + !DELIMITER.has(content[i]) + ) { + i += 1; + } + } + out.push({ text: content.slice(start, i), start, end: i }); + } + return out; +} + +const IS_OPERATOR = /^[A-Za-z'"][A-Za-z0-9*'"]*$/; +const NON_OPERATOR = new Set(["true", "false", "null", "R"]); + +/** Group tokens into operator invocations. */ +export function parseOps(content: string): ContentOp[] { + const tokens = tokenize(content); + const ops: ContentOp[] = []; + let operands: string[] = []; + let operandStart = -1; + let inlineImage = false; + for (const t of tokens) { + // Inline images carry raw binary between ID and EI that must not be + // lexed at all. + if (inlineImage) { + if (t.text !== "EI") continue; + inlineImage = false; + ops.push({ op: "EI", operands: [], start: t.start, end: t.end }); + operands = []; + operandStart = -1; + continue; + } + if (IS_OPERATOR.test(t.text) && !NON_OPERATOR.has(t.text)) { + ops.push({ + op: t.text, + operands, + start: operandStart < 0 ? t.start : operandStart, + end: t.end, + }); + if (t.text === "BI" || t.text === "ID") inlineImage = true; + operands = []; + operandStart = -1; + continue; + } + if (operandStart < 0) operandStart = t.start; + operands.push(t.text); + } + return ops; +} + +/** Operators that show text. */ +export const TEXT_SHOWING = new Set(["Tj", "TJ", "'", '"']); + +/** Path-painting operators, all of which also end the current path. */ +export const PATH_PAINTING = new Set([ + "S", + "s", + "f", + "F", + "f*", + "B", + "B*", + "b", + "b*", + "n", +]); + +/** Path construction operators. */ +export const PATH_CONSTRUCTION = new Set(["m", "l", "c", "v", "y", "h", "re"]); + +/** Operators that only mutate graphics state. */ +export const STATE_ONLY = new Set([ + "q", + "Q", + "cm", + "gs", + "w", + "J", + "j", + "M", + "d", + "ri", + "i", + "cs", + "CS", + "sc", + "scn", + "SC", + "SCN", + "g", + "G", + "rg", + "RG", + "k", + "K", + "W", + "W*", +]); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/consolidateContents.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/consolidateContents.ts new file mode 100644 index 0000000000..bf7799a03f --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/consolidateContents.ts @@ -0,0 +1,92 @@ +/** + * Merge multi-part `/Contents` arrays into a single stream, at load time. + * + * A page may legally split its content across several streams, and some + * producers do it every few kilobytes. The array is defined to be the + * concatenation of its parts, but PDFium's content generator rewrites only + * the parts that own a modified object - so after one edit the page holds a + * freshly written first chunk followed by stale continuation chunks that no + * longer make sense in that graphics state. The page then renders wrongly, + * or not at all, once it is reloaded. + * + * Collapsing the array before the document is ever opened removes the whole + * failure mode, and is invisible to everything else: one stream in, one + * stream out, same bytes of content. + */ +import { + concatBytes, + deflate, + fromLatin1, +} from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import { RawPdf, spliceValue } from "@app/tools/pdfTextEditor/pdfdoc/raw"; +import { + appendRevision, + plainObject, + streamObject, + type RevisionObject, +} from "@app/tools/pdfTextEditor/pdfdoc/revision"; + +export interface ConsolidateResult { + bytes: Uint8Array; + /** Page indices whose content streams were merged. */ + pages: number[]; +} + +export async function consolidateContents( + bytes: Uint8Array, +): Promise { + const pdf = await RawPdf.parse(bytes); + if (!pdf) return null; + if (pdf.encrypted) return null; + + const pageNums = pdf.pageNumbers(); + const objects: RevisionObject[] = []; + const merged: number[] = []; + let nextNum = pdf.highestObjectNumber + 1; + + for (let pageIndex = 0; pageIndex < pageNums.length; pageIndex += 1) { + const pageNum = pageNums[pageIndex]; + const body = pdf.objectBody(pageNum); + if (!body) continue; + const refs = pdf.contentRefs(body); + if (refs.length < 2) continue; + + const parts: Uint8Array[] = []; + let readable = true; + for (const ref of refs) { + const data = await pdf.streamData(ref); + if (!data) { + readable = false; + break; + } + parts.push(data); + // Parts join by concatenation, but a part ending mid-token would + // fuse with the next one's first token; a separator is always legal. + parts.push(fromLatin1("\n")); + } + if (!readable) continue; + + const span = pdf.valueSpan(body, "Contents"); + if (!span) continue; + + const raw = concatBytes(parts); + const packed = await deflate(raw); + const streamNum = nextNum; + nextNum += 1; + objects.push({ + num: streamNum, + body: packed + ? streamObject("<< /Filter /FlateDecode >>", packed) + : streamObject("<< >>", raw), + }); + objects.push({ + num: pageNum, + body: plainObject(spliceValue(body, span, `${streamNum} 0 R`)), + }); + merged.push(pageIndex); + } + + if (objects.length === 0) return null; + const out = appendRevision(pdf, objects); + return out ? { bytes: out, pages: merged } : null; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/preserveShadings.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/preserveShadings.ts new file mode 100644 index 0000000000..a16f1d23c6 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/preserveShadings.ts @@ -0,0 +1,253 @@ +/** + * Keep vector gradients when a page is regenerated. + * + * PDFium's content generator serialises text, paths and images. A shading + * painted with the `sh` operator is none of those, so it is simply absent + * from the regenerated stream - the gradient disappears from every page the + * user edited, while the shading dictionaries and the resource names that + * point at them survive untouched in the saved file. + * + * That asymmetry is the repair: re-derive the original draw operators from + * the file as it was opened, and append them to the saved page as an extra + * content stream. The names still resolve, so the gradients come back as + * true vectors rather than a rasterised approximation. + * + * Rather than copying a byte range and hoping it is self-contained, the + * original stream is replayed through a filter that keeps everything + * affecting graphics state, neuters anything that would paint, and drops + * text and XObjects entirely. What is left reproduces the exact state each + * `sh` was drawn in, and paints nothing else. + */ +import { + deflate, + fromLatin1, + toLatin1, +} from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import { + PATH_PAINTING, + parseOps, + TEXT_SHOWING, +} from "@app/tools/pdfTextEditor/pdfdoc/contentOps"; +import { RawPdf, spliceValue } from "@app/tools/pdfTextEditor/pdfdoc/raw"; +import { + appendRevision, + plainObject, + streamObject, + type RevisionObject, +} from "@app/tools/pdfTextEditor/pdfdoc/revision"; + +const MARKED_CONTENT = new Set(["BDC", "BMC", "EMC", "MP", "DP"]); + +export type ShadingPhase = "all" | "background" | "foreground"; + +interface ExtractedShading { + /** Content-stream fragment that redraws every shading on the page. */ + content: string; + /** Resource names the fragment depends on, by resource category. */ + needs: { shading: string[]; extGState: string[]; pattern: string[] }; + /** True when the first shading precedes any text on the page. */ + isBackground: boolean; +} + +/** + * Replay a page's content, keeping only what is needed to redraw its + * shadings. Returns null when the page has none. + */ +export function extractShadingDraws( + content: string, + phase: ShadingPhase = "all", +): ExtractedShading | null { + const ops = parseOps(content); + const firstText = ops.findIndex((o) => TEXT_SHOWING.has(o.op)); + const wanted = (index: number): boolean => { + if (phase === "all" || firstText < 0) return true; + return phase === "background" ? index < firstText : index > firstText; + }; + const shIndexes = ops + .map((o, i) => (o.op === "sh" ? i : -1)) + .filter((i) => i >= 0 && wanted(i)); + if (shIndexes.length === 0) return null; + + const lastShading = shIndexes[shIndexes.length - 1]; + const shading: string[] = []; + const extGState: string[] = []; + const pattern: string[] = []; + const out: string[] = []; + let depth = 0; + let inText = false; + + for (let i = 0; i <= lastShading; i += 1) { + const op = ops[i]; + if (op.op === "BT") { + inText = true; + continue; + } + if (op.op === "ET") { + inText = false; + continue; + } + // Text positioning and font selection are scoped to the text object, so + // nothing inside BT..ET can influence a shading drawn outside it. + if (inText) continue; + if (op.op === "BI" || op.op === "ID" || op.op === "EI") continue; + // Marked content affects nothing a shading paints, and a BDC kept past + // the last `sh` without its EMC would swallow the rest of the page into + // an optional-content section. + if (MARKED_CONTENT.has(op.op)) continue; + // An XObject invocation could itself paint; the shadings it may contain + // live in the form's own stream, which regeneration never rewrites. + if (op.op === "Do") continue; + if (op.op === "sh") { + const name = op.operands[op.operands.length - 1]; + if (!name || name[0] !== "/") return null; + // Out-of-phase shadings still contribute nothing but must not paint. + if (!wanted(i)) continue; + shading.push(name.slice(1)); + out.push(`${name} sh`); + continue; + } + if (op.op === "gs") { + const name = op.operands[op.operands.length - 1]; + // A malformed `gs` would otherwise emit the literal token "undefined". + if (!name || name[0] !== "/") continue; + extGState.push(name.slice(1)); + out.push(`${name} gs`); + continue; + } + if (op.op === "scn" || op.op === "SCN") { + const last = op.operands[op.operands.length - 1]; + if (last && last[0] === "/") pattern.push(last.slice(1)); + out.push(`${op.operands.join(" ")} ${op.op}`); + continue; + } + if (PATH_PAINTING.has(op.op)) { + // Keep the path - a preceding `W` may be using it as a clip - but end + // it without painting, so only the shadings put ink on the page. + out.push("n"); + continue; + } + if (op.op === "q") depth += 1; + if (op.op === "Q") { + if (depth === 0) continue; + depth -= 1; + } + out.push(op.operands.length ? `${op.operands.join(" ")} ${op.op}` : op.op); + } + + // The fragment is concatenated with content that assumes a clean state. + for (let i = 0; i < depth; i += 1) out.push("Q"); + + if (shading.length === 0) return null; + return { + content: `q\n${out.join("\n")}\nQ\n`, + needs: { + shading: [...new Set(shading)], + extGState: [...new Set(extGState)], + pattern: [...new Set(pattern)], + }, + isBackground: firstText < 0 || shIndexes[0] < firstText, + }; +} + +/** True when `resources` declares `name` under `/Category`. */ +function resourceHasName( + pdf: RawPdf, + resources: string | null, + category: string, + name: string, +): boolean { + if (!resources) return false; + const sub = pdf.resolve(resources, category); + if (!sub) return false; + return new RegExp(`/${escapeName(name)}(?![^\\s/<>()\\[\\]{}%])`).test(sub); +} + +function escapeName(name: string): string { + return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export interface PreserveShadingsOptions { + /** Page indices that were regenerated and may have lost their shadings. */ + pages: number[]; +} + +/** + * Re-inject shading draws into `savedBytes`, using `originalBytes` as the + * source of truth. Returns null when nothing could be applied safely - the + * caller keeps the saved bytes as they are. + */ +export async function preserveShadings( + savedBytes: Uint8Array, + originalBytes: Uint8Array, + options: PreserveShadingsOptions, +): Promise { + if (options.pages.length === 0) return null; + const original = await RawPdf.parse(originalBytes); + const saved = await RawPdf.parse(savedBytes); + if (!original || !saved) return null; + if (original.encrypted || saved.encrypted) return null; + + const objects: RevisionObject[] = []; + let nextNum = saved.highestObjectNumber + 1; + + for (const pageIndex of [...new Set(options.pages)].sort((a, b) => a - b)) { + const originalPageNum = original.pageNumberAt(pageIndex); + const savedPageNum = saved.pageNumberAt(pageIndex); + if (originalPageNum === null || savedPageNum === null) continue; + + const content = await original.pageContent(originalPageNum); + if (!content) continue; + const page = toLatin1(content); + const savedBody = saved.objectBody(savedPageNum); + if (!savedBody) continue; + const resources = saved.pageInherited(savedPageNum, "Resources"); + const existing = saved.contentRefs(savedBody); + if (existing.length === 0) continue; + const span = saved.valueSpan(savedBody, "Contents"); + if (!span) continue; + + // Split by phase: a gradient that sat under the text goes back under it, + // one that sat over it goes back over. A single fragment for the page put + // mid-page shadings on the wrong side of the content. + const before: number[] = []; + const after: number[] = []; + for (const phase of ["background", "foreground"] as const) { + const extracted = extractShadingDraws(page, phase); + if (!extracted) continue; + const resolvable = + extracted.needs.shading.every((n) => + resourceHasName(saved, resources, "Shading", n), + ) && + extracted.needs.extGState.every((n) => + resourceHasName(saved, resources, "ExtGState", n), + ) && + extracted.needs.pattern.every((n) => + resourceHasName(saved, resources, "Pattern", n), + ); + if (!resolvable) continue; + + const raw = fromLatin1(extracted.content); + const packed = await deflate(raw); + const streamNum = nextNum; + nextNum += 1; + objects.push({ + num: streamNum, + body: packed + ? streamObject("<< /Filter /FlateDecode >>", packed) + : streamObject("<< >>", raw), + }); + (phase === "background" ? before : after).push(streamNum); + } + if (before.length === 0 && after.length === 0) continue; + + const order = [...before, ...existing, ...after]; + const array = `[${order.map((n) => `${n} 0 R`).join(" ")}]`; + objects.push({ + num: savedPageNum, + body: plainObject(spliceValue(savedBody, span, array)), + }); + } + + if (objects.length === 0) return null; + return appendRevision(saved, objects); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/prepareForEditing.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/prepareForEditing.ts new file mode 100644 index 0000000000..84b8221512 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/prepareForEditing.ts @@ -0,0 +1,58 @@ +/** + * Load-time repairs, applied to the bytes before PDFium ever sees them. + * + * Each pass is optional and self-cancelling: it returns the original bytes + * unless it is certain it improved them. A document this cannot understand + * is opened exactly as it arrived, which is always a valid outcome. + */ +import { consolidateContents } from "@app/tools/pdfTextEditor/pdfdoc/passes/consolidateContents"; + +/** + * Above this the parse the passes need costs more than the repairs are worth, + * and the failure they guard against is rare in files this large. + */ +const MAX_PREPARE_BYTES = 96 * 1024 * 1024; + +export async function prepareForEditing( + bytes: Uint8Array, +): Promise { + if (bytes.length > MAX_PREPARE_BYTES) return bytes; + let out = bytes; + + // Scanned over the bytes, not a decoded string: this runs on every open, + // and converting a multi-megabyte file to a string just to answer "is + // there anything to do?" is pure latency on the load path. + if (hasContentsArray(out)) { + try { + const merged = await consolidateContents(out); + if (merged) out = merged.bytes; + } catch { + /* leaving the bytes alone is always safe */ + } + } + + return out; +} + +const CONTENTS = "/Contents"; +const WHITESPACE = new Set([0x20, 0x09, 0x0d, 0x0a, 0x0c, 0x00]); +const OPEN_BRACKET = 0x5b; + +/** True when some page's `/Contents` is an array rather than one stream. */ +function hasContentsArray(bytes: Uint8Array): boolean { + const first = CONTENTS.charCodeAt(0); + const limit = bytes.length - CONTENTS.length; + for (let i = 0; i < limit; i += 1) { + if (bytes[i] !== first) continue; + let k = 1; + while (k < CONTENTS.length && bytes[i + k] === CONTENTS.charCodeAt(k)) { + k += 1; + } + if (k < CONTENTS.length) continue; + let j = i + CONTENTS.length; + while (j < bytes.length && WHITESPACE.has(bytes[j])) j += 1; + if (bytes[j] === OPEN_BRACKET) return true; + i = j - 1; + } + return false; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/raw.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/raw.ts new file mode 100644 index 0000000000..8703775d35 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/raw.ts @@ -0,0 +1,686 @@ +/** + * A deliberately small read-only view over raw PDF bytes. + * + * PDFium's public API cannot express some of the repairs the editor needs + * (see `pdfdoc/passes/*`), so those passes work on the file itself. This is + * the shared substrate: one scan builds the object index, one walk builds + * the page list, and everything else is lookups. + * + * Everything here is best-effort by design. A file this cannot understand + * makes every accessor return null, and the calling pass leaves the bytes + * untouched rather than guessing. + */ +import { + fromLatin1, + inflate, + toLatin1, + undoPngPredictor, +} from "@app/tools/pdfTextEditor/pdfdoc/bytes"; + +/** No legitimate PDF object body runs longer than this. */ +const MAX_OBJECT_BYTES = 32 * 1024 * 1024; + +const WHITESPACE = new Set([" ", "\t", "\r", "\n", "\f", "\0"]); +const DELIMITER = new Set(["(", ")", "<", ">", "[", "]", "{", "}", "/", "%"]); + +/** Span of a dictionary entry's value inside an object body. */ +export interface ValueSpan { + /** Index of the first character of the value. */ + start: number; + /** Index one past the last character of the value. */ + end: number; + text: string; +} + +interface ObjectSource { + /** Generation the file declares for this object; almost always 0. */ + gen: number; + /** Byte offset of the object's `obj` keyword, for top-level objects. */ + offset?: number; + /** Pre-extracted body, for objects unpacked from an object stream. */ + body?: string; +} + +export class RawPdf { + readonly bytes: Uint8Array; + readonly src: string; + readonly rootNum: number; + /** True when the file's newest cross-reference section is a stream. */ + readonly usesXrefStream: boolean; + readonly startXref: number; + readonly trailerId: string | null; + /** True when the file has an /Encrypt dictionary. */ + readonly encrypted: boolean; + + private readonly objects: Map; + private readonly bodyCache = new Map(); + private pageNums: number[] | null = null; + /** Highest object number the file has ever used, across all revisions. */ + private highestObj: number; + + private constructor(init: { + bytes: Uint8Array; + src: string; + rootNum: number; + maxObjNum: number; + usesXrefStream: boolean; + startXref: number; + trailerId: string | null; + encrypted: boolean; + objects: Map; + }) { + this.bytes = init.bytes; + this.src = init.src; + this.rootNum = init.rootNum; + this.highestObj = init.maxObjNum; + this.usesXrefStream = init.usesXrefStream; + this.startXref = init.startXref; + this.trailerId = init.trailerId; + this.encrypted = init.encrypted; + this.objects = init.objects; + } + + static async parse(bytes: Uint8Array): Promise { + const src = toLatin1(bytes); + if (!src.startsWith("%PDF-") && src.indexOf("%PDF-") > 1024) return null; + + // ONE pass indexes every top-level object. A per-lookup scan of the + // whole file makes every caller quadratic, and several passes run per + // open - that is the difference between "opens instantly" and "the tab + // freezes on a large book". + const objects = new Map(); + let maxObjNum = 0; + const objRe = /(\d+)[\t\r\n\f ]+(\d+)[\t\r\n\f ]+obj\b/g; + for (let m = objRe.exec(src); m !== null; m = objRe.exec(src)) { + const before = m.index > 0 ? src[m.index - 1] : "\n"; + // "12 0 obj" must not match inside "912 0 obj". + if (before >= "0" && before <= "9") continue; + const num = parseInt(m[1], 10); + if (!Number.isFinite(num)) continue; + // Later revisions shadow earlier ones, so the last definition wins. + objects.set(num, { + gen: parseInt(m[2], 10) || 0, + offset: m.index + m[0].length, + }); + if (num > maxObjNum) maxObjNum = num; + } + if (objects.size === 0) return null; + + const startXref = (() => { + const at = src.lastIndexOf("startxref"); + if (at < 0) return -1; + const n = parseInt(src.slice(at + 9, at + 40).trim(), 10); + return Number.isFinite(n) ? n : -1; + })(); + const usesXrefStream = + startXref >= 0 && src.slice(startXref, startXref + 4) !== "xref"; + + // /Root lives in a trailer dictionary, or - for cross-reference-stream + // files, which have no `trailer` keyword at all - in the xref stream's + // own dictionary. Updated files chain trailers and the newest one may + // carry only /Size and /ID, so walk backwards until /Root turns up. + let rootNum = -1; + let trailerId: string | null = null; + for (let at = src.length; ;) { + at = src.lastIndexOf("trailer", at - 1); + if (at < 0) break; + const chunk = src.slice(at, at + 2048); + if (trailerId === null) { + const idm = chunk.match(/\/ID\s*(\[[^\]]*\])/); + if (idm) trailerId = idm[1]; + } + const rm = chunk.match(/\/Root\s+(\d+)\s+\d+\s+R/); + if (rm) { + rootNum = parseInt(rm[1], 10); + break; + } + if (at === 0) break; + } + if (rootNum < 0) { + const rm = src.match(/\/Root\s+(\d+)\s+\d+\s+R/); + if (rm) rootNum = parseInt(rm[1], 10); + } + if (trailerId === null) { + const idm = src.match(/\/ID\s*(\[[^\]]*\])/); + if (idm) trailerId = idm[1]; + } + if (rootNum < 0) return null; + + // Appended objects must number past every revision the file has, not + // just the newest one, so /Size takes the maximum found anywhere. + for (const m of src.matchAll(/\/Size\s+(\d+)/g)) { + const n = parseInt(m[1], 10); + if (Number.isFinite(n) && n - 1 > maxObjNum) maxObjNum = n - 1; + } + + const pdf = new RawPdf({ + bytes, + src, + rootNum, + maxObjNum, + usesXrefStream, + startXref, + trailerId, + encrypted: /\/Encrypt\s+\d+\s+\d+\s+R/.test(src), + objects, + }); + await pdf.indexObjectStreams(); + return pdf; + } + + /** + * Unpack `/Type /ObjStm` containers so objects stored inside them are + * reachable. In a PDF 1.5+ file most of the structure - page dictionaries + * included - lives in these, so without this step the passes see almost + * nothing. + */ + private async indexObjectStreams(): Promise { + const compressed = await this.compressedInNewestXref(); + const containers: number[] = []; + for (const num of this.objects.keys()) { + const body = this.objectBody(num); + if (body && /\/Type\s*\/ObjStm\b/.test(body)) containers.push(num); + } + for (const num of containers) { + const data = await this.streamData(num); + if (!data) continue; + const body = this.objectBody(num); + if (!body) continue; + const n = this.dictInt(body, "N"); + const first = this.dictInt(body, "First"); + if (n === null || first === null || first < 0) continue; + const text = toLatin1(data); + const header = text.slice(0, first).trim(); + const nums = header.length ? header.split(/\s+/).map(Number) : []; + for (let i = 0; i < n; i += 1) { + const objNum = nums[i * 2]; + const off = nums[i * 2 + 1]; + if (!Number.isFinite(objNum) || !Number.isFinite(off)) continue; + // A top-level definition usually comes from a later revision and + // wins - unless the newest xref says this object lives in a stream, + // in which case the top-level copy is the stale one. + if (this.objects.has(objNum) && !compressed.has(objNum)) continue; + const nextOff = i + 1 < n ? nums[i * 2 + 3] : data.length - first; + const end = Number.isFinite(nextOff) ? first + nextOff : text.length; + // Objects inside an object stream are generation 0 by definition. + this.objects.set(objNum, { + gen: 0, + body: text.slice(first + off, end), + }); + // The container scan above cached the stale top-level body. + this.bodyCache.delete(objNum); + if (objNum > this.highestObj) this.highestObj = objNum; + } + } + } + + // Object numbers the NEWEST cross-reference section stores inside an object + // stream (entry type 2). Empty for classic tables, which have no type 2. + private async compressedInNewestXref(): Promise> { + const out = new Set(); + if (this.startXref < 0 || !this.usesXrefStream) return out; + const header = /^(\d+)\s+(\d+)\s+obj\b/.exec( + this.src.slice(this.startXref, this.startXref + 64), + ); + if (!header) return out; + const num = parseInt(header[1], 10); + const body = this.objectBody(num); + if (!body || !/\/Type\s*\/XRef\b/.test(body)) return out; + const data = await this.streamData(num); + if (!data) return out; + + const wSpan = this.valueSpan(body, "W"); + const w = wSpan + ? [...wSpan.text.matchAll(/\d+/g)].map((m) => parseInt(m[0], 10)) + : []; + if (w.length < 3) return out; + const size = this.dictInt(body, "Size") ?? 0; + const indexSpan = this.valueSpan(body, "Index"); + const index = indexSpan + ? [...indexSpan.text.matchAll(/\d+/g)].map((m) => parseInt(m[0], 10)) + : [0, size]; + + const rowLen = w[0] + w[1] + w[2]; + if (rowLen <= 0) return out; + let at = 0; + for (let g = 0; g + 1 < index.length; g += 2) { + for (let k = 0; k < index[g + 1]; k += 1) { + if (at + rowLen > data.length) return out; + let type = 1; + if (w[0] > 0) { + type = 0; + for (let b = 0; b < w[0]; b += 1) type = (type << 8) | data[at + b]; + } + if (type === 2) out.add(index[g] + k); + at += rowLen; + } + } + return out; + } + + /** Object numbers appended by a revision must start above this. */ + get highestObjectNumber(): number { + return this.highestObj; + } + + /** Raw text of an object's body: everything between `obj` and `endobj`. */ + objectBody(num: number): string | null { + const cached = this.bodyCache.get(num); + if (cached !== undefined) return cached; + const entry = this.objects.get(num); + let body: string | null = null; + if (entry?.body !== undefined) { + body = entry.body; + } else if (entry?.offset !== undefined) { + // Bounded: an unterminated object in a hostile file would otherwise + // make every lookup scan to end of file. + const limit = Math.min(this.src.length, entry.offset + MAX_OBJECT_BYTES); + const end = this.src.indexOf("endobj", entry.offset); + body = end < 0 || end > limit ? null : this.src.slice(entry.offset, end); + } + this.bodyCache.set(num, body); + return body; + } + + /** Generation the file declares for an object, 0 when unknown. */ + generationOf(num: number): number { + return this.objects.get(num)?.gen ?? 0; + } + + hasObject(num: number): boolean { + return this.objects.has(num); + } + + /** Byte offset of the object body, or -1 when it lives in an ObjStm. */ + bodyOffset(num: number): number { + return this.objects.get(num)?.offset ?? -1; + } + + /** `/Key 12 0 R` -> 12. */ + dictRef(body: string, key: string): number | null { + const span = this.valueSpan(body, key); + if (!span) return null; + const m = span.text.match(/^(\d+)\s+\d+\s+R\b/); + return m ? parseInt(m[1], 10) : null; + } + + /** + * `/Key 42` -> 42, and null for anything else. Strict on purpose: a lax + * match reads `/Length 12 0 R` as the integer 12 and truncates the stream. + */ + dictInt(body: string, key: string): number | null { + const span = this.valueSpan(body, key); + if (!span) return null; + return /^-?\d+$/.test(span.text.trim()) ? parseInt(span.text, 10) : null; + } + + dictName(body: string, key: string): string | null { + const span = this.valueSpan(body, key); + if (!span) return null; + const m = span.text.match(/^\/([^\s/<>()[\]{}%]*)/); + return m ? m[1] : null; + } + + /** Follow `/Key n 0 R` when indirect, else return the direct value text. */ + resolve(body: string, key: string): string | null { + const span = this.valueSpan(body, key); + if (!span) return null; + const m = span.text.match(/^(\d+)\s+\d+\s+R\b/); + if (m) return this.objectBody(parseInt(m[1], 10)); + return span.text; + } + + /** + * Locate the value of `/Key` in the object's OUTERMOST dictionary. + * + * Depth-aware on purpose: a naive regex happily matches a `/Contents` + * buried in a nested annotation dictionary, and rewriting that instead of + * the page's own entry produces a file that opens but renders nothing. + */ + valueSpan(body: string, key: string): ValueSpan | null { + const open = body.indexOf("<<"); + if (open < 0) return null; + let i = open + 2; + let depth = 1; + while (i < body.length) { + const ch = body[i]; + if (ch === "%") { + while (i < body.length && body[i] !== "\n" && body[i] !== "\r") i += 1; + continue; + } + if (ch === "(") { + i = skipLiteralString(body, i); + continue; + } + if (ch === "<" && body[i + 1] === "<") { + depth += 1; + i += 2; + continue; + } + if (ch === ">" && body[i + 1] === ">") { + depth -= 1; + i += 2; + if (depth === 0) return null; + continue; + } + if (ch === "[" || ch === "]") { + i += 1; + continue; + } + if (ch === "/" && depth === 1) { + const nameEnd = scanNameEnd(body, i + 1); + if (body.slice(i + 1, nameEnd) === key) { + const start = skipWhitespace(body, nameEnd); + const end = scanValueEnd(body, start); + return { start, end, text: body.slice(start, end) }; + } + i = nameEnd; + continue; + } + i += 1; + } + return null; + } + + /** Decoded stream payload for an object, or null when unsupported. */ + async streamData(num: number): Promise { + const entry = this.objects.get(num); + if (!entry || entry.offset === undefined) return null; + const body = this.objectBody(num); + if (body === null) return null; + const kw = body.indexOf("stream"); + if (kw < 0) return null; + let dataStart = entry.offset + kw + "stream".length; + if (this.src[dataStart] === "\r") dataStart += 1; + if (this.src[dataStart] === "\n") dataStart += 1; + + let length = this.dictInt(body, "Length"); + if (length === null) { + const ref = this.dictRef(body, "Length"); + if (ref !== null) { + const lenBody = this.objectBody(ref); + const m = lenBody?.match(/-?\d+/); + if (m) length = parseInt(m[0], 10); + } + } + let dataEnd = length !== null && length >= 0 ? dataStart + length : -1; + // A wrong /Length is common enough in the wild that trusting it blindly + // truncates real content; verify against the endstream keyword. + const marker = this.src.indexOf("endstream", dataStart); + if (dataEnd < 0 || marker < 0 || dataEnd > marker) { + dataEnd = marker < 0 ? this.bytes.length : marker; + while ( + dataEnd > dataStart && + (this.src[dataEnd - 1] === "\n" || this.src[dataEnd - 1] === "\r") + ) { + dataEnd -= 1; + } + } + let data = this.bytes.subarray(dataStart, dataEnd); + + const filters = this.filterNames(body); + if (filters === null) return null; + if (filters.length === 0) return data; + if (filters.some((f) => f !== "FlateDecode")) return null; + for (let i = 0; i < filters.length; i += 1) { + const out = await inflate(data); + if (!out) return null; + data = out; + } + return this.applyPredictor(body, data); + } + + /** Null means "there is a filter here I cannot read", never "no filter". */ + private filterNames(body: string): string[] | null { + const span = this.valueSpan(body, "Filter"); + if (!span) return []; + // An indirect /Filter would otherwise look like no filter at all, and the + // still-compressed bytes would be handed back as decoded content. + if (/^\d+\s+\d+\s+R\b/.test(span.text)) return null; + if (span.text.startsWith("/")) { + const m = span.text.match(/^\/([^\s/<>()[\]{}%]*)/); + return m ? [m[1]] : []; + } + if (span.text.startsWith("[")) { + return [...span.text.matchAll(/\/([^\s/<>()[\]{}%]+)/g)].map((m) => m[1]); + } + return null; + } + + private applyPredictor(body: string, data: Uint8Array): Uint8Array | null { + const parms = this.valueSpan(body, "DecodeParms"); + if (!parms) return data; + if (/^\d+\s+\d+\s+R\b/.test(parms.text)) return null; + const dict = parms.text; + const int = (key: string, dflt: number): number => { + const m = dict.match(new RegExp(`/${key}\\s+(\\d+)`)); + return m ? parseInt(m[1], 10) : dflt; + }; + const predictor = int("Predictor", 1); + if (predictor < 10) return data; + return undoPngPredictor( + data, + int("Colors", 1), + int("BitsPerComponent", 8), + int("Columns", 1), + ); + } + + /** + * Object numbers of every page, in document order. + * + * Walked once and cached: re-walking from the root per page index turns a + * few-hundred-page document into a quadratic traversal. + */ + pageNumbers(): number[] { + if (this.pageNums) return this.pageNums; + const out: number[] = []; + const root = this.objectBody(this.rootNum); + const pagesNum = root ? this.dictRef(root, "Pages") : null; + const seen = new Set(); + const visit = (num: number, depth: number): void => { + if (depth > 64 || seen.has(num)) return; + seen.add(num); + const body = this.objectBody(num); + if (!body) return; + const type = this.dictName(body, "Type"); + if (type === "Page") { + out.push(num); + return; + } + const kids = this.valueSpan(body, "Kids"); + if (!kids) { + if (type === null) out.push(num); + return; + } + for (const m of kids.text.matchAll(/(\d+)\s+\d+\s+R/g)) { + visit(parseInt(m[1], 10), depth + 1); + } + }; + if (pagesNum !== null) visit(pagesNum, 0); + this.pageNums = out; + return out; + } + + pageNumberAt(pageIndex: number): number | null { + const pages = this.pageNumbers(); + return pageIndex >= 0 && pageIndex < pages.length ? pages[pageIndex] : null; + } + + /** + * Resolve a key on a page, walking `/Parent` for the inheritable ones + * (`/Resources`, `/MediaBox`, `/CropBox`, `/Rotate`). A page that inherits + * its resources is common, and treating it as having none silently + * disables every pass that needs them. + */ + pageInherited(pageNum: number, key: string): string | null { + let num: number | null = pageNum; + for (let depth = 0; num !== null && depth < 64; depth += 1) { + const body: string | null = this.objectBody(num); + if (!body) return null; + const direct = this.resolve(body, key); + if (direct !== null) return direct; + num = this.dictRef(body, "Parent"); + } + return null; + } + + /** Concatenated, decoded content stream(s) of a page. */ + async pageContent(pageNum: number): Promise { + const body = this.objectBody(pageNum); + if (!body) return null; + const refs = this.contentRefs(body); + if (refs.length === 0) return null; + const parts: Uint8Array[] = []; + for (const ref of refs) { + const data = await this.streamData(ref); + if (!data) return null; + parts.push(data); + parts.push(fromLatin1("\n")); + } + let total = 0; + for (const p of parts) total += p.length; + const out = new Uint8Array(total); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; + } + + /** Object numbers backing a page's `/Contents`, in order. */ + contentRefs(pageBody: string): number[] { + const span = this.valueSpan(pageBody, "Contents"); + if (!span) return []; + if (span.text.startsWith("[")) { + return [...span.text.matchAll(/(\d+)\s+\d+\s+R/g)].map((m) => + parseInt(m[1], 10), + ); + } + const m = span.text.match(/^(\d+)\s+\d+\s+R\b/); + return m ? [parseInt(m[1], 10)] : []; + } +} + +/** + * Replace a dictionary entry's value, keeping the result lexable. + * + * Producers write `/Contents[8 0 R]` with no separator, so splicing a plain + * `11 0 R` straight in yields the single name token `/Contents11` and the + * page silently loses its content. + */ +export function spliceValue( + body: string, + span: ValueSpan, + replacement: string, +): string { + const before = body[span.start - 1]; + const needsGap = + before !== undefined && + !WHITESPACE.has(before) && + !DELIMITER.has(before) && + !WHITESPACE.has(replacement[0]) && + !DELIMITER.has(replacement[0]); + return ( + body.slice(0, span.start) + + (needsGap ? " " : "") + + replacement + + body.slice(span.end) + ); +} + +function skipWhitespace(text: string, at: number): number { + let i = at; + while (i < text.length && WHITESPACE.has(text[i])) i += 1; + return i; +} + +function scanNameEnd(text: string, at: number): number { + let i = at; + while ( + i < text.length && + !WHITESPACE.has(text[i]) && + !DELIMITER.has(text[i]) + ) { + i += 1; + } + return i; +} + +function skipLiteralString(text: string, at: number): number { + let i = at + 1; + let depth = 1; + while (i < text.length && depth > 0) { + const ch = text[i]; + if (ch === "\\") { + i += 2; + continue; + } + if (ch === "(") depth += 1; + else if (ch === ")") depth -= 1; + i += 1; + } + return i; +} + +/** End index of one complete object starting at `at`. */ +function scanValueEnd(text: string, at: number): number { + let i = at; + if (text[i] === "(") return skipLiteralString(text, i); + if (text[i] === "<" && text[i + 1] === "<") { + let depth = 0; + while (i < text.length) { + if (text[i] === "(") { + i = skipLiteralString(text, i); + continue; + } + if (text[i] === "<" && text[i + 1] === "<") { + depth += 1; + i += 2; + continue; + } + if (text[i] === ">" && text[i + 1] === ">") { + depth -= 1; + i += 2; + if (depth === 0) return i; + continue; + } + i += 1; + } + return i; + } + if (text[i] === "<") { + const close = text.indexOf(">", i); + return close < 0 ? text.length : close + 1; + } + if (text[i] === "[") { + let depth = 0; + while (i < text.length) { + if (text[i] === "(") { + i = skipLiteralString(text, i); + continue; + } + if (text[i] === "[") depth += 1; + else if (text[i] === "]") { + depth -= 1; + if (depth === 0) return i + 1; + } + i += 1; + } + return i; + } + // Bare token(s). An indirect reference is three tokens, so consume them + // together or `/Length 12 0 R` reads back as the integer 12. + const refMatch = /^\d+\s+\d+\s+R\b/.exec(text.slice(i)); + if (refMatch) return i + refMatch[0].length; + if (text[i] === "/") return scanNameEnd(text, i + 1); + while ( + i < text.length && + !WHITESPACE.has(text[i]) && + !DELIMITER.has(text[i]) + ) { + i += 1; + } + return i; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/revision.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/revision.ts new file mode 100644 index 0000000000..d6ac11cd18 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/revision.ts @@ -0,0 +1,156 @@ +/** + * Append an incremental revision to a PDF. + * + * Everything the raw-PDF passes do is expressed as "add these objects, + * shadow those ones" and appended to the end of the file. That is the only + * edit shape that leaves the original bytes untouched, which matters twice + * over: existing digital signatures keep verifying against their own + * revision, and a pass that turns out to be wrong can never destroy content + * that was already there. + */ +import { concatBytes, fromLatin1 } from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import type { RawPdf } from "@app/tools/pdfTextEditor/pdfdoc/raw"; + +export interface RevisionObject { + num: number; + /** Complete object body, everything that goes between `obj` and `endobj`. */ + body: Uint8Array; +} + +/** Build the body of a stream object from its dictionary and payload. */ +export function streamObject( + dictWithoutLength: string, + data: Uint8Array, +): Uint8Array { + const trimmed = dictWithoutLength.trim(); + const inner = trimmed.replace(/^<<|>>$/g, "").trim(); + const head = `<< ${inner} /Length ${data.length} >>\nstream\n`; + return concatBytes([fromLatin1(head), data, fromLatin1("\nendstream")]); +} + +export function plainObject(body: string): Uint8Array { + return fromLatin1(body); +} + +/** + * Serialise `objects` as a new revision appended to `pdf`. + * + * Returns null when the file's structure is not one this can extend safely - + * the caller then keeps the original bytes, which is always a valid outcome. + */ +export function appendRevision( + pdf: RawPdf, + objects: RevisionObject[], +): Uint8Array | null { + if (objects.length === 0) return pdf.bytes; + if (pdf.startXref < 0) return null; + + const sorted = [...objects].sort((a, b) => a.num - b.num); + for (let i = 1; i < sorted.length; i += 1) { + if (sorted[i].num === sorted[i - 1].num) return null; + } + + const parts: Uint8Array[] = [pdf.bytes]; + let at = pdf.bytes.length; + // PDFium and most producers end the file with `%%EOF` and no trailing + // newline; starting the revision on its own line keeps the appended + // objects lexable regardless. + const lead = fromLatin1("\n"); + parts.push(lead); + at += lead.length; + + const offsets = new Map(); + const gens = new Map(); + for (const obj of sorted) { + // Rewriting at generation 0 would orphan every reference that names the + // object's real generation. + const gen = pdf.generationOf(obj.num); + gens.set(obj.num, gen); + const header = fromLatin1(`${obj.num} ${gen} obj\n`); + offsets.set(obj.num, at); + parts.push(header, obj.body, fromLatin1("\nendobj\n")); + at += header.length + obj.body.length + "\nendobj\n".length; + } + + // Above everything in the batch, not just above the file: callers allocate + // their new objects from the same high-water mark, so basing this on that + // mark alone hands the xref stream a number a content stream already has - + // and the page then resolves its content to the cross-reference stream. + const xrefStreamNum = pdf.usesXrefStream + ? Math.max(pdf.highestObjectNumber, sorted[sorted.length - 1].num) + 1 + : -1; + const size = Math.max( + pdf.highestObjectNumber + 1, + sorted[sorted.length - 1].num + 1, + xrefStreamNum >= 0 ? xrefStreamNum + 1 : 0, + ); + const idPart = pdf.trailerId ? ` /ID ${pdf.trailerId}` : ""; + + if (xrefStreamNum < 0) { + const xrefAt = at; + let table = "xref\n"; + for (const [first, nums] of runsOf(sorted.map((o) => o.num))) { + table += `${first} ${nums.length}\n`; + for (const num of nums) { + const gen = String(gens.get(num) ?? 0).padStart(5, "0"); + table += `${String(offsets.get(num) ?? 0).padStart(10, "0")} ${gen} n \n`; + } + } + table += + `trailer\n<< /Size ${size} /Root ${pdf.rootNum} 0 R ` + + `/Prev ${pdf.startXref}${idPart} >>\n` + + `startxref\n${xrefAt}\n%%EOF\n`; + parts.push(fromLatin1(table)); + return concatBytes(parts); + } + + // Cross-reference-stream file: the update must be a stream too. A classic + // table whose /Prev points at a stream is not a structure readers accept. + offsets.set(xrefStreamNum, at); + const entryNums = [...sorted.map((o) => o.num), xrefStreamNum].sort( + (a, b) => a - b, + ); + const groups = [...runsOf(entryNums)]; + const index: number[] = []; + const rows: number[][] = []; + for (const [first, nums] of groups) { + index.push(first, nums.length); + for (const num of nums) { + const off = offsets.get(num) ?? 0; + rows.push([1, off, gens.get(num) ?? 0]); + } + } + const data = new Uint8Array(rows.length * 7); + rows.forEach((row, i) => { + const base = i * 7; + data[base] = row[0]; + data[base + 1] = (row[1] >>> 24) & 0xff; + data[base + 2] = (row[1] >>> 16) & 0xff; + data[base + 3] = (row[1] >>> 8) & 0xff; + data[base + 4] = row[1] & 0xff; + data[base + 5] = (row[2] >>> 8) & 0xff; + data[base + 6] = row[2] & 0xff; + }); + const dict = + `<< /Type /XRef /W [1 4 2] /Index [${index.join(" ")}] ` + + `/Size ${size} /Root ${pdf.rootNum} 0 R /Prev ${pdf.startXref}${idPart} >>`; + const xrefBody = streamObject(dict, data); + const header = fromLatin1(`${xrefStreamNum} 0 obj\n`); + parts.push(header, xrefBody, fromLatin1("\nendobj\n")); + parts.push(fromLatin1(`startxref\n${at}\n%%EOF\n`)); + return concatBytes(parts); +} + +/** Group sorted object numbers into consecutive runs for xref subsections. */ +function* runsOf(nums: number[]): Generator<[number, number[]]> { + let run: number[] = []; + for (const num of nums) { + if (run.length === 0 || num === run[run.length - 1] + 1) { + run.push(num); + continue; + } + yield [run[0], run]; + run = [num]; + } + if (run.length > 0) yield [run[0], run]; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/BackgroundSampler.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/BackgroundSampler.ts new file mode 100644 index 0000000000..3749e1db90 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/BackgroundSampler.ts @@ -0,0 +1,135 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { PageRect, RGBA } from "@app/tools/pdfTextEditor/types"; + +// Render the area of the page surrounding a text run and pick the dominant +// background color. +const MARGIN_POINTS = 6; +const SAMPLE_SCALE = 1.5; // bitmap resolution (px per PDF point) + +export interface SampleResult { + fill: RGBA; + /** True when the sampler found at least one consensus background pixel. */ + confident: boolean; +} + +export function sampleBackground( + m: WrappedPdfiumModule, + page: Page, + bounds: PageRect, +): SampleResult { + const fallback: RGBA = { r: 255, g: 255, b: 255, a: 255 }; + try { + // No flush needed: the render path draws from the in-memory object list. + // The rendered bitmap is CropBox/rotation (display) space; the run bounds + // are raw PDF. + const d = page.display; + const cs = [ + d.apply(bounds.x, bounds.y), + d.apply(bounds.x + bounds.width, bounds.y), + d.apply(bounds.x, bounds.y + bounds.height), + d.apply(bounds.x + bounds.width, bounds.y + bounds.height), + ]; + const dx0 = Math.min(...cs.map((c) => c.x)); + const dx1 = Math.max(...cs.map((c) => c.x)); + const dy0 = Math.min(...cs.map((c) => c.y)); + const dy1 = Math.max(...cs.map((c) => c.y)); + const left = Math.max(0, dx0 - MARGIN_POINTS); + const right = Math.min(page.width, dx1 + MARGIN_POINTS); + const top = Math.min(page.height, dy1 + MARGIN_POINTS); + const bottom = Math.max(0, dy0 - MARGIN_POINTS); + const widthPts = right - left; + const heightPts = top - bottom; + if (widthPts <= 1 || heightPts <= 1) + return { fill: fallback, confident: false }; + + const w = Math.max(8, Math.round(widthPts * SAMPLE_SCALE)); + const h = Math.max(8, Math.round(heightPts * SAMPLE_SCALE)); + + // Render the slice via PDFium. + const bitmapPtr = m.FPDFBitmap_Create(w, h, 1); + if (!bitmapPtr) return { fill: fallback, confident: false }; + try { + m.FPDFBitmap_FillRect(bitmapPtr, 0, 0, w, h, 0xffffffff); + // PDFium renders the WHOLE page sized to (pageW*scale, pageH*scale) + // at the bitmap's origin. We translate so our slice lands at 0,0. + const fullW = Math.round(page.width * SAMPLE_SCALE); + const fullH = Math.round(page.height * SAMPLE_SCALE); + const startX = -Math.round(left * SAMPLE_SCALE); + // CSS-style y: PDFium origin is page top-left in render coords. + const startY = -Math.round((page.height - top) * SAMPLE_SCALE); + // 0x01 = FPDF_ANNOT, 0x10 = FPDF_REVERSE_BYTE_ORDER (gives RGBA). + m.FPDF_RenderPageBitmap( + bitmapPtr, + page.pagePtr, + startX, + startY, + fullW, + fullH, + 0, + 0x01 | 0x10, + ); + + const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr); + const stride = m.FPDFBitmap_GetStride(bitmapPtr); + const heap = new Uint8Array( + (m.pdfium.wasmExports as unknown as { memory: WebAssembly.Memory }) + .memory.buffer, + bufferPtr, + stride * h, + ); + + // Sample the border rings (top, bottom, left, right) plus the + // four corners. Bucket by 4 bits per channel. + const buckets = new Map< + number, + { r: number; g: number; b: number; count: number } + >(); + const samples: Array<[number, number]> = []; + const ringWidth = 2; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const inTop = y < ringWidth; + const inBottom = y >= h - ringWidth; + const inLeft = x < ringWidth; + const inRight = x >= w - ringWidth; + if (!(inTop || inBottom || inLeft || inRight)) continue; + samples.push([x, y]); + } + } + for (const [x, y] of samples) { + const off = y * stride + x * 4; + const r = heap[off]; + const g = heap[off + 1]; + const b = heap[off + 2]; + const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4); + const bucket = buckets.get(key) ?? { r: 0, g: 0, b: 0, count: 0 }; + bucket.r += r; + bucket.g += g; + bucket.b += b; + bucket.count += 1; + buckets.set(key, bucket); + } + let best: { r: number; g: number; b: number; count: number } | null = + null; + for (const b of buckets.values()) { + if (!best || b.count > best.count) best = b; + } + if (!best || best.count === 0) + return { fill: fallback, confident: false }; + return { + fill: { + r: Math.round(best.r / best.count), + g: Math.round(best.g / best.count), + b: Math.round(best.b / best.count), + a: 255, + }, + confident: true, + }; + } finally { + m.FPDFBitmap_Destroy(bitmapPtr); + } + } catch { + return { fill: fallback, confident: false }; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/LineGrouper.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/LineGrouper.ts new file mode 100644 index 0000000000..c5e354d288 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/LineGrouper.ts @@ -0,0 +1,225 @@ +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; + +/** Cluster adjacent text runs on a page into "line groups". */ +export interface LineGroupInfo { + /** The merged "virtual" run shown in the overlay. */ + representative: TextRun; + /** Original runs collapsed into this group, in left-to-right order. */ + members: TextRun[]; +} + +const BASELINE_TOLERANCE = 0.4; +// Two runs on the same baseline join the same line only when the horizontal gap +// between them is below this absolute cap. +const ABS_MAX_GAP_PT = 12; + +const WORD_GAP_MIN_RATIO = 0.2; +const FALLBACK_SPACE_UNIT_RATIO = 0.5; +const MIN_SPACE_UNIT_RATIO = 0.3; +const MAX_SPACE_UNIT_RATIO = 1; +const MULTI_SPACE_UNITS = 1.7; + +function junctionGapRatio(prev: TextRun, cur: TextRun): number { + const fontSize = Math.max(prev.fontSize, 4); + return (cur.bounds.x - (prev.bounds.x + prev.bounds.width)) / fontSize; +} + +function lineSpaceUnitRatio(members: TextRun[]): number { + const wordGaps: number[] = []; + for (let i = 1; i < members.length; i++) { + const ratio = junctionGapRatio(members[i - 1], members[i]); + if (ratio > WORD_GAP_MIN_RATIO) wordGaps.push(ratio); + } + if (wordGaps.length < 2) return FALLBACK_SPACE_UNIT_RATIO; + wordGaps.sort((a, b) => a - b); + const lowerMedian = wordGaps[Math.floor((wordGaps.length - 1) / 2)]; + return Math.min( + MAX_SPACE_UNIT_RATIO, + Math.max(MIN_SPACE_UNIT_RATIO, lowerMedian), + ); +} + +function spacesForGap(gapRatio: number, unitRatio: number): number { + if (gapRatio <= WORD_GAP_MIN_RATIO) return 0; + const units = gapRatio / unitRatio; + if (units < MULTI_SPACE_UNITS) return 1; + return Math.max(2, Math.round(units)); +} + +// True when a same-baseline cluster's glyphs overlap so heavily that it can't +// be normal running text. +function isDecorativeOverlap(members: TextRun[]): boolean { + if (members.length < 3) return false; + let overlapping = 0; + for (let i = 1; i < members.length; i++) { + const minAdvance = 0.12 * Math.max(members[i].fontSize, 4); + if (members[i].bounds.x - members[i - 1].bounds.x < minAdvance) { + overlapping += 1; + } + } + return overlapping / (members.length - 1) > 0.3; +} + +// A run that is just a list bullet (and a narrow glyph). +const BULLET_GLYPHS = /^[\s]*[•·∙▪●○◦‣⁃・‧°]+[\s]*$/; +function isBulletLead(run: TextRun): boolean { + return BULLET_GLYPHS.test(run.text) && run.bounds.width <= run.fontSize; +} + +// Sort one container's runs top-to-bottom / left-to-right and merge +// same-baseline, close-together runs into line groups. +function groupPartitionIntoLines(runs: TextRun[], out: LineGroupInfo[]): void { + const sorted = [...runs].sort((a, b) => { + const yDiff = b.matrix.f - a.matrix.f; + // Same-line band scaled to font size so a list bullet sitting a couple of + // points above its item still x-sorts onto the item's line. + const band = + BASELINE_TOLERANCE * Math.max(Math.min(a.fontSize, b.fontSize), 4); + if (Math.abs(yDiff) > Math.max(1, band)) return yDiff; + return a.bounds.x - b.bounds.x; + }); + + let current: LineGroupInfo | null = null; + for (const run of sorted) { + if (!current) { + current = { representative: run, members: [run] }; + out.push(current); + continue; + } + const ref = current.representative; + const baseDiff = Math.abs(run.matrix.f - ref.matrix.f); + const sameLine = baseDiff <= BASELINE_TOLERANCE * Math.max(ref.fontSize, 4); + const prev = current.members[current.members.length - 1]; + const gap = run.bounds.x - (prev.bounds.x + prev.bounds.width); + // The gap cap must scale with font size: an inter-word space in a 50pt + // heading is ~15-25pt, which a flat 12pt cap would treat as a line break. + const maxGap = Math.max(ABS_MAX_GAP_PT, 0.5 * Math.max(ref.fontSize, 4)); + // A leading bullet is indented from its item by more than an inter-word + // space; let the item attach across that wider indent. + const effMaxGap = isBulletLead(prev) + ? Math.max(maxGap, 2 * Math.max(ref.fontSize, 4)) + : maxGap; + // Reject joining a run that starts far to the LEFT of the previous run's + // right edge - a right-column run must never absorb the left column. + const minNegGap = 0.25 * Math.max(ref.fontSize, 4); + const close = gap <= effMaxGap && gap >= -minNegGap; + + if (sameLine && close) { + current.members.push(run); + } else { + current = { representative: run, members: [run] }; + out.push(current); + } + } +} + +export class LineGrouper { + /** Group a page's runs and store the result back onto the page. */ + static apply(page: Page): LineGroupInfo[] { + // Partition by form-xobject container BEFORE grouping. + const partitions = new Map(); + for (const run of page.runs) { + const key = run.containerPtr || 0; + const list = partitions.get(key); + if (list) list.push(run); + else partitions.set(key, [run]); + } + + const groups: LineGroupInfo[] = []; + for (const partition of partitions.values()) { + groupPartitionIntoLines(partition, groups); + } + + // Refine: a "line" whose glyphs heavily OVERLAP in x is not real running + // text. + const refined: LineGroupInfo[] = []; + for (const group of groups) { + if (group.members.length > 2 && isDecorativeOverlap(group.members)) { + for (const m of group.members) { + refined.push({ representative: m, members: [m] }); + } + } else { + refined.push(group); + } + } + groups.length = 0; + groups.push(...refined); + + // Mutate the representative's text/bounds to reflect the merged group and + // remember the underlying object pointers so ReplaceLineGroupCommand can. + for (const group of groups) { + if (group.members.length === 1) { + // A one-object line still needs its sub-run arrays. EditTextCommand's + // surgical path requires a non-empty mergedFromPtrs; without it even a + // two-character append detached the object and re-emitted the whole run + // from scratch, which is where real documents lost their text. + const only = group.members[0]; + group.representative.mergedFromPtrs = [only.pdfiumObjPtr]; + group.representative.mergedFromTexts = [only.text]; + group.representative.mergedFromBounds = [ + { x: only.bounds.x, right: only.bounds.x + only.bounds.width }, + ]; + group.representative.mergedFromCharStarts = [0]; + continue; + } + // Snapshot per-member texts and bounds BEFORE we mutate the + // representative. + const memberTexts = group.members.map((m) => m.text); + const memberBounds = group.members.map((m) => ({ + x: m.bounds.x, + right: m.bounds.x + m.bounds.width, + })); + // When the typesetter emitted a cursor jump instead of a literal space + // character, the two runs end up with content like ["Hello". + const parts: string[] = [memberTexts[0]]; + const memberCharStarts: number[] = [0]; + let cumulativeLen = memberTexts[0].length; + const spaceUnitRatio = lineSpaceUnitRatio(group.members); + for (let i = 1; i < group.members.length; i++) { + const prev = group.members[i - 1]; + const cur = group.members[i]; + const prevTail = memberTexts[i - 1].slice(-1); + const curHead = memberTexts[i].slice(0, 1); + const extraSpaces = spacesForGap( + junctionGapRatio(prev, cur), + spaceUnitRatio, + ); + const prevEndsInSpace = /\s/.test(prevTail); + const curStartsWithSpace = /\s/.test(curHead); + const alreadyHave = + (prevEndsInSpace ? 1 : 0) + (curStartsWithSpace ? 1 : 0); + const toInsert = Math.max(0, extraSpaces - alreadyHave); + if (toInsert > 0) { + parts.push(" ".repeat(toInsert)); + cumulativeLen += toInsert; + } + memberCharStarts.push(cumulativeLen); + parts.push(memberTexts[i]); + cumulativeLen += memberTexts[i].length; + } + const joined = parts.join(""); + const last = group.members[group.members.length - 1]; + const left = group.representative.bounds.x; + const right = last.bounds.x + last.bounds.width; + group.representative.text = joined; + group.representative.bounds = { + ...group.representative.bounds, + x: left, + width: Math.max(group.representative.bounds.width, right - left), + }; + // Per-sub-run texts + bounds so EditTextCommand's pure-deletion + // optimization can map joined-text chars back to their source. + group.representative.mergedFromTexts = memberTexts; + group.representative.mergedFromBounds = memberBounds; + group.representative.mergedFromCharStarts = memberCharStarts; + group.representative.mergedFromPtrs = group.members.map( + (m) => m.pdfiumObjPtr, + ); + } + + // Replace the page's runs with just the representatives. + page.setRuns(groups.map((g) => g.representative)); + return groups; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/ParagraphGrouper.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/ParagraphGrouper.ts new file mode 100644 index 0000000000..cc4dcdbcc8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/ParagraphGrouper.ts @@ -0,0 +1,327 @@ +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { + ParagraphLineSlot, + TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; + +/** Cluster consecutive `LineGroup` representatives into "paragraphs". */ +const MIN_LINE_FACTOR = 0.6; +const MAX_LINE_FACTOR = 2.0; +const MEDIAN_TOLERANCE = 0.25; +const MARGIN_INDENT_RIGHT = 12; +const MARGIN_OUTDENT_LEFT = 2; +// Two runs are "side by side" (column peers) when their baselines are within +// this fraction of a line and a horizontal gap this wide sits between them. +const COLUMN_BASELINE_FRAC = 0.6; +const COLUMN_MIN_GAP_PT = 24; +// Left-edge clustering tolerance when splitting runs into columns. +const COLUMN_LEFT_TOLERANCE = 14; + +export interface ParagraphInfo { + representative: TextRun; + members: TextRun[]; +} + +export class ParagraphGrouper { + static apply(page: Page): ParagraphInfo[] { + const allLines = [...page.runs]; + const paragraphs: ParagraphInfo[] = []; + + // Columns first: a reading-order sort across the whole page interleaves + // side-by-side columns into one bogus paragraph. + for (const column of segmentColumns(allLines)) { + const sorted = column.sort((a, b) => { + const yDiff = b.matrix.f - a.matrix.f; + if (Math.abs(yDiff) > 0.5) return yDiff; + return a.bounds.x - b.bounds.x; + }); + groupColumnLines(sorted, paragraphs); + } + + // Fold member bounds + text into the representative and drop the member + // runs from the page so the editor sees one overlay per paragraph. + for (const para of paragraphs) { + if (para.members.length === 1) continue; + const rep = para.representative; + + // Snapshot per-line sub-run arrays BEFORE the rep.text mutation + // overwrites members[0]'s state. + const memberLineTexts = para.members.map((m) => m.text); + const slots = buildLineSlots(para.members, memberLineTexts); + + const joinedText = memberLineTexts.join("\n"); + const minX = Math.min(...para.members.map((m) => m.bounds.x)); + const maxRight = Math.max( + ...para.members.map((m) => m.bounds.x + m.bounds.width), + ); + const topY = Math.max( + ...para.members.map((m) => m.bounds.y + m.bounds.height), + ); + const bottomY = Math.min(...para.members.map((m) => m.bounds.y)); + rep.text = joinedText; + rep.bounds = { + x: minX, + y: bottomY, + width: maxRight - minX, + height: topY - bottomY, + }; + // Stash per-line metadata on the representative so the React layer can + // render with the correct line-height and the edit command can emit one. + rep.paragraphLineHeight = computeMedianLineHeight(para.members); + rep.paragraphMemberPtrs = para.members.map((m) => m.pdfiumObjPtr); + rep.paragraphMemberContainers = para.members.map((m) => m.containerPtr); + rep.paragraphMemberFs = para.members.map((m) => m.matrix.f); + // Track every leaf ptr so EditTextCommand can remove the original + // sub-words. + const leafPtrs: number[] = []; + const leafContainers: number[] = []; + for (const m of para.members) { + const leaves = + m.mergedFromPtrs.length > 0 + ? m.mergedFromPtrs + : m.pdfiumObjPtr + ? [m.pdfiumObjPtr] + : []; + for (const p of leaves) { + leafPtrs.push(p); + leafContainers.push(m.containerPtr); + } + } + rep.paragraphLeafPtrs = leafPtrs; + rep.paragraphLeafContainers = leafContainers; + rep.paragraphLineSlots = slots; + } + + page.setRuns(paragraphs.map((p) => p.representative)); + return paragraphs; + } +} + +/** Build a `ParagraphLineSlot[]` from the paragraph's member runs. */ +export function buildLineSlots( + members: TextRun[], + lineTexts: string[], +): ParagraphLineSlot[] { + const slots: ParagraphLineSlot[] = []; + let cursor = 0; + for (let i = 0; i < members.length; i++) { + const m = members[i]; + const text = lineTexts[i]; + const len = text.length; + // A line that LineGrouper merged from several source objects already has + // per-sub-run arrays. + const hasSubRuns = m.mergedFromPtrs.length > 0; + const mergedFromPtrs = hasSubRuns + ? [...m.mergedFromPtrs] + : m.pdfiumObjPtr + ? [m.pdfiumObjPtr] + : []; + const mergedFromTexts = hasSubRuns ? [...m.mergedFromTexts] : [text]; + const mergedFromBounds = hasSubRuns + ? m.mergedFromBounds.map((b) => ({ ...b })) + : [{ x: m.bounds.x, right: m.bounds.x + m.bounds.width }]; + const mergedFromCharStarts = hasSubRuns ? [...m.mergedFromCharStarts] : [0]; + slots.push({ + startChar: cursor, + endChar: cursor + len, + baselineY: m.matrix.f, + matrixE: m.matrix.e, + containerPtr: m.containerPtr, + fontId: m.fontId, + fontSize: m.fontSize, + fontSubset: m.fontSubset, + mergedFromPtrs, + mergedFromTexts, + mergedFromBounds, + mergedFromCharStarts, + }); + // +1 for the synthesised "\n" between lines (no separator after the + // last line). + cursor += len + (i < members.length - 1 ? 1 : 0); + } + return slots; +} + +/** One visual line's worth of slot source. */ +export interface LineSlotDescriptor { + text: string; + baselineY: number; + matrixE: number; + containerPtr: number; + fontId: string; + fontSize: number; + fontSubset: boolean; + mergedFromPtrs: number[]; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + mergedFromCharStarts: number[]; +} + +// Same cursor walk as `buildLineSlots` but pulls each line's `mergedFrom*` +// directly from a descriptor instead of a TextRun. +export function buildLineSlotsFromDescriptors( + descs: LineSlotDescriptor[], +): ParagraphLineSlot[] { + const slots: ParagraphLineSlot[] = []; + let cursor = 0; + for (let i = 0; i < descs.length; i++) { + const d = descs[i]; + const len = d.text.length; + slots.push({ + startChar: cursor, + endChar: cursor + len, + baselineY: d.baselineY, + matrixE: d.matrixE, + containerPtr: d.containerPtr, + fontId: d.fontId, + fontSize: d.fontSize, + fontSubset: d.fontSubset, + mergedFromPtrs: [...d.mergedFromPtrs], + mergedFromTexts: [...d.mergedFromTexts], + mergedFromBounds: d.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...d.mergedFromCharStarts], + }); + cursor += len + (i < descs.length - 1 ? 1 : 0); + } + return slots; +} + +// A run's visual font identity for grouping: family + rounded size. +// `run.fontId` is `pdf::`. +function fontKey(run: TextRun): string { + const family = run.fontId.slice(run.fontId.lastIndexOf(":") + 1); + return `${family}@${Math.round(run.fontSize)}`; +} + +/** Split a page's line-runs into columns. */ +function segmentColumns(lines: TextRun[]): TextRun[][] { + if (lines.length < 4) return [lines]; + + // Detect side-by-side peers. + let sideBySide = 0; + for (let i = 0; i < lines.length && sideBySide < 2; i++) { + for (let j = i + 1; j < lines.length; j++) { + const a = lines[i]; + const b = lines[j]; + const baseTol = + COLUMN_BASELINE_FRAC * Math.min(a.fontSize, b.fontSize || a.fontSize); + if (Math.abs(a.matrix.f - b.matrix.f) > baseTol) continue; + const aRight = a.bounds.x + a.bounds.width; + const bRight = b.bounds.x + b.bounds.width; + const gap = + a.bounds.x > b.bounds.x ? a.bounds.x - bRight : b.bounds.x - aRight; + if (gap >= COLUMN_MIN_GAP_PT) { + sideBySide += 1; + break; + } + } + } + if (sideBySide < 2) return [lines]; + + // Cluster left edges into column buckets. + const edges = lines.map((l) => l.bounds.x).sort((a, b) => a - b); + const centers: number[] = []; + for (const e of edges) { + const last = centers[centers.length - 1]; + if (last === undefined || e - last > COLUMN_LEFT_TOLERANCE) centers.push(e); + } + if (centers.length < 2) return [lines]; + + const columns: TextRun[][] = centers.map(() => []); + for (const line of lines) { + let best = 0; + let bestDist = Infinity; + for (let i = 0; i < centers.length; i++) { + const d = Math.abs(line.bounds.x - centers[i]); + if (d < bestDist) { + bestDist = d; + best = i; + } + } + columns[best].push(line); + } + return columns.filter((c) => c.length > 0); +} + +// Sequentially group one column's already-sorted (top-to-bottom) lines into +// paragraphs, appending each paragraph to `out`. +function groupColumnLines(sorted: TextRun[], out: ParagraphInfo[]): void { + let current: ParagraphInfo | null = null; + let currentDeltas: number[] = []; + let currentLeftEdge = 0; + + for (const line of sorted) { + if (!current) { + current = { representative: line, members: [line] }; + out.push(current); + currentDeltas = []; + currentLeftEdge = line.bounds.x; + continue; + } + const prev = current.members[current.members.length - 1]; + const sameFont = fontKey(prev) === fontKey(line); + const sameColor = + prev.fill.r === line.fill.r && + prev.fill.g === line.fill.g && + prev.fill.b === line.fill.b; + const baselineDelta = prev.matrix.f - line.matrix.f; + + let lineHeightOk: boolean; + if (currentDeltas.length === 0) { + lineHeightOk = + baselineDelta >= MIN_LINE_FACTOR * line.fontSize && + baselineDelta <= MAX_LINE_FACTOR * line.fontSize; + } else { + const med = median(currentDeltas); + const tol = MEDIAN_TOLERANCE * med; + lineHeightOk = baselineDelta >= med - tol && baselineDelta <= med + tol; + } + + const deltaFromLeft = line.bounds.x - currentLeftEdge; + const leftOk = + deltaFromLeft >= -MARGIN_OUTDENT_LEFT && + deltaFromLeft <= MARGIN_INDENT_RIGHT; + + if (sameFont && sameColor && lineHeightOk && leftOk) { + current.members.push(line); + currentDeltas.push(baselineDelta); + if (line.bounds.x < currentLeftEdge) currentLeftEdge = line.bounds.x; + } else { + current = { representative: line, members: [line] }; + out.push(current); + currentDeltas = []; + currentLeftEdge = line.bounds.x; + } + } +} + +function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +function computeMedianLineHeight(members: TextRun[]): number { + if (members.length < 2) return members[0].fontSize * 1.2; + return medianLineHeightFromBaselines( + members.map((m) => m.matrix.f), + members[0].fontSize, + ); +} + +// Median of consecutive baseline deltas; falls back to 1.2em when there is +// fewer than one delta. +export function medianLineHeightFromBaselines( + baselines: number[], + fallbackFontSize: number, +): number { + if (baselines.length < 2) return fallbackFontSize * 1.2; + const deltas: number[] = []; + for (let i = 1; i < baselines.length; i++) { + deltas.push(baselines[i - 1] - baselines[i]); + } + return median(deltas); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumAnnotationReader.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumAnnotationReader.ts new file mode 100644 index 0000000000..9e87b5d51b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumAnnotationReader.ts @@ -0,0 +1,92 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { + type AnnotationBox, + annotationKindFor, +} from "@app/tools/pdfTextEditor/model/AnnotationBox"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; + +// The canvas renders with FPDF_ANNOT, but the editor model walks page objects +// only - so FreeText/widget/stamp text is visible and completely uneditable. +// Reading the boxes lets the UI outline them and say why. + +interface AnnotModule { + FPDFPage_GetAnnotCount?: (page: number) => number; + FPDFPage_GetAnnot?: (page: number, index: number) => number; + FPDFPage_CloseAnnot?: (annot: number) => void; + FPDFAnnot_GetSubtype?: (annot: number) => number; + FPDFAnnot_GetRect?: (annot: number, rect: number) => boolean; + EPDFAnnot_GetRect?: (annot: number, rect: number) => boolean; +} + +/** Hard cap so a pathological page can't stall the reader. */ +const MAX_ANNOTS = 2000; + +export class PdfiumAnnotationReader { + static populate(m: WrappedPdfiumModule, page: Page): void { + const mod = m as unknown as AnnotModule; + if ( + !mod.FPDFPage_GetAnnotCount || + !mod.FPDFPage_GetAnnot || + !mod.FPDFAnnot_GetSubtype || + !mod.FPDFPage_CloseAnnot + ) { + page.setAnnotations([]); + return; + } + const getRect = mod.EPDFAnnot_GetRect ?? mod.FPDFAnnot_GetRect; + if (!getRect) { + page.setAnnotations([]); + return; + } + + let count = 0; + try { + count = mod.FPDFPage_GetAnnotCount(page.pagePtr); + } catch { + page.setAnnotations([]); + return; + } + + const out: AnnotationBox[] = []; + const rectBuf = m.pdfium.wasmExports.malloc(4 * 4); + try { + for (let i = 0; i < Math.min(count, MAX_ANNOTS); i++) { + const annot = mod.FPDFPage_GetAnnot(page.pagePtr, i); + if (!annot) continue; + try { + const kind = annotationKindFor(mod.FPDFAnnot_GetSubtype(annot)); + if (!kind) continue; + if (!getRect(annot, rectBuf)) continue; + const left = m.pdfium.getValue(rectBuf, "float"); + const top = m.pdfium.getValue(rectBuf + 4, "float"); + const right = m.pdfium.getValue(rectBuf + 8, "float"); + const bottom = m.pdfium.getValue(rectBuf + 12, "float"); + const x = Math.min(left, right); + const y = Math.min(top, bottom); + const width = Math.abs(right - left); + const height = Math.abs(top - bottom); + // Degenerate rects (hidden widgets) would draw a dot over the page. + if (!(width > 1 && height > 1)) continue; + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(width) || + !Number.isFinite(height) + ) { + continue; + } + out.push({ + id: `p${page.index}-annot-${i}`, + kind, + rect: { x, y, width, height }, + }); + } finally { + mod.FPDFPage_CloseAnnot(annot); + } + } + } finally { + m.pdfium.wasmExports.free(rectBuf); + } + page.setAnnotations(out); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumModelSync.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumModelSync.ts new file mode 100644 index 0000000000..62ea27a5fb --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumModelSync.ts @@ -0,0 +1,126 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { PdfiumTextReader } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextReader"; +import type { GroupingMode } from "@app/tools/pdfTextEditor/types"; + +// Re-read a page from PDFium and fold the result onto the EXISTING run objects +// instead of replacing them. +// +// `PdfiumTextReader.populate` mints fresh `TextRun`s with fresh ids, so calling +// it after a commit would invalidate every id the selection, the undo stack and +// React's keys are holding - which is exactly why the model is hand-patched by +// each command today. Matching the re-read runs back onto the live ones by +// PDFium object pointer keeps identity stable, so the engine can be the source +// of truth for geometry without anything downstream noticing. + +/** Every PDFium pointer that backs a run, in the order the reader emits them. */ +function memberPtrsOf(run: TextRun): number[] { + if (run.paragraphLeafPtrs.length > 0) return run.paragraphLeafPtrs; + if (run.mergedFromPtrs.length > 0) return run.mergedFromPtrs; + return run.pdfiumObjPtr ? [run.pdfiumObjPtr] : []; +} + +// Engine-owned geometry. Text and font identity stay with the model. +// +// Deliberately positions ONLY, not bounds or the matrix. This refresh fires +// 600ms after the last keystroke, which is usually still mid-edit, so adopting +// the engine's box would resize the field under the user's caret - and would +// also overwrite the deliberate "focused box grows past its text so the caret +// has room" behaviour. Bounds adoption becomes safe once the overlay is +// destroyed on blur (issue 3c), which is why the doc orders 3a -> 3b -> 3c. +function adoptGeometry(target: TextRun, fresh: TextRun): boolean { + // Pen positions are what the overlay paints against. Only adopt them when + // they describe the SAME string, or the overlay would lay this run's glyphs + // out against another text's advances. + if (fresh.charPositionsKey !== target.positionsKey()) return false; + target.charStartsX = fresh.charStartsX; + target.charEndsX = fresh.charEndsX; + target.charPositionsKey = fresh.charPositionsKey; + target.charSpacingPt = fresh.charSpacingPt; + return true; +} + +export interface ModelSyncResult { + /** True when any live run's geometry actually moved. */ + changed: boolean; + matched: number; + /** Live runs the re-read no longer sees (their objects went away). */ + unmatched: number; + /** Runs the re-read found that the model has no id for. */ + appeared: number; +} + +export class PdfiumModelSync { + // Re-read `page` and mutate its existing runs in place. Runs are matched by + // shared PDFium object pointers, so ids survive. + static resyncPage( + doc: EditorDocument, + page: Page, + mode: GroupingMode, + ): ModelSyncResult { + const result: ModelSyncResult = { + changed: false, + matched: 0, + unmatched: 0, + appeared: 0, + }; + if (!page.loaded || page.runs.length === 0) return result; + + // Push pending object edits into the content stream first: the text page + // the reader opens is built from the CURRENT stream. + page.flushGenerate(doc.module); + + // Read into a scratch page so a failure leaves the live model untouched. + const scratch = new Page({ + index: page.index, + pagePtr: page.pagePtr, + width: page.width, + height: page.height, + display: page.display, + }); + try { + PdfiumTextReader.populate(doc, scratch, mode); + } catch { + return result; + } + if (scratch.runs.length === 0) return result; + + // Index the live runs by every pointer that backs them. + const liveByPtr = new Map(); + for (const run of page.runs) { + for (const ptr of memberPtrsOf(run)) { + if (ptr && !liveByPtr.has(ptr)) liveByPtr.set(ptr, run); + } + } + + // A fresh run belongs to whichever live run it shares the most pointers + // with: grouping can split or merge, so a single shared pointer is not + // enough to claim identity. + const claimed = new Set(); + for (const fresh of scratch.runs) { + const votes = new Map(); + for (const ptr of memberPtrsOf(fresh)) { + const live = liveByPtr.get(ptr); + if (live) votes.set(live, (votes.get(live) ?? 0) + 1); + } + let best: TextRun | null = null; + let bestVotes = 0; + for (const [live, count] of votes) { + if (count > bestVotes && !claimed.has(live)) { + best = live; + bestVotes = count; + } + } + if (!best) { + result.appeared += 1; + continue; + } + claimed.add(best); + result.matched += 1; + if (adoptGeometry(best, fresh)) result.changed = true; + } + result.unmatched = page.runs.length - claimed.size; + return result; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumPageRenderer.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumPageRenderer.ts new file mode 100644 index 0000000000..46bb5056e3 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumPageRenderer.ts @@ -0,0 +1,130 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; + +// A display ratio above this buys no visible sharpness for a PDF preview and +// doubles memory per step, so the raster stops following it there. +const MAX_DPR = 3; + +// Budget for one page's bitmap, in pixels (32M ≈ 128MB of RGBA). Zoom and +// device ratio multiply together, and a poster-sized page at that product can +// otherwise ask the wasm heap for gigabytes. +const MAX_RASTER_PIXELS = 32_000_000; + +/** Renders pages to bitmaps for the on-screen preview. */ +export class PdfiumPageRenderer { + static rasterSize( + pageWidth: number, + pageHeight: number, + scale: number, + ): { width: number; height: number } { + return { + width: Math.max(1, Math.round(pageWidth * scale)), + height: Math.max(1, Math.round(pageHeight * scale)), + }; + } + + /** + * The scale to RENDER at for a page displayed at `cssScale`: the display's + * pixel ratio multiplied in, so a HiDPI screen gets real pixels instead of + * a browser-upscaled bitmap, then capped by the per-page pixel budget. + */ + static deviceScale( + pageWidth: number, + pageHeight: number, + cssScale: number, + dpr: number, + ): number { + const ratio = Math.min(Math.max(dpr || 1, 1), MAX_DPR); + const cap = Math.sqrt( + MAX_RASTER_PIXELS / Math.max(1, pageWidth * pageHeight), + ); + return Math.max(0.25, Math.min(cssScale * ratio, cap)); + } + + static async render( + doc: EditorDocument, + page: Page, + scale: number, + ): Promise { + const m = doc.module; + // No flush: FPDF_RenderPageBitmap draws from the in-memory object list, so + // the preview is current without rewriting the content stream. + const { width: w, height: h } = PdfiumPageRenderer.rasterSize( + page.width, + page.height, + scale, + ); + + // BGRA bitmap = format 1, fill white, then render with REVERSE_BYTE_ORDER + // so the pixel buffer is RGBA-ordered for ImageData. + const bitmapPtr = m.FPDFBitmap_Create(w, h, 1); + try { + m.FPDFBitmap_FillRect(bitmapPtr, 0, 0, w, h, 0xffffffff); + // FPDF_REVERSE_BYTE_ORDER = 0x10, FPDF_ANNOT = 0x01 + m.FPDF_RenderPageBitmap( + bitmapPtr, + page.pagePtr, + 0, + 0, + w, + h, + 0, + 0x01 | 0x10, + ); + + // Second pass for the form layer. A widget with no appearance stream is + // drawn ONLY here - FPDF_ANNOT alone leaves such fields blank, which is + // why they were invisible in the editor but fine in the viewer. + const formEnv = doc.formEnvironment(); + if (formEnv) { + doc.notifyFormPageLoaded(page); + const formMod = m as unknown as { + FPDF_FFLDraw?: ( + env: number, + bitmap: number, + pagePtr: number, + startX: number, + startY: number, + sizeX: number, + sizeY: number, + rotate: number, + flags: number, + ) => void; + }; + try { + formMod.FPDF_FFLDraw?.( + formEnv, + bitmapPtr, + page.pagePtr, + 0, + 0, + w, + h, + 0, + 0x01 | 0x10, + ); + } catch { + /* the page content is already drawn; the form layer is additive */ + } + } + + const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr); + const stride = m.FPDFBitmap_GetStride(bitmapPtr); + const pixels = new Uint8ClampedArray(w * h * 4); + const heap = new Uint8Array( + (m.pdfium.wasmExports as unknown as { memory: WebAssembly.Memory }) + .memory.buffer, + bufferPtr, + stride * h, + ); + for (let y = 0; y < h; y++) { + const srcRow = y * stride; + const dstRow = y * w * 4; + pixels.set(heap.subarray(srcRow, srcRow + w * 4), dstRow); + } + return new ImageData(pixels, w, h); + } finally { + m.FPDFBitmap_Destroy(bitmapPtr); + } + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumSave.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumSave.ts new file mode 100644 index 0000000000..4f1c308eef --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumSave.ts @@ -0,0 +1,73 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +/** `FPDF_SaveAsCopy` flags. */ +const FPDF_INCREMENTAL = 1; + +interface SaveFlagsModule { + FPDF_SaveAsCopy?: (doc: number, writer: number, flags: number) => boolean; +} + +export interface SerializeOptions { + // Append a revision instead of rewriting: the only way a signature stays + // verifiable for the revision it signed. + incremental?: boolean; +} + +/** Serialise the current edited document back to a `Uint8Array`. */ +export class PdfiumSave { + static serialize( + doc: EditorDocument, + options: SerializeOptions = {}, + ): Uint8Array { + const m = doc.module; + const failedPages: number[] = []; + for (const page of doc.loadedPages()) { + try { + // Always force a flush before save. + if (page.dirty) page.markNeedsGenerate(); + page.flushGenerate(m); + page.clearDirty(); + } catch { + failedPages.push(page.index + 1); + } + } + if (failedPages.length > 0) { + // A swallowed flush failure would serialize the page's stale + // pre-edit content while the UI reports a successful save. + throw new Error( + `Could not apply edits on page${failedPages.length > 1 ? "s" : ""} ` + + `${failedPages.join(", ")}; save aborted so no edits are silently lost.`, + ); + } + + const writerPtr = m.PDFiumExt_OpenFileWriter(); + try { + // The writer the shim hands back is the FPDF_FILEWRITE the flagged + // entry point expects, so incremental mode needs no extra plumbing. + const withFlags = (m as unknown as SaveFlagsModule).FPDF_SaveAsCopy; + if (options.incremental && typeof withFlags === "function") { + withFlags(doc.docPtr, writerPtr, FPDF_INCREMENTAL); + } else { + m.PDFiumExt_SaveAsCopy(doc.docPtr, writerPtr); + } + const size = m.PDFiumExt_GetFileWriterSize(writerPtr); + const outBuf = m.pdfium.wasmExports.malloc(size); + try { + m.PDFiumExt_GetFileWriterData(writerPtr, outBuf, size); + const view = new Uint8Array(size); + const heap = new Uint8Array( + (m.pdfium.wasmExports as unknown as { memory: WebAssembly.Memory }) + .memory.buffer, + outBuf, + size, + ); + view.set(heap); + return view; + } finally { + m.pdfium.wasmExports.free(outBuf); + } + } finally { + m.PDFiumExt_CloseFileWriter(writerPtr); + } + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextReader.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextReader.ts new file mode 100644 index 0000000000..e3793d90d5 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextReader.ts @@ -0,0 +1,791 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { LineGrouper } from "@app/tools/pdfTextEditor/pdfium/LineGrouper"; +import { ParagraphGrouper } from "@app/tools/pdfTextEditor/pdfium/ParagraphGrouper"; +import { PdfiumAnnotationReader } from "@app/tools/pdfTextEditor/pdfium/PdfiumAnnotationReader"; +import { primeFontGlyphMap } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import type { + Affine, + GroupingMode, + PageRect, + RGBA, +} from "@app/tools/pdfTextEditor/types"; +import { readUtf16 } from "@app/services/pdfiumService"; +import { registerEmbeddedFace } from "@app/tools/pdfTextEditor/util/embeddedFace"; + +/** PDFium page-object type constants - mirrors `public/fpdf_edit.h`. */ +const FPDF_PAGEOBJ_TEXT = 1; +const FPDF_PAGEOBJ_IMAGE = 3; +const FPDF_PAGEOBJ_FORM = 5; + +/** Reads the editable objects out of a PDFium page. */ +export class PdfiumTextReader { + static populate( + doc: EditorDocument, + page: Page, + mode: GroupingMode = "auto", + ): void { + if (page.loaded) return; + const m = doc.module; + const pagePtr = page.pagePtr; + const count = m.FPDFPage_CountObjects(pagePtr); + + const runs: TextRun[] = []; + const images: ImageObject[] = []; + + // ONE text page for the whole walk: FPDFText_LoadPage runs full page text + // extraction, so opening it per text object made population O. + const textPagePtr = m.FPDFText_LoadPage(pagePtr); + try { + // Recurse into form xobjects: InDesign/Quark wrap content in + // FPDF_PAGEOBJ_FORM containers and the real text/images only show up. + walkObjects( + m, + pagePtr, + count, + runs, + images, + doc, + page, + [], + 0, + IDENTITY, + textPagePtr, + ); + + page.setRuns(runs); + page.setImages(images); + // Annotation text is drawn by FPDF_ANNOT but lives outside the object + // tree, so record the boxes to explain why it can't be edited. + PdfiumAnnotationReader.populate(m, page); + // LineGrouper always runs (merges per-glyph/per-word source objects into + // one line). + LineGrouper.apply(page); + if (mode === "auto") ParagraphGrouper.apply(page); + // Grouping is done. + // One walk feeds both: each was reading the same characters with its + // own WASM round-trips, doubling the cost of every page read. + const geometry = collectCharGeometry(m, page, textPagePtr); + if (geometry) { + inferRunCharSpacing(page, geometry); + captureCharPositions(geometry); + } + } finally { + m.FPDFText_ClosePage(textPagePtr); + } + page.loaded = true; + } + + // Returns the runs whose captured positions actually moved, so the caller + // can re-snapshot just those instead of re-rendering every overlay per tick. + static recapturePositions(doc: EditorDocument, page: Page): Set { + const m = doc.module; + if (!page.loaded || page.runs.length === 0) return new Set(); + // No flush: like FPDF_RenderPageBitmap, FPDFText_LoadPage walks the live + // object list. Regenerating here cost ~1s per keystroke on Firefox and is + // what save/repopulate do anyway. + const textPagePtr = m.FPDFText_LoadPage(page.pagePtr); + if (!textPagePtr) return new Set(); + try { + const geometry = collectCharGeometry(m, page, textPagePtr); + return geometry ? captureCharPositions(geometry) : new Set(); + } finally { + m.FPDFText_ClosePage(textPagePtr); + } + } +} + +/** Every backing PDFium object pointer mapped to its post-grouping run. */ +function indexRunsByObjectPtr(runs: TextRun[]): Map { + const map = new Map(); + for (const run of runs) { + const members = + run.paragraphLeafPtrs.length > 0 + ? run.paragraphLeafPtrs + : run.mergedFromPtrs.length > 0 + ? run.mergedFromPtrs + : [run.pdfiumObjPtr]; + for (const ptr of members) if (ptr) map.set(ptr, run); + } + return map; +} + +// Infer each run's effective character spacing from on-page char geometry: for +// consecutive text-page chars inside one run, `extra = nextOrigin.x - origin.x. +interface CharGeometry { + cp: number; + run: TextRun | null; + /** False when the engine could not give this character a box. */ + ok: boolean; + left: number; + right: number; + bottom: number; + originX: number; +} + +// Read every character's geometry once. Both consumers below need the same +// characters, so doing this twice was pure duplicated WASM traffic. +function collectCharGeometry( + m: WrappedPdfiumModule, + page: Page, + textPagePtr: number, +): CharGeometry[] | null { + if (page.runs.length === 0) return null; + const probe = m as unknown as { + FPDFText_GetLooseCharBox?: (tp: number, i: number, rect: number) => boolean; + FPDFText_GetCharOrigin?: ( + tp: number, + i: number, + x: number, + y: number, + ) => boolean; + }; + if (!probe.FPDFText_GetLooseCharBox) return null; + const charCount = m.FPDFText_CountChars(textPagePtr); + if (charCount <= 1) return null; + + const ptrToRun = indexRunsByObjectPtr(page.runs); + const wasm = m.pdfium.wasmExports; + const rectBuf = wasm.malloc(16); // FS_RECT: 4 floats {l, t, r, b} + const xPtr = wasm.malloc(8); + const yPtr = wasm.malloc(8); + const out: CharGeometry[] = []; + try { + for (let i = 0; i < charCount; i += 1) { + const cp = m.FPDFText_GetUnicode(textPagePtr, i); + const objPtr = m.FPDFText_GetTextObject(textPagePtr, i); + const run = objPtr ? (ptrToRun.get(objPtr) ?? null) : null; + const boxed = probe.FPDFText_GetLooseCharBox(textPagePtr, i, rectBuf); + const heap = (m.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8; + const f = new Float32Array(heap.buffer, rectBuf, 4); + let originX = Number.NaN; + if (probe.FPDFText_GetCharOrigin?.(textPagePtr, i, xPtr, yPtr)) { + originX = m.pdfium.getValue(xPtr, "double"); + } + out.push({ + cp, + run, + ok: boxed, + left: boxed ? f[0] : Number.NaN, + right: boxed ? f[2] : Number.NaN, + bottom: boxed ? f[3] : Number.NaN, + originX, + }); + } + } finally { + wasm.free(rectBuf); + wasm.free(xPtr); + wasm.free(yPtr); + } + return out; +} + +function inferRunCharSpacing(page: Page, geometry: CharGeometry[]): void { + if (page.runs.length === 0) return; + const samples = new Map(); + let prev: { + run: TextRun; + left: number; + right: number; + bottom: number; + } | null = null; + for (const g of geometry) { + const isWs = !g.cp || g.cp <= 0x20 || g.cp === 0xa0; + if (isWs) { + // A REAL space glyph (belongs to a text object) ends the pair chain - + // pairs across it would fold word spacing (Tw) into the estimate. + if (g.run) prev = null; + continue; + } + if (!g.run || !g.ok) { + prev = null; + continue; + } + const run = g.run; + const cur = { run, left: g.left, right: g.right, bottom: g.bottom }; + if (prev && prev.run === run) { + const advance = prev.right - prev.left; + const delta = cur.left - prev.left; + const extra = delta - advance; + // Same visual line, forward advance only, and NOT a word gap: real + // letter-spacing stays well under ~0.6em. + if ( + delta > 0 && + advance > 0 && + extra < run.fontSize * 0.6 && + Math.abs(cur.bottom - prev.bottom) < Math.max(1, run.fontSize * 0.25) + ) { + let arr = samples.get(run); + if (!arr) { + arr = []; + samples.set(run, arr); + } + arr.push(extra); + } + } + prev = cur; + } + + for (const [run, extras] of samples) { + if (extras.length < 2) continue; + // Upright runs only - the box math above is axis-aligned. + const scale = Math.hypot(run.matrix.a, run.matrix.b); + if (!scale || Math.abs(run.matrix.b) / scale > 0.02 || run.matrix.a <= 0) { + continue; + } + const sorted = [...extras].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + // Noise floor: kerning tweaks and float fuzz stay well under 2% of the + // font size; a real Tc (like a spaced-caps heading) is far above it. + const noise = Math.max(0.25, run.fontSize * 0.02); + if (Math.abs(median) < noise) continue; + // Sanity cap - a broken measurement must not explode the layout. + if (Math.abs(median) > run.fontSize * 2) continue; + run.charSpacingPt = median; + } +} + +/** NaN-safe element-wise equality for captured position arrays. */ +function samePositions(prev: number[] | null, next: number[]): boolean { + if (!prev || prev.length !== next.length) return false; + for (let i = 0; i < prev.length; i += 1) { + if (!Object.is(prev[i], next[i])) return false; + } + return true; +} + +// Record where the engine put every glyph, indexed by code unit of `text`. +// Both units of a surrogate pair share a value; synthesised spaces stay NaN. +function captureCharPositions(geometry: CharGeometry[]): Set { + const glyphs = new Map< + TextRun, + Array<{ cp: number; x: number; end: number }> + >(); + for (const g of geometry) { + if (!g.run || !g.cp || !g.ok) continue; + if (!Number.isFinite(g.originX) || g.right < g.originX) continue; + let list = glyphs.get(g.run); + if (!list) { + list = []; + glyphs.set(g.run, list); + } + // The loose box's right edge is the pen position after the glyph, which + // is what makes consecutive word boxes tile without drift. + list.push({ cp: g.cp, x: g.originX, end: g.right }); + } + + const changed = new Set(); + for (const [run, list] of glyphs) { + // Upright runs only: an origin's X is the advance direction only when the + // baseline is horizontal. + const scale = Math.hypot(run.matrix.a, run.matrix.b); + if (!scale || Math.abs(run.matrix.b) / scale > 0.02 || run.matrix.a <= 0) { + continue; + } + const aligned = alignToText(run.text, list); + if (!aligned) continue; + // Same positions under a still-current key is a no-op capture; skipping it + // keeps untouched runs' snapshots stable across the periodic tick. + if ( + samePositions(run.charStartsX, aligned.starts) && + samePositions(run.charEndsX, aligned.ends) && + run.charPositionsKey === run.positionsKey() + ) { + continue; + } + run.charStartsX = aligned.starts; + run.charEndsX = aligned.ends; + run.charPositionsKey = run.positionsKey(); + changed.add(run); + } + return changed; +} + +// Line up the engine's glyph list with the run's text - they are not +// index-for-index, and anything unplaceable is left unknown, not guessed. +function alignToText( + text: string, + glyphs: Array<{ cp: number; x: number; end: number }>, +): { starts: number[]; ends: number[] } | null { + const starts = new Array(text.length).fill(Number.NaN); + const ends = new Array(text.length).fill(Number.NaN); + let g = 0; + let placed = 0; + for (let i = 0; i < text.length;) { + const cp = text.codePointAt(i) ?? 0; + const units = cp > 0xffff ? 2 : 1; + if (g < glyphs.length && glyphs[g].cp === cp) { + for (let u = 0; u < units; u += 1) { + starts[i + u] = glyphs[g].x; + ends[i + u] = glyphs[g].end; + } + g += 1; + placed += 1; + } else if (g < glyphs.length && cp !== 0x20 && cp !== 0x0a) { + // The text has a character the glyph list does not: look at the next + // couple of glyphs only, so a long mismatching run stays linear. + let next = -1; + for (let at = g + 1; at <= g + 2 && at < glyphs.length; at += 1) { + if (glyphs[at].cp === cp) { + next = at; + break; + } + } + if (next > 0) { + g = next; + continue; + } + } + i += units; + } + // A capture that placed almost nothing is not worth trusting. + const visible = [...text].filter((c) => !/\s/.test(c)).length; + return placed >= Math.max(1, Math.floor(visible * 0.6)) + ? { starts, ends } + : null; +} + +/** Walk a list of PDFium page objects, collecting text and image objects. */ +type PdfiumWithForms = WrappedPdfiumModule & { + FPDFFormObj_CountObjects: (formObj: number) => number; + FPDFFormObj_GetObject: (formObj: number, index: number) => number; +}; + +function walkObjects( + m: WrappedPdfiumModule, + pagePtr: number, + count: number, + runs: TextRun[], + images: ImageObject[], + doc: EditorDocument, + page: Page, + path: number[], + depth: number, + transform: Affine, + textPagePtr: number, +): void { + const MAX_DEPTH = 4; + const formModule = m as PdfiumWithForms; + // Container pointer for the current depth - either the page (path=[]) + // or the form xobject we're recursing into. + const containerPtr = + path.length === 0 ? 0 : getFormContainer(m, pagePtr, path); + const topLevelContainerPtr = + path.length === 0 ? 0 : m.FPDFPage_GetObject(pagePtr, path[0]); + for (let i = 0; i < count; i++) { + const objPtr = + path.length === 0 + ? m.FPDFPage_GetObject(pagePtr, i) + : formModule.FPDFFormObj_GetObject(containerPtr, i); + if (!objPtr) continue; + const type = m.FPDFPageObj_GetType(objPtr); + if (type === FPDF_PAGEOBJ_TEXT) { + const indexId = [...path, i].join("-"); + const run = readTextRun( + m, + doc, + page, + objPtr, + indexId, + transform, + textPagePtr, + ); + if (run) { + run.containerPtr = containerPtr; + run.topLevelContainerPtr = topLevelContainerPtr; + runs.push(run); + } + } else if (type === FPDF_PAGEOBJ_IMAGE) { + const indexId = [...path, i].join("-"); + const img = readImage(m, page, objPtr, indexId, transform, containerPtr); + if (img) images.push(img); + } else if (type === FPDF_PAGEOBJ_FORM && depth < MAX_DEPTH) { + let formCount: number; + try { + formCount = formModule.FPDFFormObj_CountObjects(objPtr); + } catch { + formCount = 0; + } + if (formCount > 0) { + // Compose the form's own matrix onto the running transform so + // children's form-local coordinates resolve to page space. + const childTransform = composeAffine(transform, readMatrix(m, objPtr)); + walkObjects( + m, + pagePtr, + formCount, + runs, + images, + doc, + page, + [...path, i], + depth + 1, + childTransform, + textPagePtr, + ); + } + } + } +} + +/** Identity affine - the page-level transform. */ +const IDENTITY: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + +// Compose two affines: returns `parent ∘ child` (child applied first, then +// parent). +function composeAffine(parent: Affine, child: Affine): Affine { + return { + a: parent.a * child.a + parent.c * child.b, + b: parent.b * child.a + parent.d * child.b, + c: parent.a * child.c + parent.c * child.d, + d: parent.b * child.c + parent.d * child.d, + e: parent.a * child.e + parent.c * child.f + parent.e, + f: parent.b * child.e + parent.d * child.f + parent.f, + }; +} + +/** Map a point through an affine. */ +function applyAffine( + t: Affine, + x: number, + y: number, +): { x: number; y: number } { + return { x: t.a * x + t.c * y + t.e, y: t.b * x + t.d * y + t.f }; +} + +// Transform an axis-aligned rect by an affine and return the new AABB (all four +// corners mapped, then min/max). +function transformRect(t: Affine, r: PageRect): PageRect { + const c0 = applyAffine(t, r.x, r.y); + const c1 = applyAffine(t, r.x + r.width, r.y); + const c2 = applyAffine(t, r.x, r.y + r.height); + const c3 = applyAffine(t, r.x + r.width, r.y + r.height); + const xs = [c0.x, c1.x, c2.x, c3.x]; + const ys = [c0.y, c1.y, c2.y, c3.y]; + const minX = Math.min(...xs); + const minY = Math.min(...ys); + return { + x: minX, + y: minY, + width: Math.max(...xs) - minX, + height: Math.max(...ys) - minY, + }; +} + +/** True when the affine is (close to) the identity - skip work if so. */ +function isIdentity(t: Affine): boolean { + return ( + t.a === 1 && t.b === 0 && t.c === 0 && t.d === 1 && t.e === 0 && t.f === 0 + ); +} + +// Re-walk to the form container at the given index path so the recursive call +// can index its children. +function getFormContainer( + m: WrappedPdfiumModule, + pagePtr: number, + path: number[], +): number { + const formModule = m as PdfiumWithForms; + let current = m.FPDFPage_GetObject(pagePtr, path[0]); + for (let i = 1; i < path.length; i++) { + current = formModule.FPDFFormObj_GetObject(current, path[i]); + } + return current; +} + +function readBounds(m: WrappedPdfiumModule, objPtr: number): PageRect | null { + const lPtr = m.pdfium.wasmExports.malloc(4); + const bPtr = m.pdfium.wasmExports.malloc(4); + const rPtr = m.pdfium.wasmExports.malloc(4); + const tPtr = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(objPtr, lPtr, bPtr, rPtr, tPtr)) return null; + const left = m.pdfium.getValue(lPtr, "float"); + const bottom = m.pdfium.getValue(bPtr, "float"); + const right = m.pdfium.getValue(rPtr, "float"); + const top = m.pdfium.getValue(tPtr, "float"); + return { + x: Math.min(left, right), + y: Math.min(bottom, top), + width: Math.abs(right - left), + height: Math.abs(top - bottom), + }; + } finally { + m.pdfium.wasmExports.free(lPtr); + m.pdfium.wasmExports.free(bPtr); + m.pdfium.wasmExports.free(rPtr); + m.pdfium.wasmExports.free(tPtr); + } +} + +function readMatrix(m: WrappedPdfiumModule, objPtr: number): Affine { + // FS_MATRIX: { a, b, c, d, e, f } as floats. + const buf = m.pdfium.wasmExports.malloc(6 * 4); + try { + const ok = m.FPDFPageObj_GetMatrix(objPtr, buf); + if (!ok) return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + return { + a: m.pdfium.getValue(buf, "float"), + b: m.pdfium.getValue(buf + 4, "float"), + c: m.pdfium.getValue(buf + 8, "float"), + d: m.pdfium.getValue(buf + 12, "float"), + e: m.pdfium.getValue(buf + 16, "float"), + f: m.pdfium.getValue(buf + 20, "float"), + }; + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +function readFill(m: WrappedPdfiumModule, objPtr: number): RGBA { + const r = m.pdfium.wasmExports.malloc(4); + const g = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const a = m.pdfium.wasmExports.malloc(4); + try { + const ok = m.FPDFPageObj_GetFillColor(objPtr, r, g, b, a); + if (!ok) return { r: 0, g: 0, b: 0, a: 255 }; + return { + r: m.pdfium.getValue(r, "i32") & 0xff, + g: m.pdfium.getValue(g, "i32") & 0xff, + b: m.pdfium.getValue(b, "i32") & 0xff, + a: m.pdfium.getValue(a, "i32") & 0xff, + }; + } finally { + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(g); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(a); + } +} + +interface StrokeReaderModule { + FPDFPageObj_GetStrokeColor?: ( + obj: number, + r: number, + g: number, + b: number, + a: number, + ) => boolean; + FPDFPageObj_GetStrokeWidth?: (obj: number, out: number) => boolean; +} + +/** Render modes that actually put stroke ink on the page. */ +const STROKING_MODES = new Set([1, 2, 5, 6]); + +// Outline colour and width, or null when the object does not stroke. PDFium +// reports a stroke colour for every text object, so the render mode decides. +function readStroke( + m: WrappedPdfiumModule, + objPtr: number, + renderMode: number, +): { stroke: RGBA | null; strokeWidth: number } { + if (!STROKING_MODES.has(renderMode)) return { stroke: null, strokeWidth: 0 }; + const mod = m as unknown as StrokeReaderModule; + const getColor = mod.FPDFPageObj_GetStrokeColor; + const getWidth = mod.FPDFPageObj_GetStrokeWidth; + if (!getColor) return { stroke: null, strokeWidth: 0 }; + const r = m.pdfium.wasmExports.malloc(4); + const g = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const a = m.pdfium.wasmExports.malloc(4); + const w = m.pdfium.wasmExports.malloc(4); + try { + if (!getColor(objPtr, r, g, b, a)) return { stroke: null, strokeWidth: 0 }; + const alpha = m.pdfium.getValue(a, "i32") & 0xff; + let strokeWidth = 0; + if (getWidth && getWidth(objPtr, w)) { + const raw = m.pdfium.getValue(w, "float"); + if (Number.isFinite(raw) && raw > 0) strokeWidth = raw; + } + return { + stroke: { + r: m.pdfium.getValue(r, "i32") & 0xff, + g: m.pdfium.getValue(g, "i32") & 0xff, + b: m.pdfium.getValue(b, "i32") & 0xff, + a: alpha, + }, + strokeWidth, + }; + } catch { + return { stroke: null, strokeWidth: 0 }; + } finally { + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(g); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(a); + m.pdfium.wasmExports.free(w); + } +} + +function readTextObjString( + m: WrappedPdfiumModule, + textPagePtr: number, + objPtr: number, +): string { + // First call returns size in bytes for the UTF-16 buffer (including NUL). + const len = m.FPDFTextObj_GetText(objPtr, textPagePtr, 0, 0); + if (len <= 2) return ""; + const buf = m.pdfium.wasmExports.malloc(len); + try { + m.FPDFTextObj_GetText(objPtr, textPagePtr, buf, len); + return readUtf16(m, buf, len); + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +/** 6-letter "ABCDEF+" subset tag PDFium prefixes onto subset font names. */ +const SUBSET_TAG_RE = /^[A-Z]{6}\+/; + +/** Read a UTF-8 font name via an FPDFFont_Get*Name accessor (null if empty). */ +function readFontNameVia( + m: WrappedPdfiumModule, + fontPtr: number, + getName: (font: number, buf: number, len: number) => number, +): string | null { + const len = getName(fontPtr, 0, 0); + if (len <= 1) return null; + const buf = m.pdfium.wasmExports.malloc(len); + try { + getName(fontPtr, buf, len); + return m.pdfium.UTF8ToString(buf); + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +function readFontFamily( + m: WrappedPdfiumModule, + fontPtr: number, +): { family: string; subset: boolean } { + if (!fontPtr) return { family: "Unknown", subset: false }; + const familyRaw = readFontNameVia(m, fontPtr, m.FPDFFont_GetFamilyName); + // Some PDFs carry the 6-letter subset tag only on /BaseFont, not the embedded + // name table. + const baseRaw = readFontNameVia(m, fontPtr, m.FPDFFont_GetBaseFontName); + // Plenty of embedded fonts expose no name-table family at all. /BaseFont + // still names the face, and that name is what decides the fallback's + // serif/sans class - calling it "Unknown" silently substituted Helvetica + // into serif documents. + const nameRaw = familyRaw ?? baseRaw; + if (nameRaw == null) return { family: "Unknown", subset: false }; + const tagged = SUBSET_TAG_RE.test(nameRaw); + const family = tagged ? nameRaw.slice(7) : nameRaw; + if (tagged) return { family, subset: true }; + return { family, subset: baseRaw != null && SUBSET_TAG_RE.test(baseRaw) }; +} + +function readTextRun( + m: WrappedPdfiumModule, + _doc: EditorDocument, + page: Page, + objPtr: number, + index: number | string, + transform: Affine, + textPagePtr: number, +): TextRun | null { + { + const text = readTextObjString(m, textPagePtr, objPtr); + if (!text || text.length === 0) return null; + // Whitespace-only objects (positional space glyphs) would surface as + // invisible, selectable, editable ghost runs - skip them. + if (text.trim().length === 0) return null; + + const localBounds = readBounds(m, objPtr); + if (!localBounds) return null; + const localMatrix = readMatrix(m, objPtr); + const fill = readFill(m, objPtr); + + // Lift form-local coordinates into page space. For page-level text + // `transform` is identity and these are no-ops. + const ident = isIdentity(transform); + const bounds = ident ? localBounds : transformRect(transform, localBounds); + const matrix = ident ? localMatrix : composeAffine(transform, localMatrix); + + const sizePtr = m.pdfium.wasmExports.malloc(4); + let rawFontSize = 12; + try { + if (m.FPDFTextObj_GetFontSize(objPtr, sizePtr)) { + rawFontSize = m.pdfium.getValue(sizePtr, "float"); + } + } finally { + m.pdfium.wasmExports.free(sizePtr); + } + // The on-page visible font size is `rawFontSize * |matrix scale|`. + const matrixScale = + Math.sqrt(matrix.a * matrix.a + matrix.b * matrix.b) || 1; + const fontSize = rawFontSize * matrixScale; + + const fontPtr = m.FPDFTextObj_GetFont(objPtr); + const { family, subset } = readFontFamily(m, fontPtr); + // Prime this font's glyph cmap here, in the loader's SERIALIZED text-read + // phase (before the page rasterizes). + if (fontPtr) primeFontGlyphMap(fontPtr, m); + // Make the same face available to the overlay as a CSS FontFace. + if (fontPtr) registerEmbeddedFace(m, fontPtr); + // Treat the PDFium font handle pointer as a unique id within the doc. + const fontId = fontPtr ? `pdf:${fontPtr}` : `pdf:unknown-${index}`; + + // Text render mode (PDF Tr): 0 fill (default), 1/2 stroke variants, 3 + // invisible (OCR text layers over scans), 4-7 clipping variants. + let renderMode = 0; + const rm = ( + m as unknown as { + FPDFTextObj_GetTextRenderMode?: (obj: number) => number; + } + ).FPDFTextObj_GetTextRenderMode; + if (rm) { + try { + const v = rm(objPtr); + if (Number.isInteger(v) && v >= 0 && v <= 7) renderMode = v; + } catch { + /* keep default */ + } + } + + const { stroke, strokeWidth } = readStroke(m, objPtr, renderMode); + + return new TextRun({ + id: `p${page.index}-t${index}`, + pageIndex: page.index, + pdfiumObjPtr: objPtr, + bounds, + matrix, + text, + fontId: `${fontId}:${family}`, + fontSize, + fill, + fontSubset: subset, + renderMode, + stroke: stroke ?? undefined, + strokeWidth, + }); + } +} + +function readImage( + m: WrappedPdfiumModule, + page: Page, + objPtr: number, + index: number | string, + transform: Affine, + containerPtr: number, +): ImageObject | null { + const localBounds = readBounds(m, objPtr); + if (!localBounds) return null; + const localMatrix = readMatrix(m, objPtr); + const ident = isIdentity(transform); + return new ImageObject({ + id: `p${page.index}-i${index}`, + pageIndex: page.index, + pdfiumObjPtr: objPtr, + bounds: ident ? localBounds : transformRect(transform, localBounds), + matrix: ident ? localMatrix : composeAffine(transform, localMatrix), + containerPtr, + }); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextWriter.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextWriter.ts new file mode 100644 index 0000000000..ed11bd073b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextWriter.ts @@ -0,0 +1,101 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { writeUtf16 } from "@app/services/pdfiumService"; +import { collectMemberPtrs } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; + +// Narrowest base-14 glyph ("i") is ~0.22em, so ink well under ~0.15em per +// visible char means the font produced .notdef / zero-width filler. +const MIN_INK_EM_PER_CHAR = 0.15; + +/** Pushes `TextRun` mutations into PDFium. */ +export class PdfiumTextWriter { + /** + * Set the run's text on its existing PDFium object. + * + * Returns false when the object's font could not actually encode the text. + * `FPDFText_SetText` re-encodes from Unicode and silently substitutes filler + * charcodes for anything the font cannot map - on a Type 3 or symbolically + * encoded subset that yields blank, zero-advance glyphs. The caller must + * treat false as "this fast path is unusable" and re-emit through the + * validated overlay path instead of shipping the corrupted object. + */ + static commitRunText(doc: EditorDocument, page: Page, run: TextRun): boolean { + if (!run.pdfiumObjPtr) return false; + const m = doc.module; + const ptr = writeUtf16(m, run.text); + try { + m.FPDFText_SetText(run.pdfiumObjPtr, ptr); + } finally { + m.pdfium.wasmExports.free(ptr); + } + // Defer the regen: FPDFPageObj_GetBounds reads the object, not the + // stream, and a direct call here would skip the page's regenerated flag. + page.markNeedsGenerate(); + // Re-measure the run's bounds. Stale width corrupts all of those. + const bbox = measureObjBboxPt(m, run.pdfiumObjPtr); + if (!bbox) { + // Can't measure, so can't disprove the write; keep the old behaviour. + return true; + } + const width = Math.max(0, bbox.right - bbox.left); + const visible = run.text.replace(/\s+/gu, "").length; + const fontSize = run.fontSize > 0 ? run.fontSize : 0; + if (visible > 0 && fontSize > 0) { + if (width < visible * fontSize * MIN_INK_EM_PER_CHAR) { + // Leave `run.bounds` alone: the collapsed box is not real geometry. + return false; + } + } + run.bounds = { ...run.bounds, x: bbox.left, width }; + return true; + } + + static commitRunFill(doc: EditorDocument, page: Page, run: TextRun): void { + const m = doc.module; + // Recolour EVERY sub-object. + const ptrs = collectMemberPtrs(run); + if (ptrs.every((p) => !p)) return; + const seen = new Set(); + for (const ptr of ptrs) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + try { + m.FPDFPageObj_SetFillColor( + ptr, + run.fill.r, + run.fill.g, + run.fill.b, + run.fill.a, + ); + } catch { + /* best-effort - stale ptrs silently skipped */ + } + } + page.markNeedsGenerate(); + } +} + +/** Read the visible-bbox of a text object in PDF points. */ +function measureObjBboxPt( + m: WrappedPdfiumModule, + objPtr: number, +): { left: number; right: number } | null { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(objPtr, l, b, r, t)) return null; + return { + left: m.pdfium.getValue(l, "float"), + right: m.pdfium.getValue(r, "float"), + }; + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/store/EditorStore.ts b/frontend/editor/src/core/tools/pdfTextEditor/store/EditorStore.ts new file mode 100644 index 0000000000..05edd8a1e8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/store/EditorStore.ts @@ -0,0 +1,514 @@ +import { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { HistoryStack } from "@app/tools/pdfTextEditor/store/HistoryStack"; +import { Selection } from "@app/tools/pdfTextEditor/store/Selection"; +import { pageGuides } from "@app/tools/pdfTextEditor/util/guides"; +import { PdfiumTextReader } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextReader"; +import { + PdfiumModelSync, + type ModelSyncResult, +} from "@app/tools/pdfTextEditor/pdfium/PdfiumModelSync"; +import { resetBackendResolverCaches } from "@app/tools/pdfTextEditor/charcode/BackendResolver"; +import { resetCmapCache } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import { resetContentStreamCache } from "@app/tools/pdfTextEditor/charcode/ContentStreamResolver"; +import { + resetCharCoverageCache, + resetDroppedBase14Chars, + resetOnPageAdvCache, + resetPerCharBranchPtrs, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import type { + GroupingMode, + PageSnapshot, + WidthMode, +} from "@app/tools/pdfTextEditor/types"; +import { resetEmbeddedFaces } from "@app/tools/pdfTextEditor/util/embeddedFace"; + +/** Drop EVERY per-document charcode/glyph cache. */ +function resetCharcodeCaches(): void { + resetBackendResolverCaches(); + resetCmapCache(); + resetContentStreamCache(); + resetOnPageAdvCache(); + // The per-char ptr set is doc-scoped since PDFium reuses pointers. + resetPerCharBranchPtrs(); + // The dropped-char record is per-session/per-document, not pointer-keyed. + resetDroppedBase14Chars(); + resetCharCoverageCache(); + // FontFaces are keyed by font pointer, which PDFium reuses across documents. + resetEmbeddedFaces(); +} + +export type InteractionMode = "select" | "addText"; + +export interface LoadProgress { + /** Stage description shown in the loader: "Reading file", "Parsing PDF", "Loading page 3/60", etc. */ + stage: string; + /** Completed work units (e.g. pages loaded). */ + current: number; + /** Total work units (e.g. total pages). 0 when unknown. */ + total: number; +} + +export interface EditorViewState { + hasDocument: boolean; + pageCount: number; + pages: PageSnapshot[]; + /** Document-level dirty bit (any page dirty). */ + dirty: boolean; + /** Async lifecycle markers. */ + loading: boolean; + /** True once the first page's bitmap has actually painted in PageView. */ + firstPageRendered: boolean; + /** Detailed progress for the loading state. */ + progress: LoadProgress | null; + error: string | null; + // Set when a load hit a password-protected PDF and the UI should prompt. + // `retry` is true after a wrong password so the prompt can say so. + passwordPrompt: { fileName: string; retry: boolean } | null; + /** Pixel scale at which previews are rendered. */ + renderScale: number; + /** What clicks on the page area do. */ + mode: InteractionMode; + /** How the reader clusters source text into editable runs. */ + groupingMode: GroupingMode; + // How an editable text box resizes as the user types more than fits: - + // "grow": the box widens to the right, never wrapping. + widthMode: WidthMode; + /** Show per-page rulers and alignment guides. */ + showRulers: boolean; +} + +const POSITION_REFRESH_MS = 600; + +// Longest the engine's pen positions may stay stale while the user keeps +// typing. Past this the debounce above stops being postponed and runs anyway. +const POSITION_REFRESH_MAX_STALL_MS = 100; + +const INITIAL: EditorViewState = { + hasDocument: false, + pageCount: 0, + pages: [], + dirty: false, + loading: false, + firstPageRendered: false, + progress: null, + error: null, + passwordPrompt: null, + renderScale: 1.5, + mode: "select", + groupingMode: "auto", + widthMode: "grow", + showRulers: false, +}; + +// Single observable store for the editor's React layer. Components never reach +// into PDFium directly - they dispatch commands. +export class EditorStore { + readonly history: HistoryStack; + readonly selection: Selection; + private doc: EditorDocument | null; + private state: EditorViewState; + private listeners: Set<(s: EditorViewState) => void>; + // The undo-stack TOP at the last save; the doc is dirty when the current top + // is a different command object. + private savedTop: Command | null = null; + /** True when edits were baked into the stream (e.g. grouping-mode switch). */ + private bakedDirty = false; + private positionRefreshTimer: number | null = null; + /** When the debounced position refresh last actually ran. */ + private lastPositionRefreshAt = 0; + /** Monotonic token so a superseded async load can detect it lost the race. */ + private loadToken = 0; + /** File awaiting a password retry; held off the view state (not serialisable). */ + private _pendingPasswordFile: File | null = null; + + constructor() { + this.history = new HistoryStack(); + this.selection = new Selection(); + this.doc = null; + this.state = INITIAL; + this.listeners = new Set(); + } + + get document(): EditorDocument | null { + return this.doc; + } + + getState(): EditorViewState { + return this.state; + } + + subscribe(listener: (s: EditorViewState) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + setLoading(loading: boolean): void { + // Starting a load clears any stale error. + if (loading) { + this.patch({ loading: true, error: null }); + } else { + this.patch({ loading: false, progress: null }); + } + } + + setProgress(progress: LoadProgress | null): void { + this.patch({ progress }); + } + + markFirstPageRendered(): void { + if (this.state.firstPageRendered) return; + this.patch({ firstPageRendered: true }); + } + + setError(error: string | null): void { + this.patch({ error, loading: false }); + } + + /** A load needs a password. */ + setPasswordRequired(file: File, retry: boolean): void { + this._pendingPasswordFile = file; + this.patch({ + passwordPrompt: { fileName: file.name, retry }, + loading: false, + error: null, + }); + } + + /** Dismiss the password prompt (cancel or success) and drop the pending file. */ + clearPasswordPrompt(): void { + this._pendingPasswordFile = null; + if (this.state.passwordPrompt) this.patch({ passwordPrompt: null }); + } + + get pendingPasswordFile(): File | null { + return this._pendingPasswordFile; + } + + setRenderScale(scale: number): void { + this.patch({ renderScale: scale }); + } + + setMode(mode: InteractionMode): void { + this.patch({ mode }); + } + + setWidthMode(widthMode: WidthMode): void { + this.patch({ widthMode }); + } + + setShowRulers(showRulers: boolean): void { + this.patch({ showRulers }); + } + + get groupingMode(): GroupingMode { + return this.state.groupingMode; + } + + // Switch how source text is clustered into runs (Auto = detect paragraphs, + // Line = one run per source line). + setGroupingMode(mode: GroupingMode): void { + if (this.state.groupingMode === mode) return; + const doc = this.doc; + if (!doc) { + this.patch({ groupingMode: mode }); + return; + } + // Re-reading rebuilds run IDs, so the undo history can't survive the switch + // and is cleared. + const wasDirty = this.isDirty(); + // Flushes first: the rebuilt runs must reflect the user's current edits. + this.repopulateAllPages(doc, mode); + this.history.clear(); + this.savedTop = null; + this.bakedDirty = wasDirty; + this.selection.clear(); + const pages: PageSnapshot[] = this.state.pages.map((p) => { + const live = doc.page(p.pageIndex); + if (!live.loaded) return p; + return { + ...p, + revision: live.revision, + runs: live.runs.map((r) => r.snapshot()), + images: live.images.map((img) => img.snapshot()), + // Regrouping re-populates the page, which re-reads its annotations. + annotations: live.annotations, + }; + }); + this.patch({ groupingMode: mode, pages, dirty: this.isDirty() }); + } + + /** Begin a load and return a token. */ + beginLoad(): number { + return ++this.loadToken; + } + + isCurrentLoad(token: number): boolean { + return this.loadToken === token; + } + + async setDocument(doc: EditorDocument): Promise { + this.disposeDocumentIfAny(); + resetCharcodeCaches(); + this.doc = doc; + this.history.clear(); + this.savedTop = null; + this.bakedDirty = false; + this.selection.clear(); + pageGuides.clear(); + this._pendingPasswordFile = null; + this.patch({ + hasDocument: true, + pageCount: doc.pageCount, + pages: [], + dirty: false, + loading: false, + firstPageRendered: false, + error: null, + passwordPrompt: null, + }); + } + + clearDocument(): void { + this.disposeDocumentIfAny(); + resetCharcodeCaches(); + this.history.clear(); + this.savedTop = null; + this.bakedDirty = false; + this.selection.clear(); + this._pendingPasswordFile = null; + this.state = INITIAL; + this.notify(); + } + + /** Mark the current edit state as saved; clears the dirty indicator. */ + savedPosition(): Command | null { + this.history.breakCoalescing(); + return this.history.peekUndo(); + } + + markSaved(position?: Command | null): void { + // Break the coalesce burst so a post-save keystroke is a new dirtying step. + this.history.breakCoalescing(); + const saved = position === undefined ? this.history.peekUndo() : position; + this.savedTop = saved; + this.bakedDirty = false; + this.patch({ dirty: this.isDirty() }); + } + + /** Apply a command via the history stack, re-snapshot, and notify. */ + dispatch(cmd: Command): void { + if (!this.doc) return; + this.history.execute(cmd, this.doc); + this.resnapshot(); + this.patch({ dirty: this.isDirty() }); + this.schedulePositionRefresh(); + } + + private schedulePositionRefresh(): void { + if (typeof window === "undefined") return; + if (this.positionRefreshTimer !== null) { + window.clearTimeout(this.positionRefreshTimer); + } + // Debounced, but never starved. Re-clearing the timer on every keystroke + // meant a continuous burst postponed this indefinitely, and until it runs + // the overlay has no measured pen positions for the new text - so it lays + // it out on the BROWSER's advances and the caret walks off the glyphs the + // page is actually showing, about a pixel per character, snapping back + // only when the user pauses. A full recapture of every loaded page costs + // single-digit milliseconds, so a burst can afford one every so often. + const since = Date.now() - this.lastPositionRefreshAt; + const delay = Math.min( + POSITION_REFRESH_MS, + Math.max(0, POSITION_REFRESH_MAX_STALL_MS - since), + ); + this.positionRefreshTimer = window.setTimeout(() => { + this.positionRefreshTimer = null; + this.lastPositionRefreshAt = Date.now(); + const doc = this.doc; + if (!doc) return; + const changedByPage = new Map>(); + for (const page of doc.loadedPages()) { + try { + // Positions only. `PdfiumModelSync.resyncPage` re-reads the whole + // page and would give identity-preserved RUNS too, but it re-runs + // grouping, font registration and the annotation walk on every tick + // for no gain while only positions may safely be adopted mid-edit. + const changed = PdfiumTextReader.recapturePositions(doc, page); + if (changed.size > 0) changedByPage.set(page.index, changed); + } catch { + continue; + } + } + if (changedByPage.size > 0) this.refreshRunSnapshots(changedByPage); + }, delay); + } + + // Re-read one page's geometry from the engine immediately, keeping run ids. + // The debounced refresh above calls the same thing; this is the un-debounced + // entry point for callers that need it now (and for measuring its cost). + resyncPage(pageIndex: number): ModelSyncResult | null { + const doc = this.doc; + if (!doc) return null; + try { + return PdfiumModelSync.resyncPage( + doc, + doc.page(pageIndex), + this.groupingMode, + ); + } catch { + return null; + } + } + + // Publish fresh snapshots ONLY for runs whose positions moved. Re-snapshotting + // every run made the periodic tick re-render every overlay on every page per + // keystroke; reusing identities lets React skip the untouched ones. + private refreshRunSnapshots(changedByPage: Map>): void { + const doc = this.doc; + if (!doc) return; + this.patch({ + pages: this.state.pages.map((p) => { + const changed = changedByPage.get(p.pageIndex); + if (!changed || changed.size === 0) return p; + const live = doc.page(p.pageIndex); + const prevById = new Map(p.runs.map((s) => [s.id, s])); + return { + ...p, + runs: live.runs.map((r) => + changed.has(r) + ? r.snapshot() + : (prevById.get(r.id) ?? r.snapshot()), + ), + }; + }), + }); + } + + undo(): void { + if (!this.doc) return; + try { + this.history.undo(this.doc); + } catch { + this.recoverFromBrokenStep(); + return; + } + this.resnapshot(); + this.patch({ dirty: this.isDirty() }); + } + + redo(): void { + if (!this.doc) return; + try { + this.history.redo(this.doc); + } catch { + this.recoverFromBrokenStep(); + return; + } + this.resnapshot(); + this.patch({ dirty: this.isDirty() }); + } + + // A half-applied command leaves the run model describing objects that no + // longer match the page, so rebuild it from PDFium rather than guess. + private recoverFromBrokenStep(): void { + const doc = this.doc; + if (!doc) return; + this.repopulateAllPages(doc, this.state.groupingMode); + // Rebuilt runs get fresh ids, so no existing history entry can apply. + this.history.clear(); + this.savedTop = null; + this.bakedDirty = true; + this.selection.clear(); + this.resnapshot(); + this.patch({ dirty: true }); + } + + /** Drop every page's run model and read it back from the document. */ + private repopulateAllPages(doc: EditorDocument, mode: GroupingMode): void { + for (const page of doc.loadedPages()) { + if (!page.loaded) continue; + page.flushGenerate(doc.module); + page.loaded = false; + page.setRuns([]); + page.setImages([]); + PdfiumTextReader.populate(doc, page, mode); + } + } + + /** Revert every edit in history; document returns to its load state. */ + resetAll(): void { + if (!this.doc) return; + this.history.undoAll(this.doc); + this.resnapshot(); + this.patch({ dirty: this.isDirty() }); + } + + /** Re-read the model into a fresh page-snapshot array and publish it. */ + resnapshot(): void { + if (!this.doc) return; + let changed = false; + const doc = this.doc; + const pages: PageSnapshot[] = this.state.pages.map((p) => { + const live = doc.page(p.pageIndex); + if (live.revision === p.revision) return p; + changed = true; + return { + ...p, + dirty: live.dirty, + revision: live.revision, + runs: live.runs.map((r) => r.snapshot()), + images: live.images.map((img) => img.snapshot()), + }; + }); + if (!changed) return; + this.patch({ pages }); + } + + // Push a fresh page snapshot list into the store - called by the React loader + // once `PdfiumTextReader` finishes for a page. + publishPages(pages: PageSnapshot[]): void { + this.patch({ pages }); + } + + /** Document-level dirty bit. */ + private isDirty(): boolean { + if (!this.doc) return false; + return this.bakedDirty || this.history.peekUndo() !== this.savedTop; + } + + private patch(partial: Partial): void { + this.state = { ...this.state, ...partial }; + this.notify(); + } + + private notify(): void { + // Snapshot listeners before iterating. + const snapshot = Array.from(this.listeners); + for (const l of snapshot) { + try { + l(this.state); + } catch { + /* one listener throwing must not stop the rest */ + } + } + } + + private disposeDocumentIfAny(): void { + if (this.doc) { + try { + this.doc.dispose(); + } catch { + /* best-effort */ + } + this.doc = null; + } + } + + dispose(): void { + this.disposeDocumentIfAny(); + this.listeners.clear(); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/store/HistoryStack.ts b/frontend/editor/src/core/tools/pdfTextEditor/store/HistoryStack.ts new file mode 100644 index 0000000000..a403e4a56e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/store/HistoryStack.ts @@ -0,0 +1,147 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +const DEFAULT_LIMIT = 200; + +// Commands sharing a coalesce key that execute within this many ms of each +// other are grouped into one undo step. contentEditable fires several `input`. +const COALESCE_WINDOW_MS = 600; + +/** A command threw mid-step, so the document no longer matches the history. */ +export class HistoryStepError extends Error { + readonly phase: "apply" | "revert"; + readonly cause: unknown; + + constructor(phase: "apply" | "revert", cause: unknown) { + super(`Command failed to ${phase}`); + this.name = "HistoryStepError"; + this.phase = phase; + this.cause = cause; + } +} + +// LIFO command history for undo/redo. - `execute` applies the command and +// pushes it. +export class HistoryStack { + private readonly undoStack: Command[]; + private readonly redoStack: Command[]; + private readonly limit: number; + /** Coalesce key of the last executed command, or null if not coalescable. */ + private lastCoalesceKey: string | null = null; + /** Timestamp (ms) of the last execute(), for the coalesce time window. */ + private lastExecuteAt = 0; + + constructor(limit: number = DEFAULT_LIMIT) { + this.undoStack = []; + this.redoStack = []; + this.limit = limit; + } + + get canUndo(): boolean { + return this.undoStack.length > 0; + } + + get canRedo(): boolean { + return this.redoStack.length > 0; + } + + size(): { undo: number; redo: number } { + return { undo: this.undoStack.length, redo: this.redoStack.length }; + } + + /** The command a plain undo would revert next (null when empty). */ + peekUndo(): Command | null { + return this.undoStack[this.undoStack.length - 1] ?? null; + } + + execute(cmd: Command, doc: EditorDocument): void { + // Read the clock BEFORE apply: the window is meant to measure the user's + // idle time between edits. + const startedAt = Date.now(); + cmd.apply(doc); + const key = cmd.coalesceKey?.() ?? null; + const top = this.undoStack[this.undoStack.length - 1]; + // The command a merge would join. Unwrap a group to its most recent + // child so the hook compares against a real edit, not the wrapper. + const previous = (top instanceof CompositeCommand ? top.last : top) ?? null; + // Group with the previous command when it shares a coalesce key and ran + // within the time window. + const inWindow = + startedAt - this.lastExecuteAt <= COALESCE_WINDOW_MS || + cmd.coalesceIgnoresTimeWindow?.(previous) === true; + if (key !== null && key === this.lastCoalesceKey && top && inWindow) { + if (top instanceof CompositeCommand) { + top.push(cmd); + } else { + this.undoStack[this.undoStack.length - 1] = new CompositeCommand([ + top, + cmd, + ]); + } + } else { + this.undoStack.push(cmd); + if (this.undoStack.length > this.limit) { + this.undoStack.shift(); + } + } + this.lastCoalesceKey = key; + // Stamped after apply() so the next execute() measures the idle gap. + this.lastExecuteAt = Date.now(); + this.redoStack.length = 0; + } + + undo(doc: EditorDocument): Command | null { + const cmd = this.undoStack.pop(); + if (!cmd) return null; + try { + cmd.revert(doc); + } catch (err) { + // The command is already popped and the document is in an unknown + // state, so the caller has to rebuild rather than keep undoing. + this.lastCoalesceKey = null; + throw new HistoryStepError("revert", err); + } + this.redoStack.push(cmd); + // End the coalescing burst - a later edit starts a fresh undo step. + this.lastCoalesceKey = null; + return cmd; + } + + redo(doc: EditorDocument): Command | null { + const cmd = this.redoStack.pop(); + if (!cmd) return null; + try { + cmd.apply(doc); + } catch (err) { + this.lastCoalesceKey = null; + throw new HistoryStepError("apply", err); + } + this.undoStack.push(cmd); + this.lastCoalesceKey = null; + return cmd; + } + + clear(): void { + this.undoStack.length = 0; + this.redoStack.length = 0; + this.lastCoalesceKey = null; + } + + /** End the coalescing burst so the next execute starts a fresh undo step. */ + breakCoalescing(): void { + this.lastCoalesceKey = null; + } + + /** Revert every command currently on the undo stack, in reverse order. */ + undoAll( + doc: import("@app/tools/pdfTextEditor/model/EditorDocument").EditorDocument, + ): number { + let count = 0; + while (this.undoStack.length > 0) { + this.undo(doc); + count += 1; + } + return count; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/store/Selection.ts b/frontend/editor/src/core/tools/pdfTextEditor/store/Selection.ts new file mode 100644 index 0000000000..4bb76362f8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/store/Selection.ts @@ -0,0 +1,113 @@ +import type { SelectionState } from "@app/tools/pdfTextEditor/types"; + +// Singleton "find highlight" state, kept off the SelectionState (which is used +// for edit commands) so search highlights survive normal selection changes. +export class FindHighlight { + private id: string | null = null; + private listeners: Set<(id: string | null) => void> = new Set(); + + set(runId: string | null): void { + if (this.id === runId) return; + this.id = runId; + // Snapshot + guard so one throwing/unsubscribing listener can't abort + // notification of the rest (see EditorStore.notify for the rationale). + for (const l of Array.from(this.listeners)) { + try { + l(this.id); + } catch { + /* one listener throwing must not stop the rest */ + } + } + } + get(): string | null { + return this.id; + } + subscribe(l: (id: string | null) => void): () => void { + this.listeners.add(l); + return () => this.listeners.delete(l); + } +} + +export class Selection { + private state: SelectionState; + private listeners: Set<(s: SelectionState) => void>; + /** Yellow highlight for the current find-bar match. */ + readonly highlight: FindHighlight; + + constructor() { + this.state = { runIds: [], imageIds: [], caret: null }; + this.listeners = new Set(); + this.highlight = new FindHighlight(); + } + + get value(): SelectionState { + return this.state; + } + + set(next: SelectionState): void { + this.state = next; + this.notify(); + } + + clear(): void { + this.set({ runIds: [], imageIds: [], caret: null }); + } + + selectOne(runId: string, caret: number | null = null): void { + this.set({ runIds: [runId], imageIds: [], caret }); + } + + toggle(runId: string): void { + if (this.state.runIds.includes(runId)) { + this.set({ + ...this.state, + runIds: this.state.runIds.filter((id) => id !== runId), + caret: null, + }); + } else { + this.set({ + ...this.state, + runIds: [...this.state.runIds, runId], + caret: null, + }); + } + } + + selectImage(imageId: string): void { + this.set({ runIds: [], imageIds: [imageId], caret: null }); + } + + /** + * Replace the selection with `runIds`, or union them into it when additive + * (an extending rectangle-select). Additive keeps order, dedupes, and leaves + * any selected images alone. + */ + selectMany(runIds: string[], additive = false): void { + if (!additive) { + this.set({ runIds: [...runIds], imageIds: [], caret: null }); + return; + } + const merged = [...this.state.runIds]; + for (const id of runIds) { + if (!merged.includes(id)) merged.push(id); + } + this.set({ ...this.state, runIds: merged, caret: null }); + } + + subscribe(listener: (s: SelectionState) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + // Snapshot + guard: a subscriber may synchronously unsubscribe others + // or throw; iterating the live Set would skip listeners or abort early. + for (const l of Array.from(this.listeners)) { + try { + l(this.state); + } catch { + /* one listener throwing must not stop the rest */ + } + } + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/types.ts b/frontend/editor/src/core/tools/pdfTextEditor/types.ts new file mode 100644 index 0000000000..6a1d6f304e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/types.ts @@ -0,0 +1,146 @@ +/** Shared types for the PDF text editor. */ + +import type { DisplayTransformData } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import type { AnnotationBox } from "@app/tools/pdfTextEditor/model/AnnotationBox"; + +export interface RGBA { + r: number; // 0..255 + g: number; + b: number; + a: number; +} + +export interface PageRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface Affine { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; +} + +export type FontStyle = "normal" | "italic"; +export type FontWeight = "normal" | "bold"; + +// How the reader clusters source text objects into editable runs. - "auto": run +// `LineGrouper` then `ParagraphGrouper`. +export type GroupingMode = "auto" | "line"; + +// How an editable text box grows when its content exceeds the source width: +// "grow" widens to the right. +export type WidthMode = "grow" | "wrap"; + +export interface FontDescriptor { + /** Stable id used internally for ref equality */ + id: string; + family: string; + style: FontStyle; + weight: FontWeight; + /** Whether the font is fully embedded in our bundle */ + bundled: boolean; +} + +export interface TextRunSnapshot { + id: string; + pageIndex: number; + bounds: PageRect; + /** Affine that places the run in page coordinates */ + matrix: Affine; + text: string; + fontId: string; + fontSize: number; + fill: RGBA; + /** True if PDFium says the source PDF subsetted this run's font */ + fontSubset: boolean; + /** PDF text render mode (Tr). 0/absent = normal fill; 3 = invisible. */ + renderMode?: number; + /** Outline colour, when the run's render mode strokes its glyphs. */ + stroke?: RGBA; + /** Outline width in PDF points; 0/absent = hairline or unstroked. */ + strokeWidth?: number; + /** Engine pen origins/ends per code unit; present only while still current. */ + charStartsX?: number[]; + charEndsX?: number[]; + /** Inferred letter-spacing (Tc footprint) in PDF points; 0/absent = none. */ + charSpacingPt?: number; + /** > 0 when this run represents a multi-line paragraph. */ + paragraphLineHeight?: number; + /** Member-line count when paragraph (== 1 implies a single line). */ + paragraphLineCount?: number; + /** Line-slot count; what line alignment actually requires 2 of. */ + paragraphSlotCount?: number; + paragraphBaselines?: number[]; + paragraphLineLefts?: number[]; + // Editor-only metadata: when true the run cannot be selected or edited via + // mouse/keyboard. + locked?: boolean; +} + +export interface ImageObjectSnapshot { + id: string; + pageIndex: number; + bounds: PageRect; + matrix: Affine; + /** Editor-only: see TextRunSnapshot.locked. */ + locked?: boolean; +} + +export interface PageSnapshot { + pageIndex: number; + width: number; + height: number; + /** True when there are uncommitted edits on this page */ + dirty: boolean; + /** Monotonic counter that increments on every commit. */ + revision: number; + runs: TextRunSnapshot[]; + images: ImageObjectSnapshot[]; + // Text-carrying annotations: drawn by the canvas, outside the editable + // object tree. Absent until the page has been read. + annotations?: AnnotationBox[]; + // Raw-PDF -> display (CropBox/rotation) transform for the screen boundary. + display: DisplayTransformData; +} + +export interface SelectionState { + runIds: string[]; + /** Selected image object ids. */ + imageIds: string[]; + /** Caret position when exactly one run is selected and the user is typing */ + caret: number | null; +} + +export interface ToolbarState { + fontFamily: string | null; + fontSize: number | null; + fill: RGBA | null; + bold: boolean; + italic: boolean; + /** + * Whether an italic cut is actually reachable for every selected run - a + * base-14 flip, or an installed face of the run's own family. False disables + * the control instead of silently substituting Helvetica for the real font. + */ + canItalic: boolean; + /** Glyph outline colour across the selection; null when unset or mixed. */ + stroke: RGBA | null; + /** Glyph outline width in points; null when mixed. 0 means no outline. */ + strokeWidth: number | null; + /** Mixed-value indicator for multi-select */ + mixed: { + fontFamily: boolean; + fontSize: boolean; + fill: boolean; + bold: boolean; + italic: boolean; + stroke: boolean; + strokeWidth: boolean; + }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/canvasBackground.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/canvasBackground.ts new file mode 100644 index 0000000000..c589d73432 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/canvasBackground.ts @@ -0,0 +1,83 @@ +/** + * Read the page's own background colour straight from the rendered bitmap. + * + * The editing mask used to pick between near-white and near-black from the + * TEXT colour alone, so a run on a coloured page got a grey band across it. + * It was also translucent, which let the original glyphs ghost through the + * replacement. Sampling the canvas gives the real colour to paint, opaquely. + */ + +/** Strip height either side of the glyph band that is sampled for background. */ +const MARGIN_RATIO = 0.22; +/** Pixels stepped over while sampling; keeps the read cheap on wide runs. */ +const STEP = 3; + +export interface Rgb { + r: number; + g: number; + b: number; +} + +/** `rgb(r, g, b)` - always fully opaque, so nothing underneath shows through. */ +export function toOpaqueCss(c: Rgb): string { + return `rgb(${c.r}, ${c.g}, ${c.b})`; +} + +/** + * Most common colour in the strips directly above and below the run's glyphs. + * Returns null when the canvas cannot be read (tainted, zero-sized, no 2d). + */ +export function sampleRunBackground( + canvas: HTMLCanvasElement, + rectInCanvasPx: { x: number; y: number; width: number; height: number }, +): Rgb | null { + const { x, y, width, height } = rectInCanvasPx; + if (width < 1 || height < 1) return null; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) return null; + + const margin = Math.max(1, Math.round(height * MARGIN_RATIO)); + const bands = [ + { top: Math.round(y), h: margin }, + { top: Math.round(y + height - margin), h: margin }, + ]; + + const buckets = new Map< + string, + { r: number; g: number; b: number; n: number } + >(); + for (const band of bands) { + const top = Math.max(0, Math.min(canvas.height - 1, band.top)); + const h = Math.max(1, Math.min(band.h, canvas.height - top)); + const left = Math.max(0, Math.min(canvas.width - 1, Math.round(x))); + const w = Math.max(1, Math.min(Math.round(width), canvas.width - left)); + let data: Uint8ClampedArray; + try { + data = ctx.getImageData(left, top, w, h).data; + } catch { + return null; + } + for (let i = 0; i < data.length; i += 4 * STEP) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const key = `${r & 0xf8},${g & 0xf8},${b & 0xf8}`; + const hit = buckets.get(key); + if (hit) { + hit.r += r; + hit.g += g; + hit.b += b; + hit.n += 1; + } else buckets.set(key, { r, g, b, n: 1 }); + } + } + + let best: { r: number; g: number; b: number; n: number } | null = null; + for (const v of buckets.values()) if (!best || v.n > best.n) best = v; + if (!best) return null; + return { + r: Math.round(best.r / best.n), + g: Math.round(best.g / best.n), + b: Math.round(best.b / best.n), + }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/deviceFontEmbed.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/deviceFontEmbed.ts new file mode 100644 index 0000000000..5a09a6d35c --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/deviceFontEmbed.ts @@ -0,0 +1,292 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { RGBA } from "@app/tools/pdfTextEditor/types"; +import { FontRef } from "@app/tools/pdfTextEditor/model/FontRef"; +import { parseTrueTypeCmap } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import { writeUtf16 } from "@app/services/pdfiumService"; +import { + getLocalFontBytes, + loadLocalFontBytes, +} from "@app/tools/pdfTextEditor/util/localFonts"; +import { + isBoldFamily, + isItalicFamily, +} from "@app/tools/pdfTextEditor/util/fontFamily"; + +// Embed the fonts installed on the user's device instead of substituting the +// nearest standard face. Reading the file is async, so the emit uses the cache. + +// Composite (CID) TrueType, so SetText can address code points beyond 255. +const FPDF_FONT_TRUETYPE = 2; +const DEVICE_FONT_ID_PREFIX = "__device_font:"; + +/** Owned-font id for a family, stable across emits of the same document. */ +export function deviceFontIdFor(family: string): string { + return `${DEVICE_FONT_ID_PREFIX}${family.trim().toLowerCase()}`; +} + +interface ExtendedPdfiumRuntime { + HEAPU8: Uint8Array; +} + +interface DeviceFontModule { + FPDFText_LoadFont?: ( + doc: number, + data: number, + size: number, + fontType: number, + cid: boolean, + ) => number; + FPDFFont_Close?: (font: number) => void; + FPDFPageObj_CreateTextObj?: ( + doc: number, + font: number, + size: number, + ) => number; + FPDFPageObj_GetBounds?: ( + obj: number, + left: number, + bottom: number, + right: number, + top: number, + ) => boolean; +} + +/** Parsed cmap per family, so coverage is computed once per session. */ +const coverageByFamily = new Map | null>(); +/** Families PDFium already refused for a document; never retried. */ +let refusedByDoc = new WeakMap>(); +/** Successful device-font emits per document, keyed by owned-font id. */ +let emitCountByDoc = new WeakMap>(); + +function refusedFor(doc: EditorDocument): Set { + let set = refusedByDoc.get(doc); + if (!set) { + set = new Set(); + refusedByDoc.set(doc, set); + } + return set; +} + +/** Test hook: drop the per-session coverage and per-document memos. */ +export function resetDeviceFontEmbedCache(): void { + coverageByFamily.clear(); + refusedByDoc = new WeakMap>(); + emitCountByDoc = new WeakMap>(); +} + +// True if the face covers every non-whitespace code point. Fails open, leaving +// the width self-check as the backstop. +function deviceFontCovers( + family: string, + bytes: Uint8Array, + text: string, +): boolean { + const key = deviceFontIdFor(family); + if (!coverageByFamily.has(key)) { + let parsed: Map | null = null; + try { + parsed = parseTrueTypeCmap(bytes); + } catch { + parsed = null; + } + coverageByFamily.set(key, parsed); + } + const coverage = coverageByFamily.get(key) ?? null; + if (!coverage) return true; + for (const ch of text) { + if (/\s/.test(ch)) continue; + const cp = ch.codePointAt(0); + if (cp === undefined || !coverage.has(cp)) return false; + } + return true; +} + +// Read the family's font file so a later synchronous emit can embed it. The UI +// must AWAIT this before dispatching a font-family change. +export async function ensureDeviceFontReady(family: string): Promise { + const bytes = await loadLocalFontBytes(family); + return !!bytes && bytes.length > 0; +} + +/** Whether a synchronous emit can embed this family right now. */ +export function isDeviceFontReady(family: string): boolean { + const bytes = getLocalFontBytes(family); + return !!bytes && bytes.length > 0; +} + +/** Whether `family` is already embedded in `doc`. */ +export function isDeviceFontEmbedded( + doc: EditorDocument, + family: string, +): boolean { + return !!doc.ownedFont(deviceFontIdFor(family)); +} + +/** How many objects this document has emitted in `family`'s embedded face. */ +export function deviceFontEmitCount( + doc: EditorDocument, + family: string, +): number { + return emitCountByDoc.get(doc)?.get(deviceFontIdFor(family)) ?? 0; +} + +function recordEmit(doc: EditorDocument, family: string): void { + let counts = emitCountByDoc.get(doc); + if (!counts) { + counts = new Map(); + emitCountByDoc.set(doc, counts); + } + const key = deviceFontIdFor(family); + counts.set(key, (counts.get(key) ?? 0) + 1); +} + +// Embed `family` into `doc` once and return its font handle, or 0. Freed with +// the document, along with its backing WASM buffer. +export function loadDeviceFontInto( + doc: EditorDocument, + family: string, +): number { + const id = deviceFontIdFor(family); + const existing = doc.ownedFont(id); + if (existing) return existing.pointer; + const refused = refusedFor(doc); + // A refusal is permanent for this document; retrying would re-malloc the + // whole font file on every keystroke. + if (refused.has(id)) return 0; + const bytes = getLocalFontBytes(family); + if (!bytes || bytes.length === 0) return 0; + + const m = doc.module; + const mod = m as unknown as DeviceFontModule; + if (typeof mod.FPDFText_LoadFont !== "function") return 0; + const len = bytes.length; + const ptr = m.pdfium.wasmExports.malloc(len); + if (!ptr) return 0; + try { + (m.pdfium as typeof m.pdfium & ExtendedPdfiumRuntime).HEAPU8.set( + bytes, + ptr, + ); + const fontPtr = mod.FPDFText_LoadFont( + doc.docPtr, + ptr, + len, + FPDF_FONT_TRUETYPE, + true, + ); + if (!fontPtr) { + refused.add(id); + m.pdfium.wasmExports.free(ptr); + return 0; + } + doc.registerOwnedFont( + new FontRef({ + id, + descriptor: { + id, + family, + style: isItalicFamily(family) ? "italic" : "normal", + weight: isBoldFamily(family) ? "bold" : "normal", + bundled: false, + }, + pointer: fontPtr, + owned: true, + // Free BOTH the font handle and its backing buffer on doc dispose. + closeFn: (p) => { + try { + mod.FPDFFont_Close?.(p); + } catch { + /* best-effort */ + } + try { + m.pdfium.wasmExports.free(ptr); + } catch { + /* best-effort */ + } + }, + }), + ); + return fontPtr; + } catch { + refused.add(id); + try { + m.pdfium.wasmExports.free(ptr); + } catch { + /* best-effort */ + } + return 0; + } +} + +/** Right edge (PDF points) of an object's visible bbox, or 0 if unmeasurable. */ +function measureRightEdge(m: EditorDocument["module"], ptr: number): number { + const mod = m as unknown as DeviceFontModule; + if (typeof mod.FPDFPageObj_GetBounds !== "function") return 0; + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!mod.FPDFPageObj_GetBounds(ptr, l, b, r, t)) return 0; + return m.pdfium.getValue(r, "float"); + } catch { + return 0; + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +// Emit one text object in `family`'s embedded face. Returns 0 when the font is +// uncached, refused, lacks the glyphs, or measured ~0-wide - caller substitutes. +export function emitDeviceFontTextObject( + doc: EditorDocument, + page: Page, + family: string, + text: string, + size: number, + fill: RGBA, + x: number, + y: number, +): number { + if (text.length === 0) return 0; + const bytes = getLocalFontBytes(family); + if (!bytes || bytes.length === 0) return 0; + if (!deviceFontCovers(family, bytes, text)) return 0; + const fontPtr = loadDeviceFontInto(doc, family); + if (!fontPtr) return 0; + const m = doc.module; + const create = (m as unknown as DeviceFontModule).FPDFPageObj_CreateTextObj; + if (typeof create !== "function") return 0; + const fp = create(doc.docPtr, fontPtr, size); + if (!fp) return 0; + const tp = writeUtf16(m, text); + try { + m.FPDFText_SetText(fp, tp); + } finally { + m.pdfium.wasmExports.free(tp); + } + m.FPDFPageObj_SetFillColor(fp, fill.r, fill.g, fill.b, fill.a); + m.FPDFPageObj_Transform(fp, 1, 0, 0, 1, x, y); + m.FPDFPage_InsertObject(page.pagePtr, fp); + const right = measureRightEdge(m, fp); + const visible = text.replace(/\s+/g, "").length; + if (visible > 0 && right - x < visible * size * 0.05) { + try { + m.FPDFPage_RemoveObject(page.pagePtr, fp); + } catch { + /* best-effort */ + } + try { + m.FPDFPageObj_Destroy(fp); + } catch { + /* best-effort */ + } + return 0; + } + recordEmit(doc, family); + return fp; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/documentRisks.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/documentRisks.ts new file mode 100644 index 0000000000..0556a8957f --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/documentRisks.ts @@ -0,0 +1,83 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { getDroppedBase14Chars } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +// Only losses that are HIGH-confidence under the save path: signatures (the +// save goes incremental), XFA, encryption, and characters an edit had to drop. +export interface SaveRisks { + signatures: number; + xfaForm: boolean; + encrypted: boolean; + /** Distinct visible chars this session's edits couldn't render and dropped. */ + droppedChars: string[]; +} + +/** Inspect the open document for content a full rewrite would damage. */ +export function detectSaveRisks(doc: EditorDocument): SaveRisks { + const m = doc.module; + let signatures = 0; + let xfaForm = false; + let encrypted = false; + try { + signatures = Math.max(0, m.FPDF_GetSignatureCount(doc.docPtr)); + } catch { + /* API absent in older builds - treat as no signatures */ + } + try { + // FORMTYPE: 0 none, 1 acroform, 2 xfa-full, 3 xfa-foreground. + const formType = m.FPDF_GetFormType(doc.docPtr); + xfaForm = formType === 2 || formType === 3; + } catch { + /* API absent - treat as no XFA */ + } + try { + // Revision -1 means unencrypted; >= 0 means an encryption dict is present. + const rev = m.FPDF_GetSecurityHandlerRevision(doc.docPtr); + encrypted = rev >= 0; + } catch { + /* API absent - treat as unencrypted */ + } + return { + signatures, + xfaForm, + encrypted, + droppedChars: getDroppedBase14Chars(), + }; +} + +export function hasSaveRisks(r: SaveRisks): boolean { + return ( + r.signatures > 0 || r.xfaForm || r.encrypted || r.droppedChars.length > 0 + ); +} + +/** Human-readable bullet lines describing what the save would damage. */ +export function describeSaveRisks(r: SaveRisks): string[] { + const out: string[] = []; + if (r.signatures > 0) { + const subject = + r.signatures === 1 + ? "This document carries a digital signature" + : `This document carries ${r.signatures} digital signatures`; + out.push( + `${subject}. Your changes are appended as a new revision, so the signed version stays ` + + "verifiable, but the document will report as modified since it was signed.", + ); + } + if (r.xfaForm) out.push("Interactive XFA form data may be lost."); + if (r.encrypted) { + out.push( + "This PDF is encrypted; the saved copy will NOT be encrypted (password and access restrictions are removed).", + ); + } + if (r.droppedChars.length > 0) { + const shown = r.droppedChars.slice(0, 12).join(" "); + const more = + r.droppedChars.length > 12 + ? ` (+${r.droppedChars.length - 12} more)` + : ""; + out.push( + `Some characters could not be embedded in any available font and were dropped: ${shown}${more}`, + ); + } + return out; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/dom.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/dom.ts new file mode 100644 index 0000000000..a9e1a85e84 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/dom.ts @@ -0,0 +1,64 @@ +/** True when focus is in a typing surface (contenteditable, input, etc). */ +export function isFocusInContentEditable(): boolean { + const el = document.activeElement as HTMLElement | null; + if (!el) return false; + if (el.isContentEditable) return true; + const tag = el.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} + +// True when focus is in a FORM field (Find/Replace/password inputs) as opposed +// to a run's contenteditable. +export function isFocusInFormField(): boolean { + const el = document.activeElement as HTMLElement | null; + if (!el) return false; + const tag = el.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} + +// Find the page index whose midpoint is closest to the viewport's vertical +// centre. +export function findVisiblePageIndex(): number { + const pages = pageElements(); + if (pages.length === 0) return 0; + const midY = window.innerHeight / 2; + let best = 0; + let bestDist = Number.POSITIVE_INFINITY; + pages.forEach((el, i) => { + const rect = el.getBoundingClientRect(); + const dist = Math.abs(rect.top + rect.height / 2 - midY); + if (dist < bestDist) { + bestDist = dist; + best = i; + } + }); + return best; +} + +// The TRUE page index of the page nearest the viewport centre - unlike {@link +// findVisiblePageIndex}, which returns a DOM-array position. +export function visiblePageNumber(): number { + const pages = pageElements(); + if (pages.length === 0) return 0; + const midY = window.innerHeight / 2; + let best = 0; + let bestDist = Number.POSITIVE_INFINITY; + for (const el of pages) { + const n = Number((el.dataset.testid ?? "").replace("pdf-editor-page-", "")); + if (!Number.isFinite(n)) continue; + const rect = el.getBoundingClientRect(); + const dist = Math.abs(rect.top + rect.height / 2 - midY); + if (dist < bestDist) { + bestDist = dist; + best = n; + } + } + return best; +} + +/** All real page surfaces in DOM order, skipping placeholders/error tiles. */ +export function pageElements(): HTMLElement[] { + return Array.from( + document.querySelectorAll('[data-testid^="pdf-editor-page-"]'), + ).filter((el) => /^pdf-editor-page-\d+$/.test(el.dataset.testid ?? "")); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/embeddedFace.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/embeddedFace.ts new file mode 100644 index 0000000000..09cb4cfcaa --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/embeddedFace.ts @@ -0,0 +1,154 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; + +// The overlay used to collapse every document font to one of three generic CSS +// stacks, so editing text visibly changed its shape. PDFium will hand back the +// face it actually rendered with - embedded, or the one it substituted - and a +// FontFace built from those bytes matches the bitmap underneath exactly. + +const faces = new Map(); +/** Pointers already tried, so a font that cannot load is not retried per run. */ +const attempted = new Set(); +const loaded = new Set(); +const listeners = new Set<() => void>(); +let generation = 0; + +/** CSS family name for a font pointer. Stable whether or not it ever loads. */ +export function embeddedFaceFamily(fontPtr: number): string { + return `pdfface-${fontPtr}`; +} + +export function registerEmbeddedFace( + m: WrappedPdfiumModule, + fontPtr: number, +): void { + if (!fontPtr || attempted.has(fontPtr)) return; + attempted.add(fontPtr); + if (typeof document === "undefined" || typeof FontFace === "undefined") { + return; + } + const bytes = readFontData(m, fontPtr); + if (!bytes || bytes.length === 0) return; + if (faceBytesHeld + bytes.length > MAX_TOTAL_FACE_BYTES) return; + const held = bytes.length; + const bornAt = generation; + faceBytesHeld += held; + + let face: FontFace; + try { + face = new FontFace(embeddedFaceFamily(fontPtr), bytes); + } catch { + faceBytesHeld -= held; + return; + } + faces.set(fontPtr, face); + void face + .load() + .then(() => { + if (bornAt !== generation) return; + document.fonts.add(face); + loaded.add(fontPtr); + notifyFaceLoaded(); + }) + .catch(() => { + faces.delete(fontPtr); + if (bornAt === generation) faceBytesHeld -= held; + }); +} + +export function isEmbeddedFaceReady(fontPtr: number): boolean { + return loaded.has(fontPtr); +} + +export function onEmbeddedFaceLoaded(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function notifyFaceLoaded(): void { + for (const listener of [...listeners]) { + try { + listener(); + } catch { + continue; + } + } +} + +function isLoadableFaceHeader(head: Uint8Array): boolean { + if (head.length < 4) return false; + const tag = String.fromCharCode(head[0], head[1], head[2], head[3]); + if (tag === "OTTO" || tag === "true" || tag === "wOFF" || tag === "wOF2") { + return true; + } + return ( + head[0] === 0x00 && head[1] === 0x01 && head[2] === 0x00 && head[3] === 0x00 + ); +} + +/** Copy a font's face bytes out of the WASM heap. */ +function readFontData( + m: WrappedPdfiumModule, + fontPtr: number, +): Uint8Array | null { + const w = m.pdfium.wasmExports; + const lenPtr = w.malloc(4); + let size = 0; + try { + if (!m.FPDFFont_GetFontData(fontPtr, 0, 0, lenPtr)) return null; + size = m.pdfium.getValue(lenPtr, "i32"); + } catch { + return null; + } finally { + w.free(lenPtr); + } + if (size <= 0 || size > MAX_FACE_BYTES) return null; + + const buf = w.malloc(size); + const out = w.malloc(4); + try { + if (!m.FPDFFont_GetFontData(fontPtr, buf, size, out)) return null; + const heap = new Uint8Array( + (m.pdfium.wasmExports as unknown as { memory: WebAssembly.Memory }).memory + .buffer, + buf, + size, + ); + if (!isLoadableFaceHeader(heap)) return null; + // Copy into a plain ArrayBuffer: the heap view dies with the next + // allocation that grows memory, and FontFace rejects a shared buffer. + const copy = new Uint8Array(new ArrayBuffer(size)); + copy.set(heap); + return copy; + } catch { + return null; + } finally { + w.free(buf); + w.free(out); + } +} + +/** A face larger than this is a corrupt length, not a font. */ +const MAX_FACE_BYTES = 8 * 1024 * 1024; +/** Total face bytes to hold for one document, so a font-heavy file can't balloon. */ +const MAX_TOTAL_FACE_BYTES = 48 * 1024 * 1024; +let faceBytesHeld = 0; + +/** Doc-scoped reset: PDFium reuses font pointers across documents. */ +export function resetEmbeddedFaces(): void { + if (typeof document !== "undefined") { + for (const face of faces.values()) { + try { + document.fonts.delete(face); + } catch { + /* never added */ + } + } + } + faces.clear(); + attempted.clear(); + loaded.clear(); + faceBytesHeld = 0; + generation++; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/exactLayout.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/exactLayout.ts new file mode 100644 index 0000000000..7abc350896 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/exactLayout.ts @@ -0,0 +1,136 @@ +// Turn captured pen positions into word boxes the overlay tiles at the engine's +// own origins, instead of re-flowing the line with a substitute font's advances. + +export interface ExactToken { + text: string; + /** Advance width in PDF points. */ + width: number; + /** True for a run of spaces rather than a word. */ + space: boolean; +} + +export interface ExactLine { + /** Pen X of the line's first character, in PDF points. */ + left: number; + tokens: ExactToken[]; +} + +/** Positions captured from the engine, parallel to a run's text. */ +export interface CharPositions { + /** Pen origin X per code unit; NaN where unknown. */ + starts: number[]; + /** Pen origin X plus advance per code unit; NaN where unknown. */ + ends: number[]; +} + +const SPACE = new Set([" ", "\t"]); + +// Per-line word boxes, or null when the capture cannot place the text; the +// caller then falls back to ordinary flow. +export function buildExactLines( + text: string, + positions: CharPositions, +): ExactLine[] | null { + if (text.length === 0) return null; + if (positions.starts.length !== text.length) return null; + if (positions.ends.length !== text.length) return null; + + const lines: ExactLine[] = []; + let lineStart = 0; + for (let i = 0; i <= text.length; i += 1) { + if (i < text.length && text[i] !== "\n") continue; + const built = buildLine(text, positions, lineStart, i); + // A line without usable positions makes the whole run fall back, rather + // than mixing exact and reflowed lines in one paragraph. + if (!built) return null; + lines.push(built); + lineStart = i + 1; + } + return lines.length > 0 ? lines : null; +} + +function buildLine( + text: string, + positions: CharPositions, + from: number, + to: number, +): ExactLine | null { + // The engine trims a line's trailing spaces, so they carry no position and + // are dropped here too; the caret still sees them in the text. + let end = to; + while (end > from && SPACE.has(text[end - 1])) end -= 1; + if (end === from) + return { left: firstFinite(positions.starts, from, to) ?? 0, tokens: [] }; + + const left = positions.starts[from]; + if (!Number.isFinite(left)) return null; + + const spans: Array<{ from: number; to: number; space: boolean }> = []; + let at = from; + while (at < end) { + const space = SPACE.has(text[at]); + let stop = at; + while (stop < end && SPACE.has(text[stop]) === space) stop += 1; + spans.push({ from: at, to: stop, space }); + at = stop; + } + + const tokens: ExactToken[] = []; + for (let i = 0; i < spans.length; i += 1) { + const span = spans[i]; + const width = span.space + ? (spaceGap(positions, spans, i) ?? + tokenWidth(positions, span.from, span.to)) + : tokenWidth(positions, span.from, span.to); + if (width === null) return null; + tokens.push({ + text: text.slice(span.from, span.to), + width, + space: span.space, + }); + } + if (to > end) + tokens.push({ text: text.slice(end, to), width: 0, space: true }); + return { left, tokens }; +} + +function spaceGap( + positions: CharPositions, + spans: Array<{ from: number; to: number; space: boolean }>, + i: number, +): number | null { + const next = spans[i + 1]; + if (!next) return null; + const after = positions.starts[next.from]; + const prev = spans[i - 1]; + const before = prev + ? positions.ends[prev.to - 1] + : positions.starts[spans[i].from]; + if (!Number.isFinite(before) || !Number.isFinite(after)) return null; + return after >= before ? after - before : null; +} + +// A token spans its first pen origin to the last origin-plus-advance, so boxes +// tile without drift. Both endpoints must be real, never nearest-finite. +function tokenWidth( + positions: CharPositions, + from: number, + to: number, +): number | null { + const start = positions.starts[from]; + const finish = positions.ends[to - 1]; + if (!Number.isFinite(start) || !Number.isFinite(finish)) return null; + const width = finish - start; + return width >= 0 ? width : null; +} + +function firstFinite( + values: number[], + from: number, + to: number, +): number | null { + for (let i = from; i < to; i += 1) { + if (Number.isFinite(values[i])) return values[i]; + } + return null; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/exportPdf.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/exportPdf.ts new file mode 100644 index 0000000000..b5b35c39aa --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/exportPdf.ts @@ -0,0 +1,76 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { preserveShadings } from "@app/tools/pdfTextEditor/pdfdoc/passes/preserveShadings"; +import { PdfiumSave } from "@app/tools/pdfTextEditor/pdfium/PdfiumSave"; + +/** Serialize the editor document to a Blob plus the download filename. */ +export async function exportToBlob( + doc: EditorDocument, + sourceName?: string | null, +): Promise<{ + blob: Blob; + filename: string; +}> { + // Nothing was ever written to a page, so PDFium has nothing to contribute: + // handing back what we opened keeps the file byte-identical. Rewriting it + // changed the bytes of 8 of this suite's 10 fixtures and inflated the small + // ones by up to 35% - for no edit at all. + if (documentIsPristine(doc)) { + return { blob: pdfBlob(doc.openedBytes), filename: exportName(sourceName) }; + } + + // A signed document is appended to rather than rewritten, so the bytes the + // signature covers are still there and still verify for their revision. + const incremental = documentIsSigned(doc); + // Must be read AFTER serialize: serialize is what marks pages regenerated, + // so reading first always yielded an empty list and silently skipped the + // shading repair on the first save after an edit. + let bytes = PdfiumSave.serialize(doc, { incremental }); + const regenerated = doc.regeneratedPages(); + + if (regenerated.length > 0 && doc.openedBytes.length > 0) { + try { + const repaired = await preserveShadings(bytes, doc.openedBytes, { + pages: regenerated, + }); + if (repaired) bytes = repaired; + } catch { + /* the unrepaired save is still a correct save */ + } + } + + return { blob: pdfBlob(bytes), filename: exportName(sourceName) }; +} + +function pdfBlob(bytes: Uint8Array): Blob { + return new Blob([bytes as unknown as ArrayBuffer], { + type: "application/pdf", + }); +} + +// Derive from the opened file's name so downloads don't all collide on +// a generic "edited.pdf". +function exportName(sourceName?: string | null): string { + const base = (sourceName ?? "").replace(/\.pdf$/i, "").trim(); + return base ? `${base}_edited.pdf` : "edited.pdf"; +} + +/** + * True when no page's content stream has been regenerated and none is waiting + * to be. `regenerated` is sticky, so this stays false for every later save in + * a session that has edited once - a second save can never hand back the + * pre-edit bytes and silently revert the first. + */ +function documentIsPristine(doc: EditorDocument): boolean { + if (doc.openedBytes.length === 0) return false; + return doc + .loadedPages() + .every((p) => !p.regenerated && !p.needsGenerateContent); +} + +function documentIsSigned(doc: EditorDocument): boolean { + try { + return doc.module.FPDF_GetSignatureCount(doc.docPtr) > 0; + } catch { + return false; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/externalImageEdit.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/externalImageEdit.ts new file mode 100644 index 0000000000..2ac6d4cd96 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/externalImageEdit.ts @@ -0,0 +1,313 @@ +// Round-trip an image through the user's own editor: save the pixels as a PNG, +// then hand the bytes back every time that file is re-saved. + +export interface ExternalEditPixels { + rgba: Uint8Array | Uint8ClampedArray; + width: number; + height: number; +} + +export interface ExternalEditWatch { + readonly fileName: string; + /** Idempotent - safe to call from an unmount path that may already have run. */ + stop(): void; +} + +export type ExternalEditOutcome = + | { status: "unsupported" } + | { status: "cancelled" } + | { status: "failed"; error: unknown } + | { status: "watching"; watch: ExternalEditWatch }; + +export interface ExternalImageEditOptions { + pixels: ExternalEditPixels; + onChange: (bytes: Uint8Array) => void; + suggestedName?: string; + pollIntervalMs?: number; + onError?: (error: unknown) => void; +} + +interface WritableFile { + write(data: Uint8Array): Promise; + close(): Promise; +} + +interface PickedFile { + lastModified: number; + arrayBuffer(): Promise; +} + +interface PickedFileHandle { + name?: string; + createWritable(): Promise; + getFile(): Promise; +} + +interface SavePickerOptions { + suggestedName?: string; + types?: Array<{ description?: string; accept: Record }>; +} + +interface SavePickerHost { + showSaveFilePicker?: ( + options?: SavePickerOptions, + ) => Promise; +} + +const DEFAULT_POLL_MS = 1000; +const MIN_POLL_MS = 100; + +function savePicker(): SavePickerHost["showSaveFilePicker"] { + return (globalThis as unknown as SavePickerHost).showSaveFilePicker; +} + +/** False on Firefox and Safari, which have no File System Access write path. */ +export function isExternalImageEditSupported(): boolean { + return typeof savePicker() === "function"; +} + +export async function startExternalImageEdit( + options: ExternalImageEditOptions, +): Promise { + const picker = savePicker(); + if (typeof picker !== "function") return { status: "unsupported" }; + const suggestedName = options.suggestedName ?? "image.png"; + + let handle: PickedFileHandle | undefined; + try { + handle = await picker({ + suggestedName, + types: [{ description: "PNG image", accept: { "image/png": [".png"] } }], + }); + } catch (error) { + if (isAbort(error)) return { status: "cancelled" }; + return { status: "failed", error }; + } + if (!handle) return { status: "cancelled" }; + + let seenAt: number; + try { + const png = await encodeRgbaAsPng(options.pixels); + const writable = await handle.createWritable(); + await writable.write(png); + await writable.close(); + seenAt = (await handle.getFile()).lastModified; + } catch (error) { + return { status: "failed", error }; + } + + return { + status: "watching", + watch: watchFile(handle, handle.name ?? suggestedName, seenAt, options), + }; +} + +function watchFile( + handle: PickedFileHandle, + fileName: string, + seenAt: number, + options: ExternalImageEditOptions, +): ExternalEditWatch { + const every = Math.max( + MIN_POLL_MS, + options.pollIntervalMs ?? DEFAULT_POLL_MS, + ); + let lastSeen = seenAt; + let stopped = false; + let reading = false; + let timer: ReturnType | null = null; + + function stop(): void { + if (stopped) return; + stopped = true; + if (timer !== null) clearInterval(timer); + timer = null; + } + + async function poll(): Promise { + // A read slower than the interval must not stack up behind itself. + if (stopped || reading) return; + reading = true; + let bytes: Uint8Array | null = null; + try { + const file = await handle.getFile(); + if (file.lastModified > lastSeen) { + lastSeen = file.lastModified; + bytes = new Uint8Array(await file.arrayBuffer()); + } + } catch (error) { + // A file that has gone away never comes back; stop rather than spin. + stop(); + options.onError?.(error); + return; + } finally { + reading = false; + } + if (bytes && !stopped) options.onChange(bytes); + } + + timer = setInterval(() => { + void poll(); + }, every); + return { fileName, stop }; +} + +function isAbort(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + (error as { name?: string }).name === "AbortError" + ); +} + +const PNG_SIGNATURE = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +/** 8-bit RGBA PNG, no filtering - the file is a scratch pad for an editor. */ +export async function encodeRgbaAsPng( + pixels: ExternalEditPixels, +): Promise { + const { width, height } = pixels; + const rowBytes = width * 4; + const raw = new Uint8Array((rowBytes + 1) * height); + for (let y = 0; y < height; y++) { + raw[y * (rowBytes + 1)] = 0; + raw.set( + pixels.rgba.subarray(y * rowBytes, y * rowBytes + rowBytes), + y * (rowBytes + 1) + 1, + ); + } + const header = new Uint8Array(13); + const view = new DataView(header.buffer); + view.setUint32(0, width); + view.setUint32(4, height); + header[8] = 8; + header[9] = 6; + return concat([ + PNG_SIGNATURE, + pngChunk("IHDR", header), + pngChunk("IDAT", await zlibCompress(raw)), + pngChunk("IEND", new Uint8Array(0)), + ]); +} + +interface ByteTransform { + readable: { + getReader(): { read(): Promise<{ done: boolean; value?: Uint8Array }> }; + }; + writable: { + getWriter(): { + write(chunk: Uint8Array): Promise; + close(): Promise; + }; + }; +} + +interface CompressionHost { + CompressionStream?: new (format: string) => ByteTransform; +} + +async function zlibCompress(raw: Uint8Array): Promise { + const Ctor = (globalThis as unknown as CompressionHost).CompressionStream; + if (typeof Ctor !== "function") return zlibStored(raw); + try { + const stream = new Ctor("deflate"); + const writer = stream.writable.getWriter(); + // Not awaited before the read loop: a chunk larger than the queue would + // otherwise deadlock against a reader that has not started yet. + const written = writer + .write(raw) + .then(() => writer.close()) + .then( + () => true, + () => false, + ); + const reader = stream.readable.getReader(); + const parts: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) parts.push(value); + } + return (await written) ? concat(parts) : zlibStored(raw); + } catch { + return zlibStored(raw); + } +} + +/** Valid zlib stream of uncompressed blocks; the fallback when no CompressionStream. */ +function zlibStored(raw: Uint8Array): Uint8Array { + const blockMax = 0xffff; + const blocks = Math.max(1, Math.ceil(raw.length / blockMax)); + const out = new Uint8Array(2 + blocks * 5 + raw.length + 4); + out[0] = 0x78; + out[1] = 0x01; + let p = 2; + for (let i = 0; i < blocks; i++) { + const start = i * blockMax; + const len = Math.min(blockMax, raw.length - start); + out[p++] = i === blocks - 1 ? 1 : 0; + out[p++] = len & 0xff; + out[p++] = (len >>> 8) & 0xff; + out[p++] = ~len & 0xff; + out[p++] = (~len >>> 8) & 0xff; + out.set(raw.subarray(start, start + len), p); + p += len; + } + const sum = adler32(raw); + out[p++] = (sum >>> 24) & 0xff; + out[p++] = (sum >>> 16) & 0xff; + out[p++] = (sum >>> 8) & 0xff; + out[p] = sum & 0xff; + return out; +} + +function pngChunk(type: string, body: Uint8Array): Uint8Array { + const out = new Uint8Array(body.length + 12); + const view = new DataView(out.buffer); + view.setUint32(0, body.length); + for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i); + out.set(body, 8); + view.setUint32(out.length - 4, crc32(out.subarray(4, out.length - 4))); + return out; +} + +let crcTable: Uint32Array | null = null; + +function crc32(bytes: Uint8Array): number { + if (!crcTable) { + crcTable = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + crcTable[n] = c >>> 0; + } + } + let crc = 0xffffffff; + for (let i = 0; i < bytes.length; i++) { + crc = crcTable[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function adler32(bytes: Uint8Array): number { + let a = 1; + let b = 0; + for (let i = 0; i < bytes.length; i++) { + a = (a + bytes[i]) % 65521; + b = (b + a) % 65521; + } + return ((b << 16) | a) >>> 0; +} + +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let at = 0; + for (const part of parts) { + out.set(part, at); + at += part.length; + } + return out; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/fallbackFont.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/fallbackFont.ts new file mode 100644 index 0000000000..46055622e2 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/fallbackFont.ts @@ -0,0 +1,207 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { RGBA } from "@app/tools/pdfTextEditor/types"; +import { FontRef } from "@app/tools/pdfTextEditor/model/FontRef"; +import { parseTrueTypeCmap } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import { writeUtf16 } from "@app/services/pdfiumService"; +import { BASE_PATH } from "@app/constants/app"; + +/** Client-side Unicode fallback font. */ +// BASE_PATH-prefixed: a bare "/fonts/..." 404s on subpath deployments +// (context-path / RUN_SUBPATH installs), permanently disabling the fallback. +const FALLBACK_FONT_URL = `${BASE_PATH}/fonts/NotoSans-Regular.ttf`; +const FALLBACK_FONT_ID = "__unicode_fallback"; +// FPDF_FONT_TRUETYPE; the trailing `true` makes it a composite (CID) font so +// FPDFText_SetText can address Unicode code points beyond 255. +const FPDF_FONT_TRUETYPE = 2; + +let bytesPromise: Promise | null = null; +let cachedBytes: Uint8Array | null = null; +let fallbackCoverage: Map | null = null; + +// True if the fallback font has a glyph for every non-whitespace code point of +// `text`. +function fallbackFontCovers(text: string): boolean { + if (!fallbackCoverage && cachedBytes) { + fallbackCoverage = parseTrueTypeCmap(cachedBytes); + } + if (!fallbackCoverage) return true; + for (const ch of text) { + if (/\s/.test(ch)) continue; + const cp = ch.codePointAt(0)!; + if (!fallbackCoverage.has(cp)) return false; + } + return true; +} + +interface ExtendedPdfiumRuntime { + HEAPU8: Uint8Array; +} + +/** Fetch the bundled fallback TTF once. Safe to call repeatedly. */ +export function preloadFallbackFontBytes(): Promise { + if (bytesPromise) return bytesPromise; + bytesPromise = (async () => { + try { + const res = await fetch(FALLBACK_FONT_URL); + if (!res.ok) { + // Don't cache the failure: a transient 404/503 would otherwise + // disable the Unicode fallback for the whole session. + bytesPromise = null; + return null; + } + cachedBytes = new Uint8Array(await res.arrayBuffer()); + return cachedBytes; + } catch { + bytesPromise = null; + return null; + } + })(); + return bytesPromise; +} + +/** Test/debug hook: bytes are loaded and a fallback emit is possible. */ +export function isFallbackFontReady(): boolean { + return !!cachedBytes && cachedBytes.length > 0; +} + +// Embed the Unicode fallback font into `doc` (once) and return its FPDF font +// handle, or 0 when the bytes aren't ready or the load failed. +export function loadFallbackFontInto(doc: EditorDocument): number { + const existing = doc.ownedFont(FALLBACK_FONT_ID); + if (existing) return existing.pointer; + // Idempotent - makes sure later edits find the bytes ready even if the + // first non-Latin edit raced the fetch. + void preloadFallbackFontBytes(); + const bytes = cachedBytes; + if (!bytes || bytes.length === 0) return 0; + + const m = doc.module; + const len = bytes.length; + const ptr = m.pdfium.wasmExports.malloc(len); + if (!ptr) return 0; + try { + (m.pdfium as typeof m.pdfium & ExtendedPdfiumRuntime).HEAPU8.set( + bytes, + ptr, + ); + const fontPtr = m.FPDFText_LoadFont( + doc.docPtr, + ptr, + len, + FPDF_FONT_TRUETYPE, + true, + ); + if (!fontPtr) { + m.pdfium.wasmExports.free(ptr); + return 0; + } + doc.registerOwnedFont( + new FontRef({ + id: FALLBACK_FONT_ID, + descriptor: { + id: FALLBACK_FONT_ID, + family: "Noto Sans", + style: "normal", + weight: "normal", + bundled: true, + }, + pointer: fontPtr, + owned: true, + // Free BOTH the font handle and its backing buffer on doc dispose. + closeFn: (p) => { + try { + m.FPDFFont_Close(p); + } catch { + /* best-effort */ + } + try { + m.pdfium.wasmExports.free(ptr); + } catch { + /* best-effort */ + } + }, + }), + ); + return fontPtr; + } catch { + try { + m.pdfium.wasmExports.free(ptr); + } catch { + /* best-effort */ + } + return 0; + } +} + +/** Right edge (PDF points) of an object's visible bbox, or 0 if unmeasurable. */ +function measureRightEdge(m: EditorDocument["module"], ptr: number): number { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(ptr, l, b, r, t)) return 0; + return m.pdfium.getValue(r, "float"); + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +interface CreateTextObjModule { + FPDFPageObj_CreateTextObj?: ( + doc: number, + font: number, + size: number, + ) => number; +} + +// Emit ONE text object for `text` in the embedded Unicode fallback font, placed +// at (x, y) with `fill`, inserted into the page. +export function emitFallbackTextObject( + doc: EditorDocument, + page: Page, + text: string, + size: number, + fill: RGBA, + x: number, + y: number, +): number { + const fb = loadFallbackFontInto(doc); + if (!fb) return 0; + if (!fallbackFontCovers(text)) return 0; + const m = doc.module; + const create = (m as unknown as CreateTextObjModule) + .FPDFPageObj_CreateTextObj; + if (typeof create !== "function") return 0; + const fp = create(doc.docPtr, fb, size); + if (!fp) return 0; + const tp = writeUtf16(m, text); + try { + m.FPDFText_SetText(fp, tp); + } finally { + m.pdfium.wasmExports.free(tp); + } + m.FPDFPageObj_SetFillColor(fp, fill.r, fill.g, fill.b, fill.a); + m.FPDFPageObj_Transform(fp, 1, 0, 0, 1, x, y); + m.FPDFPage_InsertObject(page.pagePtr, fp); + const right = measureRightEdge(m, fp); + const visible = text.replace(/\s+/g, "").length; + if (visible > 0 && right - x < visible * size * 0.05) { + try { + m.FPDFPage_RemoveObject(page.pagePtr, fp); + } catch { + /* best-effort */ + } + try { + m.FPDFPageObj_Destroy(fp); + } catch { + /* best-effort */ + } + return 0; + } + return fp; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/fitText.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/fitText.ts new file mode 100644 index 0000000000..d5dff72d38 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/fitText.ts @@ -0,0 +1,60 @@ +/** + * Fit browser-laid-out text to the width the PDF actually advances. + * + * A PDF's /Widths array overrides the face's own advances, so even with the + * identical font embedded the browser lays the same string out at a different + * width - measured at 11-15% out on real files. Whenever the overlay paints + * visible glyphs over the page bitmap, that difference is the misalignment + * the user sees. + */ + +export interface TextFit { + /** Px to add to letter-spacing; negative tightens. */ + letterSpacing: number; + /** Horizontal scale, 1 when tracking alone closed the gap. */ + scaleX: number; +} + +export const NO_FIT: TextFit = { letterSpacing: 0, scaleX: 1 }; + +// Beyond this per-gap adjustment tracking stops reading as tracking and starts +// looking like a different font, so hand over to a scale instead. +const MAX_TRACK_EM = 0.12; +// A ratio outside this band means the inputs disagree about what is being +// measured (wrong line, stale bounds); leave the text alone rather than +// squash it into nonsense. +const MIN_SCALE = 0.5; +const MAX_SCALE = 2; + +/** + * Prefer tracking over scaling: condensing glyphs changes their stroke weight, + * so a scaled word reads bolder than its neighbours, while tight tracking is + * close to invisible. + */ +export function fitTextToWidth( + text: string, + measuredPx: number, + targetPx: number, + fontSizePx: number, +): TextFit { + if (!text || !Number.isFinite(measuredPx) || !Number.isFinite(targetPx)) { + return NO_FIT; + } + if (measuredPx <= 0 || targetPx <= 0 || fontSizePx <= 0) return NO_FIT; + + const overflow = measuredPx - targetPx; + // Sub-pixel differences are not worth a style that forces a re-layout. + if (Math.abs(overflow) <= 0.5) return NO_FIT; + + // Count code points: letter-spacing applies per character, and a surrogate + // pair is one character to the layout engine. + const count = [...text].length; + const perGap = overflow / count; + if (count > 1 && Math.abs(perGap) <= MAX_TRACK_EM * fontSizePx) { + return { letterSpacing: -perGap, scaleX: 1 }; + } + + const scale = targetPx / measuredPx; + if (scale < MIN_SCALE || scale > MAX_SCALE) return NO_FIT; + return { letterSpacing: 0, scaleX: scale }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/fontCapability.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/fontCapability.ts new file mode 100644 index 0000000000..9566a823d0 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/fontCapability.ts @@ -0,0 +1,152 @@ +import { + familyOf, + flipItalic, + isItalicFamily, +} from "@app/tools/pdfTextEditor/util/fontFamily"; +import { helveticaVariantFor } from "@app/tools/pdfTextEditor/util/helveticaVariant"; +import { + faceStyleFlags, + getLocalFontBytes, + loadLocalFontBytes, + loadedLocalFonts, + pickLocalFontFace, + splitRequested, + type LocalFont, +} from "@app/tools/pdfTextEditor/util/localFonts"; + +// Whether a style change is actually possible for a run's face, and in which +// family. The toolbar asks before offering the control: replacing a document's +// own typeface with Helvetica-Oblique is not "making it italic", it is losing +// the font. + +export type StyleSource = "base14" | "device"; + +export interface StyleCapability { + /** Family to emit, or null when nothing available can render the style. */ + family: string | null; + source: StyleSource | null; +} + +const NONE: StyleCapability = { family: null, source: null }; + +/** Families {@link warmDocumentDeviceFonts} has already looked up this session. */ +const attempted = new Set(); + +/** Test hook: forget which document families have been matched. */ +export function resetDocumentFontMatchCache(): void { + attempted.clear(); +} + +/** The installed face for `family` in the requested style, or null. */ +function deviceFaceFor( + fonts: LocalFont[], + family: string, + italic: boolean, +): string | null { + const req = splitRequested(family); + if (!req.family) return null; + const wanted = [req.family, req.bold ? "Bold" : "", italic ? "Italic" : ""] + .filter(Boolean) + .join(" "); + const face = pickLocalFontFace(fonts, wanted); + if (!face) return null; + // pickLocalFontFace always returns SOMETHING from a matching family, so the + // style has to be checked: a family with no italic cut answers with upright. + const flags = faceStyleFlags(face); + if (flags.italic !== italic) return null; + return wanted; +} + +/** + * Which family gives `fontId` its italic cut (or its upright one back). + * + * base-14 flips in place. Anything else - an embedded or subset face - needs a + * device font of the same family that genuinely carries the style, which only + * exists once the user has loaded their device fonts. + */ +export function italicCapability( + fontId: string, + italic: boolean, + fonts: LocalFont[] | null = loadedLocalFonts(), +): StyleCapability { + const family = familyOf(fontId); + if (!family) return NONE; + const flipped = flipItalic(family, italic); + if (flipped) return { family: flipped, source: "base14" }; + if (!fonts || fonts.length === 0) return NONE; + const device = deviceFaceFor(fonts, family, italic); + return device ? { family: device, source: "device" } : NONE; +} + +/** Whether every one of these runs can be flipped to the other italic state. */ +export function canToggleItalic( + fontIds: string[], + fonts: LocalFont[] | null = loadedLocalFonts(), +): boolean { + if (fontIds.length === 0) return false; + // Deduped: each miss costs a linear scan of every installed face, and a + // select-all hands this thousands of runs sharing a handful of fonts - on + // every keystroke, because the toolbar state is derived from the snapshot. + return [...new Set(fontIds)].every( + (id) => italicCapability(id, !isItalicFamily(id), fonts).family !== null, + ); +} + +/** + * The family an edited run re-emits in once its own font cannot author the + * glyph the user typed. + * + * A subset-embedded face only carries the characters the original document + * used, so typing a new letter drops out of the reuse path. Mapping the run + * straight to Helvetica there costs the document its typeface for the sake of + * one character; when the real family is installed and loaded, completing the + * subset from the device font keeps it. + */ +export function fallbackFamilyFor(fontId: string): string { + const family = familyOf(fontId); + // Readiness IS the opt-in: bytes only exist for a family the user has loaded + // their device fonts for. + if (family && getLocalFontBytes(family)) return family; + return helveticaVariantFor(fontId); +} + +/** + * The font id a run takes on once it re-emits in `family`. + * + * Tagging a device family `base14:` is what made the NEXT edit forget it - the + * prefix is how {@link fallbackFamilyFor} recognises a face worth keeping. + */ +export function fallbackFontIdFor(family: string): string { + return `${getLocalFontBytes(family) ? "device" : "base14"}:${family}`; +} + +/** + * Read the installed faces matching the DOCUMENT's own families, so a later + * edit that outgrows a subset has real bytes to complete it from. + * + * Only exact family matches are loaded - "Calibri" never warms "Calibri Light" + * - so recognition stays a match, not a guess. Returns the families matched. + */ +export async function warmDocumentDeviceFonts( + fontIds: Iterable, +): Promise { + const fonts = loadedLocalFonts(); + if (!fonts || fonts.length === 0) return []; + const wanted = new Set(); + for (const id of fontIds) { + const family = familyOf(id); + // base-14 renders everywhere already; nothing to complete. + if (!family || flipItalic(family, false)) continue; + // Every edit re-runs this over the whole page model, and scanning a few + // thousand installed faces per keystroke is not free. + if (attempted.has(family)) continue; + attempted.add(family); + if (getLocalFontBytes(family)) continue; + if (pickLocalFontFace(fonts, family)) wanted.add(family); + } + const matched: string[] = []; + for (const family of wanted) { + if (await loadLocalFontBytes(family)) matched.push(family); + } + return matched; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/fontFamily.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/fontFamily.ts new file mode 100644 index 0000000000..4fbaf7a626 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/fontFamily.ts @@ -0,0 +1,98 @@ +// Helpers for inspecting and flipping the bold/italic variants of the PDF +// base-14 font families used by the toolbar. + +export function isBoldFamily(fontId: string): boolean { + return /bold/i.test(fontId); +} + +export function isItalicFamily(fontId: string): boolean { + return /italic|oblique/i.test(fontId); +} + +/** Strip any `prefix:` qualifier that `PdfiumTextReader` adds to font ids. */ +export function familyOf(fontId: string): string { + const idx = fontId.lastIndexOf(":"); + return idx >= 0 ? fontId.slice(idx + 1) : fontId; +} + +type Base14Root = "Helvetica" | "Times" | "Courier"; + +/** Which base-14 family a name belongs to, or null if it isn't base-14. */ +function base14Root(family: string): Base14Root | null { + if (/^Helvetica/i.test(family)) return "Helvetica"; + if (/^Times/i.test(family)) return "Times"; + if (/^Courier/i.test(family)) return "Courier"; + return null; +} + +/** Build the EXACT base-14 PostScript name for a root + bold/italic combo. */ +function base14Name(root: Base14Root, bold: boolean, italic: boolean): string { + if (root === "Times") { + if (bold && italic) return "Times-BoldItalic"; + if (bold) return "Times-Bold"; + if (italic) return "Times-Italic"; + return "Times-Roman"; + } + // Helvetica + Courier share the Oblique spelling. + if (bold && italic) return `${root}-BoldOblique`; + if (bold) return `${root}-Bold`; + if (italic) return `${root}-Oblique`; + return root; +} + +/** The Helvetica variant for a bold/italic combo. */ +export function helveticaWith(bold: boolean, italic: boolean): string { + return base14Name("Helvetica", bold, italic); +} + +// Map a base-14 family to its bold variant (or back), preserving the current +// italic/oblique state. +export function flipBold(currentFamily: string, on: boolean): string | null { + const root = base14Root(currentFamily); + if (!root) return null; + return base14Name(root, on, isItalicFamily(currentFamily)); +} + +// Map a base-14 family to its italic/oblique variant (or back), preserving the +// current bold state. +export function flipItalic(currentFamily: string, on: boolean): string | null { + const root = base14Root(currentFamily); + if (!root) return null; + return base14Name(root, isBoldFamily(currentFamily), on); +} + +/** Exact names PDFium will build a text object for. */ +const STANDARD_FONTS = new Set([ + "Helvetica", + "Helvetica-Bold", + "Helvetica-Oblique", + "Helvetica-BoldOblique", + "Times-Roman", + "Times-Bold", + "Times-Italic", + "Times-BoldItalic", + "Courier", + "Courier-Bold", + "Courier-Oblique", + "Courier-BoldOblique", + "Symbol", + "ZapfDingbats", +]); + +// The standard PDF font that best stands in for an arbitrary family: PDFium +// can only build a text object for one of the 14, so approximate, don't drop. +export function nearestStandardFont(family: string): string { + if (STANDARD_FONTS.has(family)) return family; + const name = family.toLowerCase(); + const bold = /bold|black|heavy|semibold|demi/.test(name); + const italic = /italic|oblique/.test(name); + if (/mono|courier|consol|menlo|code/.test(name)) { + return base14Name("Courier", bold, italic); + } + if ( + /serif|times|georgia|garamond|book|roman|minion|cambria|palatino/.test(name) + ) { + return base14Name("Times", bold, italic); + } + return base14Name("Helvetica", bold, italic); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/guides.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/guides.ts new file mode 100644 index 0000000000..42f33a5bc7 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/guides.ts @@ -0,0 +1,283 @@ +// Ruler ticks and per-page guides. Pure and DOM-free so the geometry is +// testable; positions are RAW PDF points, so they survive crop and rotation. + +export type GuideAxis = "x" | "y"; + +/** A guide before it has been assigned an id by the store. */ +export interface GuideSeed { + axis: GuideAxis; + /** Raw PDF page-space coordinate (points) the guide holds constant. */ + position: number; +} + +export interface Guide extends GuideSeed { + id: string; +} + +export interface GuideSnap { + value: number; + guide: Guide | null; +} + +/** Orientation of a guide once drawn on the rendered (rotated) page. */ +export type GuideOrientation = "vertical" | "horizontal"; + +export interface GuideLine { + orientation: GuideOrientation; + /** Display-PDF coordinate (points, y-up, origin at the page's lower-left). */ + position: number; +} + +/** Structural slice of `DisplayTransform`, so this module stays model-free. */ +export interface GuideTransform { + apply(x: number, y: number): { x: number; y: number }; + invert(x: number, y: number): { x: number; y: number }; +} + +/** Smallest on-screen gap between neighbouring ticks, in CSS pixels. */ +export const MIN_TICK_SPACING_PX = 6; +/** Smallest on-screen gap between labelled (major) ticks, in CSS pixels. */ +export const MIN_LABEL_SPACING_PX = 48; + +const AXIS_EPSILON = 1e-6; +const MULTIPLE_EPSILON = 1e-6; +/** Upper bound on ticks per ruler; a huge page at huge zoom widens the step. */ +const MAX_TICKS = 4000; +const STEP_LADDER = buildStepLadder(); + +export interface RulerTick { + /** Offset along the ruler from the page origin, in PDF points. */ + position: number; + major: boolean; + /** Set only on major ticks. */ + label: string | null; +} + +export interface RulerScale { + minorStep: number; + majorStep: number; + ticks: RulerTick[]; +} + +// Ruler ticks at `scale` CSS px per point. The interval climbs a 1/2/5 ladder +// so ticks and labels never crowd below their minimum spacing. +export function rulerTicks(lengthInPoints: number, scale: number): RulerScale { + if ( + !Number.isFinite(lengthInPoints) || + !Number.isFinite(scale) || + lengthInPoints <= 0 || + scale <= 0 + ) { + return { minorStep: 0, majorStep: 0, ticks: [] }; + } + // Floor the step by the tick budget too, so an extreme zoom widens the + // interval instead of truncating the ruler part-way down the page. + const budget = lengthInPoints / MAX_TICKS; + const minorStep = pickStep(scale, MIN_TICK_SPACING_PX, 0, budget); + const majorStep = pickStep(scale, MIN_LABEL_SPACING_PX, minorStep, budget); + const decimals = labelDecimals(majorStep); + const last = Math.floor(lengthInPoints / minorStep + MULTIPLE_EPSILON); + const ticks: RulerTick[] = []; + for (let i = 0; i <= last; i += 1) { + const position = roundStep(i * minorStep); + const major = isMultipleOf(position, majorStep); + ticks.push({ + position, + major, + label: major ? formatTickLabel(position, decimals) : null, + }); + } + return { minorStep, majorStep, ticks }; +} + +// Snap to the nearest guide within tolerance; ties take the lower id so a drag +// hovering exactly between two guides never flickers. +export function snapToGuides( + value: number, + guides: readonly Guide[], + toleranceInPoints: number, +): GuideSnap { + if ( + !Number.isFinite(value) || + !Number.isFinite(toleranceInPoints) || + toleranceInPoints < 0 + ) { + return { value, guide: null }; + } + let best: Guide | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const guide of guides) { + if (!Number.isFinite(guide.position)) continue; + const distance = Math.abs(guide.position - value); + if (distance > toleranceInPoints) continue; + if ( + distance < bestDistance || + (distance === bestDistance && best !== null && guide.id < best.id) + ) { + best = guide; + bestDistance = distance; + } + } + return best ? { value: best.position, guide: best } : { value, guide: null }; +} + +/** Where a raw-PDF guide lands on the rendered (cropped/rotated) page. */ +export function guideToLine( + guide: GuideSeed, + transform: GuideTransform, +): GuideLine { + const a = + guide.axis === "x" + ? transform.apply(guide.position, 0) + : transform.apply(0, guide.position); + const b = + guide.axis === "x" + ? transform.apply(guide.position, 1) + : transform.apply(1, guide.position); + // The linear part is a quarter-turn rotation, so exactly one display + // coordinate stays constant along the line; that one names the orientation. + return Math.abs(a.x - b.x) <= AXIS_EPSILON + ? { orientation: "vertical", position: a.x } + : { orientation: "horizontal", position: a.y }; +} + +/** Inverse of `guideToLine`: the raw-PDF guide a drawn line represents. */ +export function lineToGuide( + line: GuideLine, + transform: GuideTransform, +): GuideSeed { + const a = + line.orientation === "vertical" + ? transform.invert(line.position, 0) + : transform.invert(0, line.position); + const b = + line.orientation === "vertical" + ? transform.invert(line.position, 1) + : transform.invert(1, line.position); + return Math.abs(a.x - b.x) <= AXIS_EPSILON + ? { axis: "x", position: a.x } + : { axis: "y", position: a.y }; +} + +const NO_GUIDES: Guide[] = []; + +type GuideListener = (pageIndex: number, guides: Guide[]) => void; + +// Per-page guide state with a subscribe channel, shaped like `Selection`. +// Arrays are replaced, never mutated, so subscribers can compare identities. +export class GuideStore { + private byPage: Map = new Map(); + private listeners: Set = new Set(); + private counter = 0; + + get(pageIndex: number): Guide[] { + return this.byPage.get(pageIndex) ?? NO_GUIDES; + } + + add(pageIndex: number, axis: GuideAxis, position: number): Guide | null { + if (!Number.isFinite(position)) return null; + this.counter += 1; + // Zero-padded so lexicographic id order matches creation order, which is + // what `snapToGuides` leans on for its tie-break. + const id = `guide-${String(this.counter).padStart(6, "0")}`; + const guide: Guide = { id, axis, position }; + this.byPage.set(pageIndex, [...this.get(pageIndex), guide]); + this.notify(pageIndex); + return guide; + } + + move(pageIndex: number, id: string, position: number): void { + if (!Number.isFinite(position)) return; + const current = this.get(pageIndex); + const index = current.findIndex((g) => g.id === id); + if (index < 0 || current[index].position === position) return; + const next = current.slice(); + next[index] = { ...current[index], position }; + this.byPage.set(pageIndex, next); + this.notify(pageIndex); + } + + remove(pageIndex: number, id: string): void { + const current = this.get(pageIndex); + const next = current.filter((g) => g.id !== id); + if (next.length === current.length) return; + this.byPage.set(pageIndex, next); + this.notify(pageIndex); + } + + clear(pageIndex?: number): void { + if (pageIndex === undefined) { + const pages = Array.from(this.byPage.keys()); + this.byPage.clear(); + for (const page of pages) this.notify(page); + return; + } + if (this.get(pageIndex).length === 0) return; + this.byPage.delete(pageIndex); + this.notify(pageIndex); + } + + subscribe(listener: GuideListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(pageIndex: number): void { + // Snapshot + guard: a subscriber may synchronously unsubscribe others + // or throw; iterating the live Set would skip listeners or abort early. + for (const listener of Array.from(this.listeners)) { + try { + listener(pageIndex, this.get(pageIndex)); + } catch { + /* one listener throwing must not stop the rest */ + } + } + } +} + +/** Shared guide state for the open document; `clear()` on document swap. */ +export const pageGuides = new GuideStore(); + +function buildStepLadder(): number[] { + const steps: number[] = []; + for (let exponent = -3; exponent <= 6; exponent += 1) { + for (const mantissa of [1, 2, 5]) { + steps.push(roundStep(mantissa * Math.pow(10, exponent))); + } + } + return steps; +} + +function pickStep( + scale: number, + minPx: number, + multipleOf: number, + minStep: number, +): number { + for (const step of STEP_LADDER) { + if (step < minStep || step * scale < minPx) continue; + if (multipleOf > 0 && !isMultipleOf(step, multipleOf)) continue; + return step; + } + return STEP_LADDER[STEP_LADDER.length - 1]; +} + +function isMultipleOf(value: number, step: number): boolean { + if (step <= 0) return false; + const ratio = value / step; + return Math.abs(ratio - Math.round(ratio)) < MULTIPLE_EPSILON; +} + +/** Trim the float noise from `mantissa * 10^e` so ticks compare exactly. */ +function roundStep(value: number): number { + return Number(value.toPrecision(12)); +} + +function labelDecimals(step: number): number { + if (step <= 0) return 0; + return Math.max(0, Math.min(6, Math.ceil(-Math.log10(step)))); +} + +function formatTickLabel(value: number, decimals: number): string { + return decimals > 0 ? value.toFixed(decimals) : String(Math.round(value)); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/helveticaVariant.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/helveticaVariant.ts new file mode 100644 index 0000000000..b158e714c1 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/helveticaVariant.ts @@ -0,0 +1,36 @@ +const DEVICE_FONT_PREFIX = "device:"; + +// Map a source font id to the base-14 family + style that best preserves its +// broad class. +export function helveticaVariantFor(fontId: string): string { + // A run already carrying an embedded device font keeps it; mapping to + // base-14 here is what reverted "Segoe UI" to Helvetica on the next edit. + if (fontId.startsWith(DEVICE_FONT_PREFIX)) { + return fontId.slice(DEVICE_FONT_PREFIX.length); + } + const bold = /bold|black|heavy/i.test(fontId); + const italic = /italic|oblique/i.test(fontId); + const mono = /mono|courier|consol/i.test(fontId); + // "roman"/"cmr"/"lmroman" cover LaTeX Computer Modern serif families. + const serif = + !mono && + /times|serif|roman|georgia|garamond|minion|palatino|cambria|book\s?antiqua|(^|[^a-z])(cmr|lmroman|lmr)/i.test( + fontId, + ); + if (mono) { + if (bold && italic) return "Courier-BoldOblique"; + if (bold) return "Courier-Bold"; + if (italic) return "Courier-Oblique"; + return "Courier"; + } + if (serif) { + if (bold && italic) return "Times-BoldItalic"; + if (bold) return "Times-Bold"; + if (italic) return "Times-Italic"; + return "Times-Roman"; + } + if (bold && italic) return "Helvetica-BoldOblique"; + if (bold) return "Helvetica-Bold"; + if (italic) return "Helvetica-Oblique"; + return "Helvetica"; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/imagePicking.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/imagePicking.ts new file mode 100644 index 0000000000..5c2f65b709 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/imagePicking.ts @@ -0,0 +1,83 @@ +// Picking and decoding a replacement image. The toolbar renders in the +// workbench, a different React tree from the panel owning the file inputs. +import type { DecodedImage } from "@app/utils/pdfiumBitmapUtils"; + +export interface PickedImage { + decoded: DecodedImage; + /** Present for JPEGs, so the embed can pass the original bytes through. */ + jpegBytes?: Uint8Array; +} + +export function pickImageFile(): Promise { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.style.display = "none"; + let settled = false; + const done = (file: File | null): void => { + if (settled) return; + settled = true; + input.remove(); + resolve(file); + }; + input.addEventListener("change", () => done(input.files?.[0] ?? null)); + // No cancel event fires in older browsers, so the dialog closing without + // a pick simply leaves the promise pending until the next focus. + input.addEventListener("cancel", () => done(null)); + document.body.appendChild(input); + input.click(); + }); +} + +export async function decodeImageForEmbed(file: File): Promise { + const decoded = await decodeToRgba(file); + if (file.type === "image/jpeg") { + return { + decoded, + jpegBytes: new Uint8Array(await file.arrayBuffer()), + }; + } + return { decoded }; +} + +/** Decode PNG bytes that came back from an external editor. */ +export async function decodeBytesForEmbed( + bytes: Uint8Array, + type = "image/png", +): Promise { + return decodeToRgba(new File([bytes as BlobPart], "external", { type })); +} + +function decodeToRgba(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + try { + const width = img.naturalWidth || img.width; + const height = img.naturalHeight || img.height; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Canvas 2D context unavailable")); + return; + } + ctx.drawImage(img, 0, 0); + const data = ctx.getImageData(0, 0, width, height); + resolve({ rgba: new Uint8Array(data.data.buffer), width, height }); + } catch (e) { + reject(e instanceof Error ? e : new Error(String(e))); + } finally { + URL.revokeObjectURL(url); + } + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Could not decode the selected image.")); + }; + img.src = url; + }); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/imagePixels.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/imagePixels.ts new file mode 100644 index 0000000000..8989e98f18 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/imagePixels.ts @@ -0,0 +1,104 @@ +// Read an image object's pixels back out of PDFium: the round trip must hand +// over the picture as it stands now, not the file it originally came from. +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +export interface ImagePixels { + rgba: Uint8Array; + width: number; + height: number; +} + +interface ImageBitmapModule { + FPDFImageObj_GetBitmap?: (obj: number) => number; + FPDFImageObj_GetRenderedBitmap?: ( + doc: number, + page: number, + obj: number, + ) => number; + FPDFBitmap_GetBuffer?: (bitmap: number) => number; + FPDFBitmap_GetWidth?: (bitmap: number) => number; + FPDFBitmap_GetHeight?: (bitmap: number) => number; + FPDFBitmap_GetStride?: (bitmap: number) => number; + FPDFBitmap_GetFormat?: (bitmap: number) => number; + FPDFBitmap_Destroy?: (bitmap: number) => void; +} + +/** FPDFBitmap_* format ids. */ +const FORMAT_GRAY = 1; +const FORMAT_BGR = 2; +const FORMAT_BGRA = 4; + +export function readImageObjectPixels( + doc: EditorDocument, + pageIndex: number, + objPtr: number, +): ImagePixels | null { + if (!objPtr) return null; + const m = doc.module; + const mod = m as unknown as ImageBitmapModule; + const page = doc.page(pageIndex); + // Any pending edit has to be in the content stream before PDFium will + // rasterise the object as the user currently sees it. + page.flushGenerate(m); + + let bitmap = 0; + try { + bitmap = + mod.FPDFImageObj_GetRenderedBitmap?.(doc.docPtr, page.pagePtr, objPtr) ?? + 0; + if (!bitmap) bitmap = mod.FPDFImageObj_GetBitmap?.(objPtr) ?? 0; + if (!bitmap) return null; + + const width = mod.FPDFBitmap_GetWidth?.(bitmap) ?? 0; + const height = mod.FPDFBitmap_GetHeight?.(bitmap) ?? 0; + const stride = mod.FPDFBitmap_GetStride?.(bitmap) ?? 0; + const buffer = mod.FPDFBitmap_GetBuffer?.(bitmap) ?? 0; + const format = mod.FPDFBitmap_GetFormat?.(bitmap) ?? FORMAT_BGRA; + if (width <= 0 || height <= 0 || stride <= 0 || !buffer) return null; + + const heap = heapView(m); + const bytesPerPixel = + format === FORMAT_GRAY ? 1 : format === FORMAT_BGR ? 3 : 4; + const rgba = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y += 1) { + let src = buffer + y * stride; + let dst = y * width * 4; + for (let x = 0; x < width; x += 1) { + // PDFium hands back gray or BGR(A); the canvas/PNG world wants RGBA. + if (format === FORMAT_GRAY) { + rgba[dst] = heap[src]; + rgba[dst + 1] = heap[src]; + rgba[dst + 2] = heap[src]; + rgba[dst + 3] = 255; + } else { + rgba[dst] = heap[src + 2]; + rgba[dst + 1] = heap[src + 1]; + rgba[dst + 2] = heap[src]; + rgba[dst + 3] = format === FORMAT_BGRA ? heap[src + 3] : 255; + } + src += bytesPerPixel; + dst += 4; + } + } + return { rgba, width, height }; + } catch { + return null; + } finally { + if (bitmap) { + try { + mod.FPDFBitmap_Destroy?.(bitmap); + } catch { + /* best-effort */ + } + } + } +} + +/** Re-acquired per call: growing the WASM memory detaches an older view. */ +function heapView(m: WrappedPdfiumModule): Uint8Array { + const memory = ( + m.pdfium.wasmExports as unknown as { memory: WebAssembly.Memory } + ).memory; + return new Uint8Array(memory.buffer); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/jpegOrientation.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/jpegOrientation.ts new file mode 100644 index 0000000000..aa8007e2de --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/jpegOrientation.ts @@ -0,0 +1,60 @@ +/** Read a JPEG's EXIF orientation (1-8); 1 when absent or unreadable. */ +export function jpegExifOrientation(bytes: Uint8Array): number { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return 1; + let off = 2; + while (off + 4 <= bytes.length) { + if (bytes[off] !== 0xff) return 1; + const marker = bytes[off + 1]; + // SOS/EOI: image data begins - no EXIF ahead. + if (marker === 0xda || marker === 0xd9) return 1; + const size = (bytes[off + 2] << 8) | bytes[off + 3]; + if (size < 2) return 1; + if (marker === 0xe1 && size >= 10) { + const seg = off + 4; + const isExif = + bytes[seg] === 0x45 && // E + bytes[seg + 1] === 0x78 && // x + bytes[seg + 2] === 0x69 && // i + bytes[seg + 3] === 0x66 && // f + bytes[seg + 4] === 0 && + bytes[seg + 5] === 0; + if (isExif) { + const tiff = seg + 6; + const little = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49; + const big = bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d; + if (!little && !big) return 1; + const u16 = (p: number): number => + little + ? bytes[p] | (bytes[p + 1] << 8) + : (bytes[p] << 8) | bytes[p + 1]; + const u32 = (p: number): number => + little + ? (bytes[p] | + (bytes[p + 1] << 8) | + (bytes[p + 2] << 16) | + (bytes[p + 3] << 24)) >>> + 0 + : ((bytes[p] << 24) | + (bytes[p + 1] << 16) | + (bytes[p + 2] << 8) | + bytes[p + 3]) >>> + 0; + if (tiff + 8 > bytes.length) return 1; + const ifd = tiff + u32(tiff + 4); + if (ifd + 2 > bytes.length) return 1; + const count = u16(ifd); + for (let i = 0; i < count; i++) { + const e = ifd + 2 + i * 12; + if (e + 12 > bytes.length) return 1; + if (u16(e) === 0x0112) { + const v = u16(e + 8); + return v >= 1 && v <= 8 ? v : 1; + } + } + return 1; + } + } + off += 2 + size; + } + return 1; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/lineLayout.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/lineLayout.ts new file mode 100644 index 0000000000..0422146269 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/lineLayout.ts @@ -0,0 +1,60 @@ +export interface TokenFit { + letterSpacingPx: number; + marginRightPx: number; +} + +export const NO_TOKEN_FIT: TokenFit = { + letterSpacingPx: 0, + marginRightPx: 0, +}; + +const MAX_TRACK_EM = 0.25; +const EPSILON_PX = 0.01; + +export function fitTokenAdvance( + charCount: number, + naturalPx: number, + targetPx: number, + fontSizePx: number, +): TokenFit { + if (charCount <= 0) return NO_TOKEN_FIT; + if (!Number.isFinite(naturalPx) || !Number.isFinite(targetPx)) { + return NO_TOKEN_FIT; + } + if (naturalPx < 0 || targetPx < 0) return NO_TOKEN_FIT; + + const delta = targetPx - naturalPx; + if (Math.abs(delta) < EPSILON_PX) return NO_TOKEN_FIT; + + let letterSpacingPx = 0; + if (charCount > 1) { + const cap = MAX_TRACK_EM * Math.max(0, fontSizePx); + const even = delta / (charCount - 1); + letterSpacingPx = Math.max(-cap, Math.min(cap, even)); + } + return { + letterSpacingPx, + marginRightPx: delta - charCount * letterSpacingPx, + }; +} + +export interface LineStack { + topPx: number; + marginTopsPx: number[]; +} + +export function stackLineBoxes( + baselineTopsPx: number[], + lineHeightPx: number, + baselineFromBoxTopPx: number, +): LineStack | null { + if (baselineTopsPx.length === 0) return null; + if (!Number.isFinite(lineHeightPx) || lineHeightPx <= 0) return null; + if (!Number.isFinite(baselineFromBoxTopPx)) return null; + if (!baselineTopsPx.every((v) => Number.isFinite(v))) return null; + + const marginTopsPx = baselineTopsPx.map((top, i) => + i === 0 ? 0 : top - baselineTopsPx[i - 1] - lineHeightPx, + ); + return { topPx: baselineTopsPx[0] - baselineFromBoxTopPx, marginTopsPx }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/localFonts.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/localFonts.ts new file mode 100644 index 0000000000..1906900af2 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/localFonts.ts @@ -0,0 +1,307 @@ +// Local Font Access API wrapper. Chromium-only and permission-gated, so every +// entry point degrades to null instead of throwing. + +export interface LocalFont { + family: string; + fullName: string; + style: string; + postscriptName: string; +} + +export interface LocalFontFamily { + family: string; + styles: string[]; +} + +type QueryLocalFonts = () => Promise; + +function localFontQuery(): QueryLocalFonts | null { + if (typeof window === "undefined") return null; + const w = window as unknown as { queryLocalFonts?: QueryLocalFonts }; + if (typeof w.queryLocalFonts !== "function") return null; + // Bound: Chrome throws "Illegal invocation" when the method is detached. + return w.queryLocalFonts.bind(w); +} + +/** Feature detection only - never prompts and has no side effects. */ +export function isLocalFontAccessSupported(): boolean { + return localFontQuery() !== null; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function toLocalFont(face: unknown): LocalFont | null { + if (!face || typeof face !== "object") return null; + const data = face as Record; + const family = readString(data.family); + if (!family) return null; + return { + family, + fullName: readString(data.fullName) || family, + style: readString(data.style), + postscriptName: readString(data.postscriptName), + }; +} + +// The raw `FontData` each mapped face came from: only it exposes `.blob()`, +// and `LocalFont` stays a plain data shape. +interface FaceEntry { + font: LocalFont; + source: unknown; +} + +let faceEntries: FaceEntry[] = []; +let resolved: LocalFont[] | null = null; +const listeners = new Set<() => void>(); + +async function queryOnce(): Promise { + const query = localFontQuery(); + if (!query) return null; + try { + const faces = await query(); + if (!Array.isArray(faces)) return null; + const entries: FaceEntry[] = []; + for (const face of faces) { + const font = toLocalFont(face); + if (font) entries.push({ font, source: face }); + } + faceEntries = entries; + resolved = entries.map((entry) => entry.font); + for (const listener of [...listeners]) listener(); + return resolved; + } catch { + // SecurityError, NotAllowedError, a dismissed prompt and anything + // unexpected all mean the same thing to callers: no device fonts. + return null; + } +} + +let pending: Promise | null = null; + +/** The installed faces, or null. Memoised so the prompt fires at most once. */ +export async function listLocalFonts(): Promise { + if (!pending) pending = queryOnce(); + return pending; +} + +/** + * The faces {@link listLocalFonts} has already resolved, or null. + * + * Never prompts and never awaits, so render-time callers (a toolbar deciding + * whether italic is even possible) can read the list without granting + * themselves permission the user has not given. Reference-stable, so it is a + * valid `useSyncExternalStore` snapshot. + */ +export function loadedLocalFonts(): LocalFont[] | null { + return resolved; +} + +/** Fires once the device fonts resolve, so derived UI state can recompute. */ +export function subscribeLocalFonts(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Drops the memoised result. Exists for tests. */ +export function resetLocalFontsCache(): void { + pending = null; + faceEntries = []; + resolved = null; + bytesByFamily.clear(); + bytesPending.clear(); +} + +function compareNames(a: string, b: string): number { + return a.localeCompare(b, undefined, { sensitivity: "base" }); +} + +/** Collapse the face list into families with styles, sorted and de-duplicated. */ +export function groupByFamily(fonts: LocalFont[]): LocalFontFamily[] { + const byFamily = new Map(); + for (const font of fonts) { + if (!font.family) continue; + const key = font.family.toLowerCase(); + let entry = byFamily.get(key); + if (!entry) { + entry = { family: font.family, styles: [] }; + byFamily.set(key, entry); + } + const style = font.style; + if (!style) continue; + const seen = entry.styles.some( + (s) => s.toLowerCase() === style.toLowerCase(), + ); + if (!seen) entry.styles.push(style); + } + const families = [...byFamily.values()]; + for (const entry of families) entry.styles.sort(compareNames); + families.sort((a, b) => compareNames(a.family, b.family)); + return families; +} + +/** Case/separator-insensitive key, so "Segoe-UI" and "Segoe UI" are one name. */ +function normaliseName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, " "); +} + +export interface RequestedFace { + family: string; + bold: boolean; + italic: boolean; +} + +// Split a picker value into family plus style axes, so "Segoe UI Bold" finds +// "Segoe UI". A bare name yields the upright regular cut. +export function splitRequested(requested: string): RequestedFace { + const spaced = requested.trim().replace(/[_-]+/g, " "); + const bold = /\bbold\b/i.test(spaced); + const italic = /\b(italic|oblique)\b/i.test(spaced); + const family = spaced + .replace(/\b(bold|italic|oblique|regular|book|normal|roman)\b/gi, " ") + .replace(/\s+/g, " ") + .trim(); + return { family: family || spaced, bold, italic }; +} + +// The style words of a face. The family is excluded on purpose so a face of +// the family "Arial Black" is not read as a bold cut. +function faceStyleText(font: LocalFont): string { + if (font.style) return font.style.toLowerCase(); + const dash = font.postscriptName.indexOf("-"); + return dash >= 0 ? font.postscriptName.slice(dash + 1).toLowerCase() : ""; +} + +/** + * The style axes an installed face actually carries. + * + * Callers use it to tell "this family really has an italic cut" from + * "pickLocalFontFace returned the upright cut because there was nothing else". + */ +export function faceStyleFlags(font: LocalFont): { + bold: boolean; + italic: boolean; +} { + const style = faceStyleText(font); + return { + bold: /bold|black|heavy|semib|demi/.test(style), + // A family NAMED "Foo Italic" carries the axis even if its style says + // "Regular", which is how several shipped fonts describe themselves. + italic: /italic|oblique/.test(`${style} ${font.family.toLowerCase()}`), + }; +} + +function scoreFace( + font: LocalFont, + wantBold: boolean, + wantItalic: boolean, +): number { + const style = faceStyleText(font); + const bold = /bold|black|heavy|semib|demi/.test(style); + const italic = /italic|oblique/.test(style); + let score = 0; + if (bold === wantBold) score += 4; + if (italic === wantItalic) score += 4; + if (/^(regular|book|normal|roman)?$/.test(style)) score += 2; + // Tie-break towards the plainer cut: "Light Condensed" also matches an + // upright non-bold request, but "Regular" is what the user meant. + return score - Math.min(style.length, 32) / 100; +} + +// The installed face best answering a family name, or null. An exact family +// hit wins, so "Arial Black" is not read as a bold cut of "Arial". +export function pickLocalFontFace( + fonts: LocalFont[], + requested: string, +): LocalFont | null { + const wanted = splitRequested(requested); + const exact = fonts.filter( + (font) => normaliseName(font.family) === normaliseName(requested), + ); + const group = + exact.length > 0 + ? exact + : fonts.filter( + (font) => normaliseName(font.family) === normaliseName(wanted.family), + ); + if (group.length === 0) return null; + const wantBold = exact.length > 0 ? false : wanted.bold; + const wantItalic = exact.length > 0 ? false : wanted.italic; + let best: LocalFont | null = null; + let bestScore = Number.NEGATIVE_INFINITY; + for (const font of group) { + const score = scoreFace(font, wantBold, wantItalic); + if (score > bestScore) { + best = font; + bestScore = score; + } + } + return best; +} + +interface BlobSource { + blob?: () => Promise; +} + +interface BlobBytes { + arrayBuffer?: () => Promise; +} + +async function readFaceBytes(source: unknown): Promise { + if (!source || typeof source !== "object") return null; + const read = (source as BlobSource).blob; + if (typeof read !== "function") return null; + try { + const blob = await read.call(source); + if (!blob || typeof blob !== "object") return null; + const toBuffer = (blob as BlobBytes).arrayBuffer; + if (typeof toBuffer !== "function") return null; + const bytes = new Uint8Array(await toBuffer.call(blob)); + return bytes.length > 0 ? bytes : null; + } catch { + return null; + } +} + +const bytesByFamily = new Map(); +const bytesPending = new Map>(); + +/** Already-read bytes for a family, or null. Never prompts, never awaits. */ +export function getLocalFontBytes(family: string): Uint8Array | null { + return bytesByFamily.get(normaliseName(family)) ?? null; +} + +// The font file bytes behind a family name, cached for the session. Null when +// unsupported, denied, unmatched, or unreadable - never throws. +export async function loadLocalFontBytes( + family: string, +): Promise { + const key = normaliseName(family); + if (!key) return null; + const cached = bytesByFamily.get(key); + if (cached) return cached; + const inFlight = bytesPending.get(key); + if (inFlight) return inFlight; + const job = (async (): Promise => { + const fonts = await listLocalFonts(); + if (!fonts) return null; + const picked = pickLocalFontFace(fonts, family); + if (!picked) return null; + const entry = faceEntries.find((candidate) => candidate.font === picked); + const bytes = entry ? await readFaceBytes(entry.source) : null; + if (bytes) bytesByFamily.set(key, bytes); + return bytes; + })(); + bytesPending.set(key, job); + try { + return await job; + } finally { + // Only successes are cached: a transient blob failure must not disable + // this family for the rest of the session. + bytesPending.delete(key); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/objectTransform.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/objectTransform.ts new file mode 100644 index 0000000000..7cdf476a0d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/objectTransform.ts @@ -0,0 +1,64 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import type { Affine } from "@app/tools/pdfTextEditor/types"; +import { + composeAffine, + invertAffine, +} from "@app/tools/pdfTextEditor/model/affine"; + +interface ClipPathModule { + FPDFPageObj_TransformClipPath?: ( + obj: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => void; +} + +/** Transform an object's clip path by the same matrix. No-op when unclipped. */ +function transformClip(m: WrappedPdfiumModule, ptr: number, t: Affine): void { + try { + (m as unknown as ClipPathModule).FPDFPageObj_TransformClipPath?.( + ptr, + t.a, + t.b, + t.c, + t.d, + t.e, + t.f, + ); + } catch { + /* best-effort */ + } +} + +// Move an object AND its clip path. Transforming the object alone leaves the +// clip behind, so moved clipped content gets sliced by a stale rectangle. +export function transformObject( + m: WrappedPdfiumModule, + ptr: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, +): void { + if (!ptr) return; + m.FPDFPageObj_Transform(ptr, a, b, c, d, e, f); + transformClip(m, ptr, { a, b, c, d, e, f }); +} + +// Follow an ABSOLUTE matrix change with the clip. The page-space delta between +// two object matrices is `next · prev⁻¹`. +export function retargetClipPath( + m: WrappedPdfiumModule, + ptr: number, + prev: Affine, + next: Affine, +): void { + if (!ptr) return; + transformClip(m, ptr, composeAffine(next, invertAffine(prev))); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/overlayPainter.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/overlayPainter.ts new file mode 100644 index 0000000000..c931332802 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/overlayPainter.ts @@ -0,0 +1,391 @@ +import { + fitTokenAdvance, + type TokenFit, +} from "@app/tools/pdfTextEditor/util/lineLayout"; +import { measureAdvancePx } from "@app/tools/pdfTextEditor/util/textMetrics"; + +export interface PaintToken { + text: string; + advancePx: number; +} + +export interface PaintLine { + tokens: PaintToken[]; + heightPx: number; + marginTopPx: number; + marginLeftPx: number; +} + +export interface PaintOptions { + font: string; + fontSizePx: number; + /** + * PDF advance per em for characters the run already contains, keyed by + * character. The only measurement of the document's own face available while + * the user is typing, so it is what newly typed glyphs are sized against. + */ + advanceEm?: Map | null; +} + +const LINE_ATTR = "data-pdf-editor-line"; +const TOKEN_ATTR = "data-pdf-editor-token"; + +export function paintLines( + el: HTMLElement, + lines: PaintLine[], + opts: PaintOptions, +): void { + const fragment = document.createDocumentFragment(); + lines.forEach((line, index) => { + const block = document.createElement("div"); + block.setAttribute(LINE_ATTR, String(index)); + // A painted block IS a line of the PDF: one text object, one pen origin, + // and the page cannot wrap it. So the block must not wrap or grow either. + // Letting it inherit `pre-wrap` from the container put a long line on two + // rows here and one row on the page, pushing every block below it a full + // line-height down - the box then overhung its own text by a row and the + // rendered text appeared to stay on the previous line. + block.style.height = `${line.heightPx}px`; + block.style.lineHeight = `${line.heightPx}px`; + block.style.marginTop = `${line.marginTopPx}px`; + block.style.marginLeft = `${line.marginLeftPx}px`; + block.style.whiteSpace = "pre"; + + if (line.tokens.length === 0) { + block.appendChild(document.createElement("br")); + } + for (const token of line.tokens) { + block.appendChild(tokenSpan(token, opts)); + } + fragment.appendChild(block); + }); + el.replaceChildren(fragment); +} + +function tokenSpan(token: PaintToken, opts: PaintOptions): HTMLSpanElement { + const span = document.createElement("span"); + span.setAttribute(TOKEN_ATTR, ""); + span.textContent = token.text; + span.dataset.adv = String(token.advancePx); + span.dataset.src = token.text; + applyFit(span, token, opts); + return span; +} + +function applyFit( + span: HTMLSpanElement, + token: PaintToken, + opts: PaintOptions, +): void { + const fit = tokenFitFor(token, opts); + span.style.letterSpacing = + fit.letterSpacingPx !== 0 ? `${fit.letterSpacingPx}px` : ""; + span.style.marginRight = + fit.marginRightPx !== 0 ? `${fit.marginRightPx}px` : ""; +} + +export function refitTokens(el: HTMLElement, opts: PaintOptions): void { + refit(el, opts, false); +} + +/** Re-fit only the tokens the user has typed into - cheap enough per keystroke. */ +export function refitEditedTokens(el: HTMLElement, opts: PaintOptions): void { + refit(el, opts, true); +} + +function refit( + el: HTMLElement, + opts: PaintOptions, + changedOnly: boolean, +): void { + for (const span of el.querySelectorAll(`[${TOKEN_ATTR}]`)) { + const advance = Number(span.dataset.adv); + if (!Number.isFinite(advance)) continue; + const text = span.textContent ?? ""; + const source = span.dataset.src ?? ""; + if (text === source) { + // An estimate the user has since backspaced away is sized for text that + // is no longer there, so replace it even on the per-keystroke pass. + if (!changedOnly || span.dataset.est) { + delete span.dataset.est; + applyFit(span, { text, advancePx: advance }, opts); + } + continue; + } + const target = predictedAdvance(text, source, advance, opts); + if (target === null) continue; + span.dataset.est = "1"; + applyFit(span, { text, advancePx: target }, opts); + } +} + +/** + * Where the PDF will advance the pen for a token the user has typed into. + * + * A token is painted at the width the PDF advances, not the width the browser + * lays the same string out at - the two differ by 10-15% whenever the document + * face isn't the one the browser has, and by a different amount per glyph. The + * engine only re-measures once typing pauses, so until then each character is + * priced from the document's own advances where the run already has that + * character, and from the token's browser-to-PDF ratio where it does not. + * Leaving the pre-edit fit in place instead smears a five-character correction + * across a thirty-character word. + */ +function predictedAdvance( + text: string, + source: string, + sourceAdvancePx: number, + opts: PaintOptions, +): number | null { + if (text === "" || source === "") return null; + const sourceNatural = measureAdvancePx(source, opts.font); + if (!(sourceNatural > 0) || !(sourceAdvancePx > 0)) return null; + const ratio = sourceAdvancePx / sourceNatural; + const table = opts.advanceEm; + if (!table || table.size === 0) { + const natural = measureAdvancePx(text, opts.font); + return natural > 0 ? natural * ratio : null; + } + let total = 0; + for (const ch of text) { + const em = table.get(ch); + total += + em === undefined + ? measureAdvancePx(ch, opts.font) * ratio + : em * opts.fontSizePx; + } + return total > 0 ? total : null; +} + +function tokenFitFor(token: PaintToken, opts: PaintOptions): TokenFit { + const natural = measureAdvancePx(token.text, opts.font); + return fitTokenAdvance( + [...token.text].length, + natural, + token.advancePx, + opts.fontSizePx, + ); +} + +export function paintPlainText(el: HTMLElement, text: string): void { + el.innerText = text; +} + +/** + * Lines held by one painted line block. + * + * A recursive walk that emits one break per
- Firefox puts a manual break + * INSIDE the token span it split, so the walk has to descend. Under the + * blocks' `white-space: pre` this agrees with layout the way innerText does, + * without innerText's forced layout flush (the old reader spent a flush per + * block per keystroke). A block the browser emptied keeps a filler break that + * would otherwise read as a newline of its own; the filler is not always a + * direct
- pressing Enter at the end of a line leaves Chrome an empty + * clone of the token span with the
inside it. An emptied block is one + * empty line however the browser spells it, so key off the absence of text. + */ +function blockLines(element: HTMLElement): string[] { + if ((element.textContent ?? "") === "") return [""]; + const lines: string[] = [""]; + const walk = (node: Node): void => { + if (node.nodeType === Node.TEXT_NODE) { + lines[lines.length - 1] += node.textContent ?? ""; + return; + } + if (node instanceof HTMLElement && node.tagName === "BR") { + lines.push(""); + return; + } + for (const child of Array.from(node.childNodes)) walk(child); + }; + for (const child of Array.from(element.childNodes)) walk(child); + return lines; +} + +/** + * Read an overlay back into the model's plain text. The inverse of paintLines + * and paintPlainText, so it lives beside them: when the two disagree about how + * many lines the DOM holds, the run is re-emitted at the wrong baselines. + */ +export function readOverlayText(element: HTMLElement): string { + const children = Array.from(element.childNodes); + if (children.length === 0) return ""; + // Seeded with the line a leading
would terminate; without it a model + // text starting with a newline lost its blank first line, pulling every line + // below it up one leading. + const lines: string[] = [""]; + let lastWasTrailingBr = false; + let sawBlock = false; + for (const node of children) { + if (node.nodeType === Node.TEXT_NODE) { + lines[lines.length - 1] += node.textContent ?? ""; + lastWasTrailingBr = false; + continue; + } + if (!(node instanceof HTMLElement)) continue; + if (node.tagName === "BR") { + lines.push(""); + lastWasTrailingBr = true; + continue; + } + // Block children carry whole lines, so the seed is not one of them. + if (!sawBlock && lines.length === 1 && lines[0] === "") lines.length = 0; + sawBlock = true; + for (const line of blockLines(node)) lines.push(line); + lastWasTrailingBr = false; + } + // Browsers park a filler
at the end of a contenteditable; innerText + // ignores it and so must we. + if (lastWasTrailingBr) lines.pop(); + return lines.join("\n").replace(/\u00A0/g, " "); +} + +export function isLinePainted(el: HTMLElement): boolean { + return el.querySelector(`[${LINE_ATTR}]`) !== null; +} + +function lineBlocks(el: HTMLElement): HTMLElement[] { + return Array.from(el.children).filter( + (c): c is HTMLElement => + c instanceof HTMLElement && c.hasAttribute(LINE_ATTR), + ); +} + +/** + * Characters of the run's model text that precede the caret. Computed by + * reading a truncated clone through the SAME walk that produces the model + * text, so any DOM the browser improvises mid-edit (a break inside a token + * span, a stray sibling div Firefox wraps typed text in, a caret parked on + * the container) yields an offset consistent with readOverlayText. The old + * block-by-block count returned null for those shapes, the repaint then + * skipped the restore, and the next keystroke landed at the start of the run. + */ +export function plainCaretOffset(el: HTMLElement): number | null { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return null; + const { focusNode, focusOffset } = selection; + if (!focusNode || !el.contains(focusNode)) return null; + const range = document.createRange(); + try { + range.setStart(el, 0); + range.setEnd(focusNode, focusOffset); + } catch { + return null; + } + const host = document.createElement("div"); + host.appendChild(range.cloneContents()); + const chars = readOverlayText(host).length; + // A caret parked on the container BETWEEN two line children sits at the + // start of the next line - one past the end of the truncated text. Past the + // last line child it belongs at that line's end, not on a fresh one. + if (focusNode === el) { + const idx = Math.min(focusOffset, el.childNodes.length); + const children = Array.from(el.childNodes); + const isLineChild = (n: Node) => + n instanceof HTMLElement && n.tagName !== "BR"; + if ( + children.slice(0, idx).some(isLineChild) && + children.slice(idx).some(isLineChild) + ) { + return chars + 1; + } + } + return chars; +} + +/** + * Move a caret parked on the CONTAINER itself into the painted block it sits + * beside. Left there, Firefox applies the next insertText as a bare sibling of + * the line divs (often wrapped in a fresh div), which reads back as an extra + * model line the user never typed. + */ +export function normalizeContainerCaret( + el: HTMLElement, + selection: Selection, +): void { + if (selection.rangeCount === 0) return; + // A CARET only. Firefox anchors a select-all on the container too, and + // collapsing that just before a Delete turns "replace the line" into + // "delete one character". + if (!selection.isCollapsed) return; + const { anchorNode, anchorOffset } = selection; + if (anchorNode !== el) return; + const blocks = lineBlocks(el); + if (blocks.length === 0) return; + // Container offset N sits between child N-1 and child N: land at the end of + // the block before it (or the start of the first block for offset 0). + let target: HTMLElement | null = null; + for ( + let i = Math.min(anchorOffset, el.childNodes.length) - 1; + i >= 0; + i -= 1 + ) { + const child = el.childNodes[i]; + if (child instanceof HTMLElement && child.hasAttribute(LINE_ATTR)) { + target = child; + break; + } + } + if (target) { + let node: Node = target; + while (node.lastChild) node = node.lastChild; + const at = + node.nodeType === Node.TEXT_NODE ? (node.textContent ?? "").length : 0; + setCollapsed(selection, node, at); + return; + } + let first: Node = blocks[0]; + while (first.firstChild) first = first.firstChild; + setCollapsed(selection, first, 0); +} + +export function restoreCaretOffset(el: HTMLElement, offset: number): void { + const selection = window.getSelection(); + if (!selection) return; + const target = Math.max(0, offset); + + const blocks = lineBlocks(el); + let scope: HTMLElement = el; + let remaining = target; + if (blocks.length > 0) { + scope = blocks[blocks.length - 1]; + remaining = (scope.textContent ?? "").length; + let before = 0; + for (const block of blocks) { + const length = (block.textContent ?? "").length; + if (target <= before + length) { + scope = block; + remaining = target - before; + break; + } + before += length + 1; + } + } + + const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); + let seen = 0; + let node = walker.nextNode(); + while (node) { + const length = (node.nodeValue ?? "").length; + if (seen + length >= remaining) { + setCollapsed(selection, node, remaining - seen); + return; + } + seen += length; + node = walker.nextNode(); + } + setCollapsed(selection, scope, 0); +} + +function setCollapsed(selection: Selection, node: Node, offset: number): void { + const range = document.createRange(); + try { + range.setStart(node, offset); + } catch { + range.selectNodeContents(node); + range.collapse(false); + } + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/pageFonts.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/pageFonts.ts new file mode 100644 index 0000000000..e2dc203cfc --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/pageFonts.ts @@ -0,0 +1,157 @@ +import type { PageSnapshot } from "@app/tools/pdfTextEditor/types"; +import { getCachedFontGlyphMap } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; + +// Editability status of a font as the PDF text editor can determine it purely +// client-side (from PDFium), without the backend JSON font model. +export type FontStatus = "standard" | "embedded" | "subset"; + +// Whether the font has real glyphs for the basic alphanumerics (a-z A-Z 0-9). +export interface GlyphCoverage { + known: boolean; + missing: string[]; +} + +export interface PageFont { + /** Stable de-dupe key (display name + status). */ + key: string; + /** Display family name with any subset tag stripped. */ + name: string; + status: FontStatus; + /** 1-based page numbers this font appears on (across loaded pages). */ + pages: number[]; + /** Basic-alphanumeric glyph coverage (from the loader-primed cmap cache). */ + coverage: GlyphCoverage; +} + +/** Code points for a-z, A-Z, 0-9 - the "can I type a letter/number?" probe. */ +const ALNUM_CODEPOINTS: readonly number[] = (() => { + const out: number[] = []; + for (let c = 0x30; c <= 0x39; c++) out.push(c); // 0-9 + for (let c = 0x41; c <= 0x5a; c++) out.push(c); // A-Z + for (let c = 0x61; c <= 0x7a; c++) out.push(c); // a-z + return out; +})(); + +/** Pure: which of a-z A-Z 0-9 are absent from a Unicode→glyphId cmap. */ +export function missingAlnumFromCmap(cmap: Map): string[] { + const out: string[] = []; + for (const cp of ALNUM_CODEPOINTS) + if (!cmap.has(cp)) out.push(String.fromCodePoint(cp)); + return out; +} + +/** Parse the live PDFium font handle out of a `pdf::` fontId. */ +function fontHandleOf(fontId: string): number { + if (!fontId.startsWith("pdf:")) return 0; + const n = Number(fontId.split(":")[1]); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +/** a-zA-Z0-9 coverage for a font, from the loader-primed cache (no WASM). */ +function coverageFor(fontId: string, status: FontStatus): GlyphCoverage { + // Base-14 fonts carry the whole standard set - always full, no cmap needed. + if (status === "standard") return { known: true, missing: [] }; + const handle = fontHandleOf(fontId); + if (!handle) return { known: false, missing: [] }; + const cmap = getCachedFontGlyphMap(handle); + if (!cmap || cmap.size === 0) return { known: false, missing: [] }; + return { known: true, missing: missingAlnumFromCmap(cmap) }; +} + +// Symbol/ZapfDingbats are intentionally excluded: their a-z/A-Z slots are Greek +// letters / dingbats, not Latin alphanumerics. +const STANDARD_14 = [ + "helvetica", + "arial", + "times", + "timesroman", + "timesnewroman", + "courier", + "couriernew", +]; + +// Style suffixes a genuine base-14 family may carry once separators are stripped +// (e.g. "Helvetica-BoldOblique", "ArialMT", "Times-Roman"). +const BASE14_STYLE_SUFFIX = /^(bold|italic|oblique|regular|roman|mt|ps)+$/; + +/** Pull the readable family from a fontId (`pdf::` or `base14:`). */ +function familyOf(fontId: string): string { + if (fontId.startsWith("base14:")) return fontId.slice("base14:".length); + const parts = fontId.split(":"); + return parts.length >= 3 ? parts.slice(2).join(":") : fontId; +} + +/** Subset fonts carry a 6-letter "ABCDEF+" tag; strip it for display. */ +function stripSubsetTag(name: string): string { + return name.replace(/^[A-Z]{6}\+/, ""); +} + +// Weight/width modifiers that mark a DIFFERENT font even when the name starts +// with a base-14 root (e.g. "Arial Black", "Helvetica Neue Condensed"). +const NON_BASE14_MODIFIERS = [ + "black", + "rounded", + "narrow", + "condensed", + "light", + "thin", + "hairline", + "semibold", + "demibold", + "demi", + "medium", + "heavy", + "ultra", + "display", + "neue", +]; + +function isStandard14(fontId: string): boolean { + // Callers pass the full fontId (`pdf::Family`); reduce to the bare + // family first so the `pdf::` prefix can't defeat the prefix match. + const f = stripSubsetTag(familyOf(fontId)) + .toLowerCase() + .replace(/[-_\s]/g, ""); + if (NON_BASE14_MODIFIERS.some((mod) => f.includes(mod))) return false; + // Exact match, or a base-14 root whose remainder is ONLY a recognised style + // suffix (Bold/Italic/Oblique/MT/PS...). + return STANDARD_14.some( + (p) => + f === p || + (f.startsWith(p) && BASE14_STYLE_SUFFIX.test(f.slice(p.length))), + ); +} + +// Group every run across the given (loaded) pages into a de-duplicated list of +// fonts with an editability status. +export function analyzePageFonts(pages: PageSnapshot[]): PageFont[] { + const map = new Map(); + for (const page of pages) { + for (const run of page.runs) { + const name = stripSubsetTag(familyOf(run.fontId)) || "Unknown font"; + let status: FontStatus; + if (run.fontId.startsWith("base14:") || isStandard14(run.fontId)) { + status = "standard"; + } else if (run.fontSubset) { + status = "subset"; + } else { + status = "embedded"; + } + const key = `${name}|${status}`; + const pageNo = page.pageIndex + 1; + const existing = map.get(key); + if (existing) { + if (!existing.pages.includes(pageNo)) existing.pages.push(pageNo); + } else { + map.set(key, { + key, + name, + status, + pages: [pageNo], + coverage: coverageFor(run.fontId, status), + }); + } + } + } + return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/sha256.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/sha256.ts new file mode 100644 index 0000000000..546179989d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/sha256.ts @@ -0,0 +1,89 @@ +/** Synchronous pure-JS SHA-256 (FIPS 180-4), returning lowercase hex. */ + +// First 32 bits of the fractional parts of the cube roots of primes 2..311. +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +/** SHA-256 of `data`, as 64 lowercase hex chars. */ +export function sha256Hex(data: Uint8Array): string { + // Message schedule + working state. + const h = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, + 0x1f83d9ab, 0x5be0cd19, + ]); + const w = new Uint32Array(64); + + // Padded length: message + 0x80 + zeros + 8-byte big-endian bit length, + // rounded up to a 64-byte multiple. + const bitLenLo = (data.length << 3) >>> 0; + const bitLenHi = Math.floor(data.length / 0x20000000); + const paddedLen = ((data.length + 8) >> 6) * 64 + 64; + const padded = new Uint8Array(paddedLen); + padded.set(data); + padded[data.length] = 0x80; + const dv = new DataView(padded.buffer); + dv.setUint32(paddedLen - 8, bitLenHi); + dv.setUint32(paddedLen - 4, bitLenLo); + + for (let off = 0; off < paddedLen; off += 64) { + for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4); + for (let i = 16; i < 64; i++) { + const s0 = + (rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3)) >>> 0; + const s1 = + (rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10)) >>> 0; + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + let a = h[0], + b = h[1], + c = h[2], + d = h[3], + e = h[4], + f = h[5], + g = h[6], + hh = h[7]; + for (let i = 0; i < 64; i++) { + const S1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0; + const ch = ((e & f) ^ (~e & g)) >>> 0; + const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0; + const S0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0; + const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0; + const t2 = (S0 + maj) >>> 0; + hh = g; + g = f; + f = e; + e = (d + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + h[0] = (h[0] + a) >>> 0; + h[1] = (h[1] + b) >>> 0; + h[2] = (h[2] + c) >>> 0; + h[3] = (h[3] + d) >>> 0; + h[4] = (h[4] + e) >>> 0; + h[5] = (h[5] + f) >>> 0; + h[6] = (h[6] + g) >>> 0; + h[7] = (h[7] + hh) >>> 0; + } + + let hex = ""; + for (let i = 0; i < 8; i++) hex += h[i].toString(16).padStart(8, "0"); + return hex; +} + +function rotr(x: number, n: number): number { + return ((x >>> n) | (x << (32 - n))) >>> 0; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/spellcheck.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/spellcheck.ts new file mode 100644 index 0000000000..5d0df643bf --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/spellcheck.ts @@ -0,0 +1,197 @@ +import { useSyncExternalStore } from "react"; + +// The browser's own spell-check engine does the checking; this module only +// owns the preference (on/off + dictionary language) that drives it. + +/** BCP-47 tag, or `SPELLCHECK_AUTO` to follow the document language. */ +export type SpellcheckLang = string; + +export interface SpellcheckPreference { + enabled: boolean; + lang: SpellcheckLang; +} + +export interface SpellcheckLanguage { + /** BCP-47 tag handed to the browser as the `lang` attribute. */ + tag: string; + /** English name; the UI localises it via Intl.DisplayNames when it can. */ + label: string; +} + +export const SPELLCHECK_AUTO = "auto"; + +export const SPELLCHECK_LANGUAGES: readonly SpellcheckLanguage[] = [ + { tag: "en-US", label: "English (United States)" }, + { tag: "en-GB", label: "English (United Kingdom)" }, + { tag: "de", label: "German" }, + { tag: "fr", label: "French" }, + { tag: "es", label: "Spanish" }, + { tag: "it", label: "Italian" }, + { tag: "pt", label: "Portuguese" }, + { tag: "ar", label: "Arabic" }, + { tag: "hi", label: "Hindi" }, +]; + +// Off by default: an unfocused overlay renders its text transparent, so +// stray squiggles would sit over the PDFium bitmap with nothing under them. +export const DEFAULT_SPELLCHECK_PREFERENCE: SpellcheckPreference = + Object.freeze({ + enabled: false, + lang: SPELLCHECK_AUTO, + }); + +const STORAGE_KEY = "stirling.pdfTextEditor.spellcheck"; + +// Deliberately loose: enough to reject junk ("not a tag", "") without +// re-implementing BCP-47, which the browser validates anyway. +const TAG_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/; + +function storage(): Storage | null { + try { + if (typeof window === "undefined") return null; + return window.localStorage ?? null; + } catch { + /* localStorage may be absent or throw on access (blocked cookies) */ + return null; + } +} + +function readStored(): SpellcheckPreference | null { + let raw: string | null = null; + try { + raw = storage()?.getItem(STORAGE_KEY) ?? null; + } catch { + /* quota / privacy modes can throw on read */ + return null; + } + if (!raw) return null; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return null; + const record = parsed as Record; + const lang = + typeof record.lang === "string" && record.lang.trim() + ? record.lang.trim() + : DEFAULT_SPELLCHECK_PREFERENCE.lang; + return { + enabled: + typeof record.enabled === "boolean" + ? record.enabled + : DEFAULT_SPELLCHECK_PREFERENCE.enabled, + lang, + }; + } catch { + /* corrupted entry - fall back to the default rather than crash */ + return null; + } +} + +function writeStored(pref: SpellcheckPreference): void { + try { + storage()?.setItem(STORAGE_KEY, JSON.stringify(pref)); + } catch { + /* best-effort: the in-memory value still applies for this session */ + } +} + +/** Module singleton so both React roots observe one preference. */ +class SpellcheckStore { + private pref: SpellcheckPreference | null = null; + private listeners: Set<(p: SpellcheckPreference) => void> = new Set(); + + get(): SpellcheckPreference { + if (!this.pref) + this.pref = readStored() ?? { ...DEFAULT_SPELLCHECK_PREFERENCE }; + return this.pref; + } + + set(next: SpellcheckPreference): void { + const current = this.get(); + const value: SpellcheckPreference = { + enabled: next.enabled, + lang: next.lang.trim() || SPELLCHECK_AUTO, + }; + if (value.enabled === current.enabled && value.lang === current.lang) + return; + this.pref = value; + writeStored(value); + this.notify(value); + } + + subscribe(listener: (p: SpellcheckPreference) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + reset(): void { + this.pref = null; + this.listeners.clear(); + } + + private notify(value: SpellcheckPreference): void { + // Snapshot + guard: a subscriber may unsubscribe others or throw; + // iterating the live Set would skip listeners or abort early. + for (const l of Array.from(this.listeners)) { + try { + l(value); + } catch { + /* one listener throwing must not stop the rest */ + } + } + } +} + +const store = new SpellcheckStore(); + +export function getSpellcheckPreference(): SpellcheckPreference { + return store.get(); +} + +export function setSpellcheckPreference(next: SpellcheckPreference): void { + store.set(next); +} + +export function setSpellcheckEnabled(enabled: boolean): void { + store.set({ ...store.get(), enabled }); +} + +export function setSpellcheckLang(lang: SpellcheckLang): void { + store.set({ ...store.get(), lang }); +} + +export function subscribeSpellcheck( + listener: (p: SpellcheckPreference) => void, +): () => void { + return store.subscribe(listener); +} + +/** Test-only - drop the cached preference and every subscriber. */ +export function __resetSpellcheckForTests(): void { + store.reset(); +} + +function normalizeTag(tag: string | null | undefined): string | null { + if (typeof tag !== "string") return null; + const trimmed = tag.trim(); + return TAG_PATTERN.test(trimmed) ? trimmed : null; +} + +/** The `lang` for an editable overlay, or null to leave it to the browser. */ +export function resolveLang( + pref: SpellcheckPreference, + documentLang: string | null | undefined, +): string | null { + if (!pref.enabled) return null; + if (pref.lang !== SPELLCHECK_AUTO) return normalizeTag(pref.lang); + return normalizeTag(documentLang); +} + +export function useSpellcheckPreference(): SpellcheckPreference { + return useSyncExternalStore( + subscribeSpellcheck, + getSpellcheckPreference, + getSpellcheckPreference, + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/textMatching.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/textMatching.ts new file mode 100644 index 0000000000..1ad1960685 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/textMatching.ts @@ -0,0 +1,130 @@ +export interface MatchOptions { + matchCase?: boolean; + wholeWord?: boolean; + ignoreAccents?: boolean; +} + +export interface TextMatch { + start: number; + end: number; +} + +const ASCII_MAX = 0x7f; +const COMBINING_MARK = /\p{M}/gu; +const WORD_CHAR = /[\p{L}\p{N}\p{M}_]/u; + +/** Scanned rather than matched: a regex for this needs control characters. */ +function isAscii(text: string): boolean { + for (let i = 0; i < text.length; i += 1) { + if (text.charCodeAt(i) > ASCII_MAX) return false; + } + return true; +} + +// Length-stable fold: index i of the result maps to index i of the input, so +// match offsets stay valid against the untouched original. +export function foldForSearch(text: string, opts: MatchOptions = {}): string { + const lower = opts.matchCase !== true; + const strip = opts.ignoreAccents === true; + if (!lower && !strip) return text; + // ASCII can never change length under either fold, and this is the hot path. + if (isAscii(text)) return lower ? text.toLowerCase() : text; + let out = ""; + for (const ch of text) out += foldChar(ch, lower, strip); + return out; +} + +function foldChar(ch: string, lower: boolean, strip: boolean): string { + let c = ch; + if (lower) { + const lowered = c.toLowerCase(); + if (lowered.length === c.length) c = lowered; + } + if (strip) { + const stripped = c.normalize("NFD").replace(COMBINING_MARK, ""); + if (stripped.length === c.length) c = stripped; + } + return c; +} + +export function isWordChar(ch: string | null): boolean { + return ch !== null && ch.length > 0 && WORD_CHAR.test(ch); +} + +function codePointAt(text: string, index: number): string | null { + if (index < 0 || index >= text.length) return null; + const cp = text.codePointAt(index); + return cp === undefined ? null : String.fromCodePoint(cp); +} + +function codePointBefore(text: string, index: number): string | null { + if (index <= 0 || index > text.length) return null; + const unit = text.charCodeAt(index - 1); + if (unit >= 0xdc00 && unit <= 0xdfff && index >= 2) { + const high = text.charCodeAt(index - 2); + if (high >= 0xd800 && high <= 0xdbff) return text.slice(index - 2, index); + } + return text.charAt(index - 1); +} + +function isWholeWordAt(text: string, start: number, end: number): boolean { + return ( + !isWordChar(codePointBefore(text, start)) && + !isWordChar(codePointAt(text, end)) + ); +} + +/** Non-overlapping matches, left to right. Offsets index the original. */ +export function findMatches( + haystack: string, + needle: string, + opts: MatchOptions = {}, +): TextMatch[] { + if (needle.length === 0 || needle.length > haystack.length) return []; + const hay = foldForSearch(haystack, opts); + const pin = foldForSearch(needle, opts); + if (pin.length === 0 || pin.length > hay.length) return []; + const out: TextMatch[] = []; + let from = 0; + while (from <= hay.length - pin.length) { + const at = hay.indexOf(pin, from); + if (at < 0) break; + const end = at + pin.length; + if (opts.wholeWord === true && !isWholeWordAt(haystack, at, end)) { + from = at + 1; + continue; + } + out.push({ start: at, end }); + from = end; + } + return out; +} + +/** Literal splice: `$&` and friends in `replacement` are inserted verbatim. */ +export function replaceMatch( + text: string, + match: TextMatch, + replacement: string, +): string { + if (match.start < 0 || match.end > text.length || match.start > match.end) { + return text; + } + return text.slice(0, match.start) + replacement + text.slice(match.end); +} + +/** Same literal semantics as replaceMatch, for an ordered non-overlapping list. */ +export function replaceMatches( + text: string, + matches: TextMatch[], + replacement: string, +): string { + if (matches.length === 0) return text; + let out = ""; + let cursor = 0; + for (const m of matches) { + if (m.start < cursor || m.end > text.length || m.start > m.end) continue; + out += text.slice(cursor, m.start) + replacement; + cursor = m.end; + } + return out + text.slice(cursor); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/textMetrics.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/textMetrics.ts new file mode 100644 index 0000000000..98eddc1997 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/textMetrics.ts @@ -0,0 +1,81 @@ +export interface FontMetrics { + ascent: number; + descent: number; +} + +let sharedCanvas: HTMLCanvasElement | null = null; +const metricsCache = new Map(); + +export function cssFontShorthand( + fontStyle: string, + fontWeight: number, + fontSizePx: number, + fontFamily: string, +): string { + return `${fontStyle} ${fontWeight} ${fontSizePx}px ${fontFamily}`; +} + +function context(): CanvasRenderingContext2D | null { + if (typeof document === "undefined") return null; + if (!sharedCanvas) sharedCanvas = document.createElement("canvas"); + return sharedCanvas.getContext("2d"); +} + +export function measureAdvancePx(text: string, font: string): number { + if (text === "") return 0; + const ctx = context(); + if (!ctx) return 0; + ctx.font = font; + if ("letterSpacing" in ctx) ctx.letterSpacing = "0px"; + return ctx.measureText(text).width; +} + +export function measureMaxLineWidth(text: string, font: string): number { + let max = 0; + for (const line of text.split(/\r?\n/)) { + const w = measureAdvancePx(line, font); + if (w > max) max = w; + } + return max; +} + +/** + * Width of the widest run of non-space characters - the narrowest a box can be + * and still show every glyph. No line breaking can beat it: there is nowhere + * inside a word to break, so a box narrower than this clips text whatever the + * wrap target says. + */ +export function measureLongestTokenWidth(text: string, font: string): number { + let max = 0; + for (const token of text.split(/\s+/)) { + if (!token) continue; + const w = measureAdvancePx(token, font); + if (w > max) max = w; + } + return max; +} + +export function measureFontMetrics( + font: string, + fontSizePx: number, +): FontMetrics { + const cached = metricsCache.get(font); + if (cached) return cached; + const fallback = { ascent: 0.8 * fontSizePx, descent: 0.2 * fontSizePx }; + const ctx = context(); + if (!ctx) return fallback; + ctx.font = font; + const m = ctx.measureText("Hg"); + const ascent = m.fontBoundingBoxAscent; + const descent = m.fontBoundingBoxDescent; + if (typeof ascent !== "number" || typeof descent !== "number") { + return fallback; + } + const metrics = { ascent, descent }; + metricsCache.set(font, metrics); + return metrics; +} + +export function resetTextMetricsCache(): void { + metricsCache.clear(); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/toolbarState.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/toolbarState.ts new file mode 100644 index 0000000000..9dba8a7b1c --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/toolbarState.ts @@ -0,0 +1,96 @@ +import { + isBoldFamily, + isItalicFamily, +} from "@app/tools/pdfTextEditor/util/fontFamily"; +import { canToggleItalic } from "@app/tools/pdfTextEditor/util/fontCapability"; +import type { LocalFont } from "@app/tools/pdfTextEditor/util/localFonts"; +import type { + PageSnapshot, + RGBA, + SelectionState, + ToolbarState, +} from "@app/tools/pdfTextEditor/types"; + +export const EMPTY_TOOLBAR: ToolbarState = { + fontFamily: null, + fontSize: null, + fill: null, + bold: false, + italic: false, + canItalic: false, + stroke: null, + strokeWidth: null, + mixed: { + fontFamily: false, + fontSize: false, + fill: false, + bold: false, + italic: false, + stroke: false, + strokeWidth: false, + }, +}; + +/** Collapse a multi-run selection into a single toolbar snapshot. */ +export function deriveToolbarState( + pages: PageSnapshot[], + selection: SelectionState, + localFonts: LocalFont[] | null = null, +): ToolbarState { + if (selection.runIds.length === 0) return EMPTY_TOOLBAR; + const selected = pages + .flatMap((p) => p.runs) + .filter((r) => selection.runIds.includes(r.id)); + if (selected.length === 0) return EMPTY_TOOLBAR; + const first = selected[0]; + const sameFamily = selected.every((r) => r.fontId === first.fontId); + const sameSize = selected.every((r) => r.fontSize === first.fontSize); + const sameFill = selected.every( + (r) => + r.fill.r === first.fill.r && + r.fill.g === first.fill.g && + r.fill.b === first.fill.b && + r.fill.a === first.fill.a, + ); + const firstStroke = first.stroke ?? null; + const sameStroke = selected.every((r) => + sameRgba(r.stroke ?? null, firstStroke), + ); + const firstStrokeWidth = first.strokeWidth ?? 0; + const sameStrokeWidth = selected.every( + (r) => (r.strokeWidth ?? 0) === firstStrokeWidth, + ); + const firstBold = isBoldFamily(first.fontId); + const firstItalic = isItalicFamily(first.fontId); + const sameBold = selected.every((r) => isBoldFamily(r.fontId) === firstBold); + const sameItalic = selected.every( + (r) => isItalicFamily(r.fontId) === firstItalic, + ); + return { + fontFamily: first.fontId, + fontSize: sameSize ? first.fontSize : null, + fill: sameFill ? first.fill : null, + bold: firstBold, + italic: firstItalic, + canItalic: canToggleItalic( + selected.map((r) => r.fontId), + localFonts, + ), + stroke: sameStroke ? firstStroke : null, + strokeWidth: sameStrokeWidth ? firstStrokeWidth : null, + mixed: { + fontFamily: !sameFamily, + fontSize: !sameSize, + fill: !sameFill, + bold: !sameBold, + italic: !sameItalic, + stroke: !sameStroke, + strokeWidth: !sameStrokeWidth, + }, + }; +} + +function sameRgba(a: RGBA | null, b: RGBA | null): boolean { + if (a === null || b === null) return a === b; + return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a; +} diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index d980029624..fe77b94c49 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -485,6 +485,14 @@ export interface EmlToPdfRequest { */ maxAttachmentSizeMB?: number; } +export interface EncodeCharcodesRequest { + fontName?: string; + fontSha256?: string; + locatorChar?: string; + pageIndex?: number; + pdfBase64?: string; + text?: string; +} export type ExtractAttachmentsRequest = Record; export interface ExtractHeaderRequest { /** @@ -1536,6 +1544,7 @@ export type ToolEndpoint = | "/api/v1/general/merge-pdfs" | "/api/v1/general/multi-page-layout" | "/api/v1/general/overlay-pdfs" + | "/api/v1/general/pdf-text-editor/encode-charcodes" | "/api/v1/general/pdf-to-single-page" | "/api/v1/general/rearrange-pages" | "/api/v1/general/remove-image-pdf" @@ -1639,6 +1648,7 @@ export interface ToolApiParams { "/api/v1/general/merge-pdfs": MergePdfsRequest; "/api/v1/general/multi-page-layout": MergeMultiplePagesRequest; "/api/v1/general/overlay-pdfs": OverlayPdfsRequest; + "/api/v1/general/pdf-text-editor/encode-charcodes": EncodeCharcodesRequest; "/api/v1/general/pdf-to-single-page": GeneralPdfToSinglePageRequest; "/api/v1/general/rearrange-pages": RearrangePagesRequest; "/api/v1/general/remove-image-pdf": GeneralRemoveImagePdfRequest; @@ -1743,6 +1753,7 @@ export const TOOL_ENDPOINTS = [ "/api/v1/general/merge-pdfs", "/api/v1/general/multi-page-layout", "/api/v1/general/overlay-pdfs", + "/api/v1/general/pdf-text-editor/encode-charcodes", "/api/v1/general/pdf-to-single-page", "/api/v1/general/rearrange-pages", "/api/v1/general/remove-image-pdf", diff --git a/frontend/editor/src/core/ui/ToggleSwitch.tsx b/frontend/editor/src/core/ui/ToggleSwitch.tsx index 12f94fdac4..5c2c715633 100644 --- a/frontend/editor/src/core/ui/ToggleSwitch.tsx +++ b/frontend/editor/src/core/ui/ToggleSwitch.tsx @@ -13,6 +13,8 @@ export interface ToggleSwitchProps { disabled?: boolean; size?: "sm" | "md"; id?: string; + /** Placed on the

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