diff --git a/Cargo.lock b/Cargo.lock index b923218d..e16e3ecf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "aligned" version = "0.4.3" @@ -136,6 +142,35 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "allsorts" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba21c7f883cc76d76a41910815b1b212e1ca7870af2bd551565282140660ca5" +dependencies = [ + "bitflags 2.11.1", + "bitreader", + "brotli-decompressor", + "byteorder", + "crc32fast", + "encoding_rs", + "flate2", + "glyph-names", + "itertools 0.10.5", + "lazy_static", + "libc", + "log", + "num-traits", + "ouroboros", + "pathfinder_geometry", + "rustc-hash 1.1.0", + "tinyvec", + "ucd-trie", + "unicode-canonical-combining-class", + "unicode-general-category", + "unicode-joining-type", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -604,7 +639,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.2", "shlex", "syn 2.0.117", ] @@ -645,6 +680,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitreader" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "886559b1e163d56c765bc3a985febb4eee8009f625244511d8ee3c432e08c066" +dependencies = [ + "cfg-if", +] + [[package]] name = "bitstream-io" version = "4.10.0" @@ -837,6 +881,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -1080,7 +1130,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -1694,6 +1744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -1911,6 +1962,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -2008,6 +2068,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -2020,7 +2089,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -2664,7 +2733,7 @@ version = "0.20.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro-crate", "proc-macro2", "quote", @@ -2677,7 +2746,7 @@ version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -2709,6 +2778,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "glyph-names" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3531d702d6c1a3ba92a5fb55a404c7b8c476c8e7ca249951077afcbe4bc807f" + [[package]] name = "gobject-sys" version = "0.20.10" @@ -3016,6 +3091,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -3127,7 +3208,7 @@ dependencies = [ "hashbrown 0.14.5", "new_debug_unreachable", "once_cell", - "rustc-hash", + "rustc-hash 2.1.2", "serde", "triomphe", ] @@ -3603,7 +3684,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -3626,6 +3707,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3997,6 +4087,35 @@ dependencies = [ "imgref", ] +[[package]] +name = "lopdf" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f560f57dfb9142a02d673e137622fd515d4231e51feb8b4af28d92647d83f35b" +dependencies = [ + "aes 0.8.4", + "bitflags 2.11.1", + "cbc", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.3.4", + "indexmap", + "itoa", + "log", + "md-5", + "nom 8.0.0", + "nom_locate", + "rand 0.9.4", + "rangemap", + "sha2", + "stringprep", + "thiserror 2.0.18", + "time", + "ttf-parser", + "weezl", +] + [[package]] name = "lru" version = "0.16.4" @@ -4362,6 +4481,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + [[package]] name = "nonzero_ext" version = "0.3.0" @@ -4754,6 +4884,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ouroboros" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ba07320d39dfea882faa70554b4bd342a5f273ed59ba7c1c6b4c840492c954" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4c6225c69b4ca778c0aea097321a64c421cf4577b331c61b229267edabb6f8" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "outref" version = "0.5.2" @@ -4866,6 +5020,25 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -5324,6 +5497,14 @@ dependencies = [ "uuid", ] +[[package]] +name = "perry-ext-pdf" +version = "0.5.1006" +dependencies = [ + "perry-ffi", + "printpdf", +] + [[package]] name = "perry-ext-pg" version = "0.5.1006" @@ -5529,6 +5710,7 @@ dependencies = [ "mongodb", "nanoid", "once_cell", + "p256", "pbkdf2", "perry-runtime", "perry-updater", @@ -5536,6 +5718,7 @@ dependencies = [ "redis", "regex", "reqwest", + "rsa", "rusqlite", "rust_decimal", "rustls", @@ -5547,6 +5730,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "spki", "sqlx", "thiserror 1.0.69", "tokio", @@ -6022,6 +6206,26 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "printpdf" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91dac59ed5eed56d899517c12cb9f00756738731926b54e513732a36f49a83d" +dependencies = [ + "allsorts", + "base64", + "flate2", + "getrandom 0.3.4", + "lopdf", + "serde", + "serde_derive", + "serde_json", + "time", + "wasm-bindgen", + "wasm-bindgen-futures", + "weezl", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -6031,6 +6235,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -6164,7 +6392,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -6184,7 +6412,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -6300,6 +6528,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "rav1e" version = "0.8.1" @@ -6656,6 +6890,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -6901,7 +7141,7 @@ dependencies = [ "phf 0.13.1", "phf_codegen", "precomputed-hash", - "rustc-hash", + "rustc-hash 2.1.2", "servo_arc", "smallvec", ] @@ -7236,7 +7476,7 @@ dependencies = [ "data-encoding", "debugid", "if_chain", - "rustc-hash", + "rustc-hash 2.1.2", "serde", "serde_json", "unicode-id-start", @@ -7340,7 +7580,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", - "heck", + "heck 0.5.0", "hex", "once_cell", "proc-macro2", @@ -7570,7 +7810,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -7607,7 +7847,7 @@ dependencies = [ "from_variant", "num-bigint", "once_cell", - "rustc-hash", + "rustc-hash 2.1.2", "serde", "siphasher 0.3.11", "swc_atoms", @@ -7629,7 +7869,7 @@ dependencies = [ "num-bigint", "once_cell", "phf 0.11.3", - "rustc-hash", + "rustc-hash 2.1.2", "string_enum", "swc_atoms", "swc_common", @@ -7647,7 +7887,7 @@ dependencies = [ "either", "num-bigint", "phf 0.11.3", - "rustc-hash", + "rustc-hash 2.1.2", "seq-macro", "serde", "smartstring", @@ -7770,7 +8010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" dependencies = [ "cfg-expr", - "heck", + "heck 0.5.0", "pkg-config", "toml", "version-compare", @@ -8299,6 +8539,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.24.0" @@ -8365,6 +8611,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -8388,6 +8640,18 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-canonical-combining-class" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41c99d5174052d02ce765418e826597a1be18f32c114e35d9e22f92390239561" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-id-start" version = "1.4.0" @@ -8400,6 +8664,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-joining-type" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d00a78170970967fdb83f9d49b92f959ab2bb829186b113e4f4604ad98e180" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -9453,7 +9723,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -9464,7 +9734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "indexmap", "prettyplease", "syn 2.0.117", diff --git a/Cargo.toml b/Cargo.toml index eeb69ca6..66a99e5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ members = [ "crates/perry-ext-streams", "crates/perry-ext-fastify", "crates/perry-ext-google-auth", + "crates/perry-ext-pdf", "crates/perry-jsruntime", "crates/perry-wasm-host", "crates/perry-stdlib", @@ -121,6 +122,7 @@ default-members = [ "crates/perry-ext-streams", "crates/perry-ext-fastify", "crates/perry-ext-google-auth", + "crates/perry-ext-pdf", "crates/perry-jsruntime", "crates/perry-wasm-host", "crates/perry-stdlib", @@ -293,6 +295,7 @@ perry-ext-http = { path = "crates/perry-ext-http" } perry-ext-streams = { path = "crates/perry-ext-streams" } perry-ext-fastify = { path = "crates/perry-ext-fastify" } perry-ext-google-auth = { path = "crates/perry-ext-google-auth" } +perry-ext-pdf = { path = "crates/perry-ext-pdf" } perry-stdlib = { path = "crates/perry-stdlib" } perry-diagnostics = { path = "crates/perry-diagnostics" } perry-jsruntime = { path = "crates/perry-jsruntime" } diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 5ad60892..e1a831cb 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -104,6 +104,11 @@ pub const NATIVE_MODULES: &[&str] = &[ // (#674). Bundled wrapper lives in `crates/perry-ext-google-auth`; // d.ts at `types/perry/google-auth/index.d.ts`. "@perryts/google-auth", + // `@perryts/pdf` — official PDF creation package (#516). + // Bundled wrapper lives in `crates/perry-ext-pdf`; the producer + // side companion to the existing PdfView widget. d.ts at + // `types/perry/pdf/index.d.ts`. + "@perryts/pdf", ]; /// Modules handled entirely by `perry-runtime` — the linker doesn't @@ -2341,4 +2346,23 @@ pub static API_MANIFEST: &[ApiEntry] = &[ method("@perryts/google-auth", "js_google_auth_sign_in", false, None), method("@perryts/google-auth", "js_google_auth_silent_sign_in", false, None), method("@perryts/google-auth", "js_google_auth_sign_out", false, None), + // --- @perryts/pdf (issue #516) --- + // Minimal PDF creation API. The five FFI entry points exported + // by crates/perry-ext-pdf. Param shapes intentionally loose + // here (mostly `p_any`) — codegen's NATIVE_MODULE_TABLE rows + // tighten them. createPdf takes a single options object and + // returns a numeric handle; pdfAddText/pdfAddLine accept + // positional args. + method_sig( + "@perryts/pdf", + "createPdf", + false, + None, + &[p_any("opts")], + TypeSpec::Number, + ), + method("@perryts/pdf", "pdfAddText", false, None), + method("@perryts/pdf", "pdfAddLine", false, None), + method("@perryts/pdf", "pdfNewPage", false, None), + method("@perryts/pdf", "pdfSave", false, None), ]; diff --git a/crates/perry-codegen/src/lower_call.rs b/crates/perry-codegen/src/lower_call.rs index 9a6c30e2..7fe7e299 100644 --- a/crates/perry-codegen/src/lower_call.rs +++ b/crates/perry-codegen/src/lower_call.rs @@ -1879,8 +1879,14 @@ pub(crate) fn lower_call(ctx: &mut FnCtx<'_>, callee: &Expr, args: &[Expr]) -> R // fast path silently disengaged for // every `Promise.resolve(...).then(...)` // call (microtask-02..07 regression). + // Resolved-from-merge note: this used to live as + // an unresolved conflict on main; the incoming + // side called `is_global_constructor_expr`, + // which is what the rest of the file uses post + // #1030. Keep the richer comment from HEAD but + // call the same helper everything else does. if inner_property == "resolve" - && crate::type_analysis::is_global_builtin_named( + && is_global_constructor_expr( inner_object.as_ref(), "Promise", ) diff --git a/crates/perry-codegen/src/runtime_decls.rs b/crates/perry-codegen/src/runtime_decls.rs index 43c4b506..9eb5f124 100644 --- a/crates/perry-codegen/src/runtime_decls.rs +++ b/crates/perry-codegen/src/runtime_decls.rs @@ -2217,6 +2217,24 @@ pub fn declare_stdlib_ffi(module: &mut LlModule) { module.declare_function("js_nanoid", I64, &[DOUBLE]); module.declare_function("js_nanoid_custom", I64, &[I64, DOUBLE]); + // ========== @perryts/pdf (issue #516) ========== + // createPdf returns an i64 handle (NaN-boxed POINTER_TAG by + // codegen via NR_PTR). The mutator ops are Rust `-> ()` and + // therefore VOID at the LLVM ABI level. + module.declare_function("js_pdf_create_pdf", I64, &[DOUBLE]); + module.declare_function( + "js_pdf_add_text", + VOID, + &[I64, I64, DOUBLE, DOUBLE, DOUBLE], + ); + module.declare_function( + "js_pdf_add_line", + VOID, + &[I64, DOUBLE, DOUBLE, DOUBLE, DOUBLE], + ); + module.declare_function("js_pdf_new_page", VOID, &[I64]); + module.declare_function("js_pdf_save", VOID, &[I64]); + // ========== Commander CLI ========== module.declare_function("js_commander_action", I64, &[I64, I64]); module.declare_function("js_commander_command", I64, &[I64, I64]); diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index ae8160a3..c86cb86e 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -995,6 +995,14 @@ pub(crate) fn is_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { /// Pass `name = "Promise"` (etc.) to require the property-access form /// to actually name that built-in; the legacy `GlobalGet(_)` arm /// accepts any global because the original code never narrowed. +// `dead_code` allow: the function survived an unresolved merge in +// main (commit 9a9a233c's "fix: recognize global Promise static +// calls" left HEAD/incoming markers in this file). The +// `is_global_constructor_expr` helper added by the same commit +// supersedes this one, but ripping it out is outside #516's +// scope — leave the lingering definition with an allow so the +// dead-code lint doesn't fail the build. +#[allow(dead_code)] pub(crate) fn is_global_builtin_named(expr: &Expr, name: &str) -> bool { if matches!(expr, Expr::GlobalGet(_)) { return true; @@ -1042,6 +1050,12 @@ pub(crate) fn is_promise_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { // `.then` codegen fell through to generic native // dispatch — microtask-02..07 and edge-promises went // silent (callbacks never enqueued). (#1008) + // + // Resolved-from-merge note: the HEAD side called + // `is_global_builtin_named`, the incoming side called + // `is_global_constructor_expr`. Post-#1030 the rest of + // the codegen prefers the latter helper, so we keep the + // richer HEAD comment but switch to the canonical call. if matches!( property.as_str(), "resolve" | "reject" | "all" | "race" | "allSettled" | "any" diff --git a/crates/perry-ext-pdf/Cargo.toml b/crates/perry-ext-pdf/Cargo.toml new file mode 100644 index 00000000..6130e8bc --- /dev/null +++ b/crates/perry-ext-pdf/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "perry-ext-pdf" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Native bindings for `@perryts/pdf` (#516) — minimal PDF creation API using the `printpdf` crate. Companion to the existing PdfView widget (which handles the read/render side). v1 surface: createPdf / pdfAddText / pdfAddLine / pdfNewPage / pdfSave." +repository.workspace = true + +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +perry-ffi.workspace = true +# Pure-Rust PDF writer. `default-features = false` drops the heavy +# `html` feature (azul-layout, kuchiki, …) — for the v1 surface we +# only need core text + line drawing, which lives in the base crate. +printpdf = { version = "0.9", default-features = false } diff --git a/crates/perry-ext-pdf/src/lib.rs b/crates/perry-ext-pdf/src/lib.rs new file mode 100644 index 00000000..3b699faa --- /dev/null +++ b/crates/perry-ext-pdf/src/lib.rs @@ -0,0 +1,652 @@ +//! `@perryts/pdf` native bindings (issue #516). +//! +//! Minimal PDF *creation* API. The viewer half of #516 (PdfView widget +//! across iOS/visionOS/macOS + stubs on the other platform crates) is +//! already shipped — this crate is the producer side. +//! +//! TypeScript surface: +//! +//! ```ignore +//! export declare function createPdf(opts: { +//! path: string; +//! pageWidth?: number; +//! pageHeight?: number; +//! }): number; +//! export declare function pdfAddText( +//! pdf: number, text: string, x: number, y: number, fontSize?: number, +//! ): void; +//! export declare function pdfAddLine( +//! pdf: number, x1: number, y1: number, x2: number, y2: number, +//! ): void; +//! export declare function pdfNewPage(pdf: number): void; +//! export declare function pdfSave(pdf: number): void; +//! ``` +//! +//! Page units are PDF points (1/72 inch). The default page is US +//! Letter (612 × 792 pt). The origin is the bottom-left corner — that +//! is the PDF native coordinate system, not a Perry convention. +//! +//! # Handle model +//! +//! `createPdf` returns a 1-based `i64` handle into a process-global +//! `Mutex>`. Each `OpenDoc` carries the in- +//! progress `printpdf::PdfDocument`, the current page's accumulated +//! `Op` list, the page dimensions, and the destination path. The +//! mutation methods (`pdfAddText`, `pdfAddLine`, `pdfNewPage`) look up +//! the handle, mutate the in-memory state, and return. `pdfSave` +//! flushes the current page, serializes the document, writes the +//! file, and removes the entry from the table — subsequent calls on +//! the freed handle become no-ops with a `warn-once` log line. + +use std::collections::HashMap; +use std::fs; +use std::sync::{Mutex, OnceLock}; + +use perry_ffi::{read_string, JsString, StringHeader}; +use printpdf::{ + BuiltinFont, Color, Line, LinePoint, Op, PdfDocument, PdfFontHandle, PdfPage, + PdfSaveOptions, PdfWarnMsg, Point, Pt, Rgb, TextItem, +}; + +// ============================================================================ +// State +// ============================================================================ + +/// Per-handle PDF-in-progress. +struct OpenDoc { + /// Path to write on `pdfSave`. Captured at `createPdf` time so the + /// save call doesn't have to re-thread it. + path: String, + /// Page size in PDF points. Applied to every page, including ones + /// added later via `pdfNewPage`. + page_width_pt: f32, + page_height_pt: f32, + /// Pages already finalized. The current (in-progress) page is held + /// separately in `current_ops` to avoid clone-on-every-mutation. + finished_pages: Vec, + /// Ops accumulated on the page currently being drawn. + current_ops: Vec, +} + +impl OpenDoc { + /// Finalize the current page and start a fresh op buffer. Idempotent + /// when the current page is empty — `pdfNewPage` called twice in a + /// row does NOT emit two blank pages. + fn finalize_current_page(&mut self) { + if self.current_ops.is_empty() { + return; + } + let ops = std::mem::take(&mut self.current_ops); + let page = PdfPage::new( + Pt(self.page_width_pt).into(), + Pt(self.page_height_pt).into(), + ops, + ); + self.finished_pages.push(page); + } +} + +/// Process-global state. `OnceLock` here defers Mutex creation to first +/// use; the FFI never touches it from multiple threads concurrently in +/// the v1 surface (the JS runtime is single-threaded), but using a +/// `Mutex` keeps the door open for `perry/thread`-style parallelism +/// later without changing the FFI contract. +fn state() -> &'static Mutex> { + static STATE: OnceLock>> = OnceLock::new(); + STATE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Monotonic handle counter. Starts at 1 so a zero return value is +/// always unambiguously an error. +fn next_handle() -> i64 { + use std::sync::atomic::{AtomicI64, Ordering}; + static COUNTER: AtomicI64 = AtomicI64::new(1); + COUNTER.fetch_add(1, Ordering::Relaxed) +} + +/// Default US Letter at 1/72 inch. +const DEFAULT_PAGE_WIDTH_PT: f32 = 612.0; +const DEFAULT_PAGE_HEIGHT_PT: f32 = 792.0; +const DEFAULT_FONT_SIZE_PT: f32 = 12.0; + +/// Warn once per stale handle id so a buggy caller doesn't spam logs. +fn warn_stale_handle_once(handle: i64) { + use std::collections::HashSet; + static WARNED: OnceLock>> = OnceLock::new(); + let warned = WARNED.get_or_init(|| Mutex::new(HashSet::new())); + if let Ok(mut set) = warned.lock() { + if set.insert(handle) { + eprintln!( + "[perry-ext-pdf] warning: operation on stale PDF handle {} \ + (already saved or never created)", + handle + ); + } + } +} + +// ============================================================================ +// FFI: createPdf +// ============================================================================ + +/// `createPdf({ path, pageWidth?, pageHeight? })`. +/// +/// Codegen lowers this as `js_pdf_create_pdf(opts_nan_boxed_f64)` where +/// `opts_nan_boxed_f64` is the NaN-boxed JSValue pointing at the +/// `{ path, pageWidth?, pageHeight? }` object. We round-trip through +/// `perry_ffi::json_stringify` (same trick fastify uses for its config +/// object) so this crate doesn't depend on `perry-runtime` object +/// internals. +/// +/// Returns the new handle as an `i64` (NaN-boxed at the call site with +/// `POINTER_TAG` by the `NR_PTR` codegen path). Returns 0 on bad input +/// — codegen NaN-boxes 0 as a small pointer, which the JS side sees as +/// a number that subsequent ops will treat as a stale handle and skip. +#[no_mangle] +pub unsafe extern "C" fn js_pdf_create_pdf(opts_f64: f64) -> i64 { + let opts_value = perry_ffi::JsValue::from_bits(opts_f64.to_bits()); + let json = match perry_ffi::json_stringify(opts_value) { + Some(s) => s, + None => { + eprintln!("[perry-ext-pdf] createPdf: options object is not stringifiable"); + return 0; + } + }; + let parsed: serde_json_lite::Value = match serde_json_lite::parse(&json) { + Some(v) => v, + None => { + eprintln!("[perry-ext-pdf] createPdf: failed to parse options JSON"); + return 0; + } + }; + + let path = match parsed.get_str("path") { + Some(s) if !s.is_empty() => s.to_string(), + _ => { + eprintln!("[perry-ext-pdf] createPdf: `path` option is required"); + return 0; + } + }; + + let page_width_pt = parsed + .get_f32("pageWidth") + .filter(|n| *n > 0.0) + .unwrap_or(DEFAULT_PAGE_WIDTH_PT); + let page_height_pt = parsed + .get_f32("pageHeight") + .filter(|n| *n > 0.0) + .unwrap_or(DEFAULT_PAGE_HEIGHT_PT); + + let handle = next_handle(); + let mut guard = match state().lock() { + Ok(g) => g, + Err(_) => return 0, + }; + guard.insert( + handle, + OpenDoc { + path, + page_width_pt, + page_height_pt, + finished_pages: Vec::new(), + current_ops: Vec::new(), + }, + ); + handle +} + +// ============================================================================ +// FFI: pdfAddText +// ============================================================================ + +/// `pdfAddText(pdf, text, x, y, fontSize?)`. +/// +/// Emits the printpdf op sequence for placing one literal string at +/// `(x, y)` in Helvetica (one of the 14 PDF built-in fonts — no font +/// file required). `fontSize` is in points; defaults to 12 when 0 or +/// negative. +/// +/// Coordinates: bottom-left origin, PDF points. +#[no_mangle] +pub unsafe extern "C" fn js_pdf_add_text( + handle: i64, + text_ptr: *const StringHeader, + x: f64, + y: f64, + font_size: f64, +) { + let text_handle = JsString::from_raw(text_ptr as *mut StringHeader); + let text = match read_string(text_handle) { + Some(s) => s.to_string(), + None => return, + }; + + let size = if font_size > 0.0 { + font_size as f32 + } else { + DEFAULT_FONT_SIZE_PT + }; + + let mut guard = match state().lock() { + Ok(g) => g, + Err(_) => return, + }; + let Some(doc) = guard.get_mut(&handle) else { + warn_stale_handle_once(handle); + return; + }; + + // Each text emission is a self-contained `BT ... ET` block: set + // font, position the cursor, show the text, end the block. We + // don't try to share font state across calls because that would + // require tracking "current font size" in `OpenDoc` and re- + // emitting the SetFont op only when it changes — overkill for the + // v1 surface, which has no font selector beyond fontSize. + let font = PdfFontHandle::Builtin(BuiltinFont::Helvetica); + doc.current_ops.push(Op::StartTextSection); + doc.current_ops.push(Op::SetFont { + font, + size: Pt(size), + }); + doc.current_ops.push(Op::SetFillColor { + col: Color::Rgb(Rgb { + r: 0.0, + g: 0.0, + b: 0.0, + icc_profile: None, + }), + }); + doc.current_ops.push(Op::SetTextCursor { + pos: Point { + x: Pt(x as f32), + y: Pt(y as f32), + }, + }); + doc.current_ops.push(Op::ShowText { + items: vec![TextItem::Text(text)], + }); + doc.current_ops.push(Op::EndTextSection); +} + +// ============================================================================ +// FFI: pdfAddLine +// ============================================================================ + +/// `pdfAddLine(pdf, x1, y1, x2, y2)`. +/// +/// Draws a single straight line in black with the default 1pt stroke +/// width. Coordinates: bottom-left origin, PDF points. +#[no_mangle] +pub unsafe extern "C" fn js_pdf_add_line(handle: i64, x1: f64, y1: f64, x2: f64, y2: f64) { + let mut guard = match state().lock() { + Ok(g) => g, + Err(_) => return, + }; + let Some(doc) = guard.get_mut(&handle) else { + warn_stale_handle_once(handle); + return; + }; + + let line = Line { + points: vec![ + LinePoint { + p: Point { + x: Pt(x1 as f32), + y: Pt(y1 as f32), + }, + bezier: false, + }, + LinePoint { + p: Point { + x: Pt(x2 as f32), + y: Pt(y2 as f32), + }, + bezier: false, + }, + ], + is_closed: false, + }; + + doc.current_ops.push(Op::SetOutlineColor { + col: Color::Rgb(Rgb { + r: 0.0, + g: 0.0, + b: 0.0, + icc_profile: None, + }), + }); + doc.current_ops.push(Op::SetOutlineThickness { pt: Pt(1.0) }); + doc.current_ops.push(Op::DrawLine { line }); +} + +// ============================================================================ +// FFI: pdfNewPage +// ============================================================================ + +/// `pdfNewPage(pdf)` — finalize the current page and start a fresh one +/// with the same dimensions. No-op when the current page has no ops +/// yet (so calling `createPdf` immediately followed by `pdfNewPage` +/// doesn't emit a blank page). +#[no_mangle] +pub unsafe extern "C" fn js_pdf_new_page(handle: i64) { + let mut guard = match state().lock() { + Ok(g) => g, + Err(_) => return, + }; + let Some(doc) = guard.get_mut(&handle) else { + warn_stale_handle_once(handle); + return; + }; + doc.finalize_current_page(); +} + +// ============================================================================ +// FFI: pdfSave +// ============================================================================ + +/// `pdfSave(pdf)` — flush the current page, serialize the document, +/// write the file at the path passed to `createPdf`, and drop the +/// handle from the table. Errors (lock poisoning, I/O failure, +/// serialize warnings) are logged but not raised — the v1 surface +/// returns `void`, so failure modes are best-effort observable via +/// stderr. +#[no_mangle] +pub unsafe extern "C" fn js_pdf_save(handle: i64) { + let mut doc = { + let mut guard = match state().lock() { + Ok(g) => g, + Err(_) => return, + }; + match guard.remove(&handle) { + Some(d) => d, + None => { + warn_stale_handle_once(handle); + return; + } + } + }; + + doc.finalize_current_page(); + + let mut pdf = PdfDocument::new("Perry PDF"); + pdf.with_pages(doc.finished_pages); + + let mut warnings: Vec = Vec::new(); + let bytes = pdf.save(&PdfSaveOptions::default(), &mut warnings); + for w in &warnings { + eprintln!("[perry-ext-pdf] save warning: {:?}", w); + } + + if let Err(e) = fs::write(&doc.path, &bytes) { + eprintln!( + "[perry-ext-pdf] failed to write PDF to {}: {}", + doc.path, e + ); + } +} + +// ============================================================================ +// Tiny JSON helper +// ============================================================================ +// +// `perry-ffi::json_stringify` hands us a JSON string. To avoid pulling +// `serde_json` (and transitively `serde_derive` + proc-macros) into +// this small crate, parse just the two fields we need by hand. The +// shape is well-known and the input always comes from the JS side, so +// we don't have to handle arbitrary user JSON — only what a Perry +// JS-to-JSON serializer would emit for a plain object literal. + +mod serde_json_lite { + /// Extremely narrow JSON parser — only enough to read a flat + /// object whose values are strings or numbers. Everything else + /// (`null`, nested objects, arrays, booleans on a field we don't + /// touch) is skipped silently. The full grammar is in + /// `perry-stdlib`'s JSON parser; that's the right thing to use + /// when this crate ever needs more. + pub struct Value { + fields: Vec<(String, Field)>, + } + enum Field { + Str(String), + Num(f64), + Other, + } + + #[allow(unused_assignments)] + pub fn parse(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut i = 0usize; + skip_ws(bytes, &mut i); + if bytes.get(i).copied() != Some(b'{') { + return None; + } + i += 1; + let mut fields = Vec::new(); + loop { + skip_ws(bytes, &mut i); + if bytes.get(i).copied() == Some(b'}') { + i += 1; + break; + } + let key = parse_string(bytes, &mut i)?; + skip_ws(bytes, &mut i); + if bytes.get(i).copied() != Some(b':') { + return None; + } + i += 1; + skip_ws(bytes, &mut i); + let value = parse_value(bytes, &mut i)?; + fields.push((key, value)); + skip_ws(bytes, &mut i); + if bytes.get(i).copied() == Some(b',') { + i += 1; + continue; + } + if bytes.get(i).copied() == Some(b'}') { + i += 1; + break; + } + return None; + } + Some(Value { fields }) + } + + impl Value { + pub fn get_str(&self, key: &str) -> Option<&str> { + for (k, v) in &self.fields { + if k == key { + if let Field::Str(s) = v { + return Some(s); + } + } + } + None + } + pub fn get_f32(&self, key: &str) -> Option { + for (k, v) in &self.fields { + if k == key { + if let Field::Num(n) = v { + return Some(*n as f32); + } + } + } + None + } + } + + fn skip_ws(bytes: &[u8], i: &mut usize) { + while let Some(&b) = bytes.get(*i) { + if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' { + *i += 1; + } else { + break; + } + } + } + + fn parse_string(bytes: &[u8], i: &mut usize) -> Option { + if bytes.get(*i).copied() != Some(b'"') { + return None; + } + *i += 1; + let mut out = String::new(); + while let Some(&b) = bytes.get(*i) { + match b { + b'"' => { + *i += 1; + return Some(out); + } + b'\\' => { + *i += 1; + let esc = bytes.get(*i).copied()?; + *i += 1; + match esc { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\x08'), + b'f' => out.push('\x0c'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + // Skip 4 hex digits, decode as one Unicode + // BMP codepoint. Surrogate pairs are not + // handled — the v1 options object only + // contains paths and numbers, which never + // need surrogates. + let mut code: u32 = 0; + for _ in 0..4 { + let h = bytes.get(*i).copied()?; + *i += 1; + code = (code << 4) + | match h { + b'0'..=b'9' => (h - b'0') as u32, + b'a'..=b'f' => (h - b'a' + 10) as u32, + b'A'..=b'F' => (h - b'A' + 10) as u32, + _ => return None, + }; + } + out.push(char::from_u32(code).unwrap_or('\u{FFFD}')); + } + _ => out.push(esc as char), + } + } + _ => { + out.push(b as char); + *i += 1; + } + } + } + None + } + + fn parse_value(bytes: &[u8], i: &mut usize) -> Option { + let start = bytes.get(*i).copied()?; + if start == b'"' { + return Some(Field::Str(parse_string(bytes, i)?)); + } + if start == b'-' || start.is_ascii_digit() { + let begin = *i; + while let Some(&b) = bytes.get(*i) { + if matches!(b, b'-' | b'+' | b'0'..=b'9' | b'.' | b'e' | b'E') { + *i += 1; + } else { + break; + } + } + let slice = std::str::from_utf8(&bytes[begin..*i]).ok()?; + let n: f64 = slice.parse().ok()?; + return Some(Field::Num(n)); + } + // Skip null / true / false / nested objects / arrays without + // capturing them — we never read those fields. + match start { + b't' | b'f' => { + while let Some(&b) = bytes.get(*i) { + if b.is_ascii_alphabetic() { + *i += 1; + } else { + break; + } + } + } + b'n' => { + while let Some(&b) = bytes.get(*i) { + if b.is_ascii_alphabetic() { + *i += 1; + } else { + break; + } + } + } + b'{' => { + // Walk balanced braces. + let mut depth = 0i32; + while let Some(&b) = bytes.get(*i) { + *i += 1; + if b == b'{' { + depth += 1; + } else if b == b'}' { + depth -= 1; + if depth == 0 { + break; + } + } else if b == b'"' { + // Skip a string literal so braces inside it + // don't confuse the depth counter. + *i -= 1; // re-read the opening quote + let _ = parse_string(bytes, i)?; + } + } + } + b'[' => { + let mut depth = 0i32; + while let Some(&b) = bytes.get(*i) { + *i += 1; + if b == b'[' { + depth += 1; + } else if b == b']' { + depth -= 1; + if depth == 0 { + break; + } + } else if b == b'"' { + *i -= 1; + let _ = parse_string(bytes, i)?; + } + } + } + _ => return None, + } + Some(Field::Other) + } +} + +#[cfg(test)] +mod tests { + use super::serde_json_lite::parse; + + #[test] + fn parses_basic_options() { + let v = parse(r#"{ "path": "/tmp/a.pdf", "pageWidth": 612, "pageHeight": 792.5 }"#) + .expect("parse"); + assert_eq!(v.get_str("path"), Some("/tmp/a.pdf")); + assert_eq!(v.get_f32("pageWidth"), Some(612.0)); + assert_eq!(v.get_f32("pageHeight"), Some(792.5)); + } + + #[test] + fn missing_optional_returns_none() { + let v = parse(r#"{ "path": "/tmp/b.pdf" }"#).expect("parse"); + assert_eq!(v.get_str("path"), Some("/tmp/b.pdf")); + assert!(v.get_f32("pageWidth").is_none()); + } + + #[test] + fn skips_unknown_value_kinds() { + let v = parse(r#"{ "path": "/tmp/c.pdf", "meta": null, "tags": [1,2], "nested": {"x":1} }"#) + .expect("parse"); + assert_eq!(v.get_str("path"), Some("/tmp/c.pdf")); + } +} diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index d68f9d12..c0eab1e7 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -267,3 +267,14 @@ tracking = "#466" crate = "perry-ext-google-auth" lib = "perry_ext_google_auth" tracking = "#674" +# `@perryts/pdf` — official PDF creation package (issue #516). +# Companion to the existing PdfView widget (rendering/viewing side +# already shipped in perry-ui-{ios,visionos,macos}). The producer +# side here wraps the pure-Rust `printpdf` crate behind a five- +# function FFI: createPdf / pdfAddText / pdfAddLine / pdfNewPage / +# pdfSave. Page units = PDF points (1/72"), origin = bottom-left +# per the PDF standard. +[bindings."@perryts/pdf"] +crate = "perry-ext-pdf" +lib = "perry_ext_pdf" +tracking = "#516" diff --git a/test-files/test_pdf_create_smoke.ts b/test-files/test_pdf_create_smoke.ts new file mode 100644 index 00000000..d950d95b --- /dev/null +++ b/test-files/test_pdf_create_smoke.ts @@ -0,0 +1,70 @@ +// Compile-smoke for `@perryts/pdf` (issue #516). +// +// Creates a two-page PDF — title + horizontal rule on page 1, +// a second page after `pdfNewPage` — saves it to +// `/tmp/perry_pdf_smoke.pdf`, then verifies the file exists, starts +// with the PDF magic bytes, and contains a plausible `%%EOF` marker +// near the tail. +// +// All five FFI entry points of the v1 surface are exercised: +// createPdf, pdfAddText, pdfAddLine, pdfNewPage, pdfSave. + +import { + createPdf, + pdfAddText, + pdfAddLine, + pdfNewPage, + pdfSave, +} from "@perryts/pdf"; +import * as fs from "fs"; + +const OUT_PATH = "/tmp/perry_pdf_smoke.pdf"; + +function main(): void { + // US Letter portrait by default; default origin = bottom-left. + const pdf = createPdf({ path: OUT_PATH }); + + // Page 1: title + underline. + pdfAddText(pdf, "Hello from Perry!", 72, 720, 18); + pdfAddLine(pdf, 72, 710, 540, 710); + pdfAddText(pdf, "Perry PDF smoke test (#516)", 72, 690, 10); + + // Add a second page to exercise pdfNewPage. + pdfNewPage(pdf); + pdfAddText(pdf, "Page 2", 72, 720, 14); + + pdfSave(pdf); + + // Verify the file exists and looks like a PDF. + if (!fs.existsSync(OUT_PATH)) { + console.error("FAIL: PDF was not written to", OUT_PATH); + process.exit(1); + } + // Read as a Buffer and inspect the magic markers via hex + // encoding. `readFileSync(path, "utf8")` lossily strips bytes + // ≥ 0x80 in Perry's current stdlib, which makes startsWith + // checks unreliable on the binary tail. Hex is round-trip-safe + // and the markers we care about are ASCII (`%PDF-` = + // 255044462d, `%%EOF` = 2525454f46). + const buf = fs.readFileSync(OUT_PATH); + if (buf.length < 100) { + console.error("FAIL: PDF is suspiciously small:", buf.length, "bytes"); + process.exit(1); + } + const hex: string = buf.toString("hex"); + const PDF_MAGIC_HEX = "255044462d"; // "%PDF-" + const EOF_MARKER_HEX = "2525454f46"; // "%%EOF" + if (!hex.startsWith(PDF_MAGIC_HEX)) { + console.error("FAIL: file does not start with %PDF-"); + process.exit(1); + } + // `%%EOF` must appear near the tail, not just somewhere — but for + // a smoke test, "anywhere in the file" is fine. + if (hex.indexOf(EOF_MARKER_HEX) === -1) { + console.error("FAIL: file does not contain %%EOF marker"); + process.exit(1); + } + console.log("OK: PDF written to", OUT_PATH, "(", buf.length, "bytes)"); +} + +main(); diff --git a/types/perry/pdf/index.d.ts b/types/perry/pdf/index.d.ts new file mode 100644 index 00000000..32288432 --- /dev/null +++ b/types/perry/pdf/index.d.ts @@ -0,0 +1,93 @@ +// Type declarations for `@perryts/pdf` — Perry's official PDF +// creation binding (issue #516). +// +// The companion read/render side ships in the PdfView widget +// (`perry/ui`), already available on iOS / visionOS / macOS with +// stubs on the other platform crates. This module is the producer +// side: build PDFs at runtime, save them to disk, hand them off to +// PdfView for display (or to the OS share sheet, email, etc.). +// +// All coordinates are in PDF points (1 pt = 1/72 inch). The origin +// is the bottom-left corner — that is the PDF native coordinate +// system, NOT a Perry convention. The default page is US Letter +// (612 × 792 pt). +// +// Underlying engine: the pure-Rust `printpdf` crate. v1 of the +// surface intentionally exposes only what fits in five FFI calls; +// images, custom fonts, encryption, forms, and annotations beyond +// text + straight lines are deliberate out-of-scope and tracked as +// follow-ups under #516. + +/** + * Options for [`createPdf`]. + */ +export interface CreatePdfOptions { + /** + * Filesystem path where [`pdfSave`] will write the PDF. Captured + * at `createPdf` time so the save call doesn't have to re-thread + * it. + */ + path: string; + /** Page width in PDF points. Defaults to 612 (US Letter portrait). */ + pageWidth?: number; + /** Page height in PDF points. Defaults to 792 (US Letter portrait). */ + pageHeight?: number; +} + +/** + * Start a new in-progress PDF document. Returns an opaque handle + * that the other functions in this module accept as their first + * argument. The handle is freed by [`pdfSave`]; subsequent calls on + * a saved handle are silently no-op (warn-once on stderr). + * + * @example + * const pdf = createPdf({ path: "out.pdf" }); + * pdfAddText(pdf, "Hello, world!", 72, 720, 14); + * pdfSave(pdf); + */ +export declare function createPdf(opts: CreatePdfOptions): number; + +/** + * Draw `text` at `(x, y)` in Helvetica. `fontSize` is in points; + * defaults to 12 when omitted or non-positive. Multiple calls on + * the same page stack without resetting state — each call emits a + * self-contained `BT … ET` PDF text block. + */ +export declare function pdfAddText( + pdf: number, + text: string, + x: number, + y: number, + fontSize?: number, +): void; + +/** + * Draw a straight black 1 pt line from `(x1, y1)` to `(x2, y2)`. + * Coordinates: bottom-left origin, PDF points. + */ +export declare function pdfAddLine( + pdf: number, + x1: number, + y1: number, + x2: number, + y2: number, +): void; + +/** + * Finalize the current page and start a fresh one with the same + * dimensions. No-op when the current page has no drawing ops yet, + * so it's safe to call after [`createPdf`] without emitting a + * leading blank page. + */ +export declare function pdfNewPage(pdf: number): void; + +/** + * Flush the current page, serialize the document, and write it to + * the `path` passed to [`createPdf`]. Drops the handle from the + * internal handle table; subsequent calls with this handle become + * no-ops. + * + * Failure modes (lock poisoning, I/O error, serialize warnings) are + * logged to stderr but not thrown — the surface returns `void`. + */ +export declare function pdfSave(pdf: number): void; diff --git a/types/perry/pdf/package.json b/types/perry/pdf/package.json new file mode 100644 index 00000000..1704e6b7 --- /dev/null +++ b/types/perry/pdf/package.json @@ -0,0 +1,3 @@ +{ + "types": "./index.d.ts" +}