R7.6: Windows and Linux print (#3)

Windows prints through the system dialog at the document's paper;
Linux through the desktop portal, the dialog's choices laid out by Ely.
Round 3 findings fixed without a further review, as the rules say.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-16 16:59:46 -04:00
1 parent de4a38e814
commit 13546ea899
26 files changed
+2544 -114

No files matched your search

Generated
+150 -16
View File
@@ -224,6 +224,21 @@ dependencies = [
"equator",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -275,7 +290,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -286,7 +301,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -801,6 +816,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -832,6 +856,16 @@ dependencies = [
"piper",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bstr"
version = "1.13.1"
@@ -936,6 +970,15 @@ dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher 0.5.2",
]
[[package]]
name = "cbindgen"
version = "0.28.0"
@@ -1002,6 +1045,17 @@ dependencies = [
"libc",
]
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -1751,7 +1805,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.3",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1867,6 +1921,15 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecb"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac"
dependencies = [
"cipher 0.5.2",
]
[[package]]
name = "either"
version = "1.18.0"
@@ -2077,6 +2140,7 @@ dependencies = [
"ely-pdf-engine",
"env_logger",
"log",
"lopdf",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
"objc2-pdf-kit",
@@ -2095,10 +2159,12 @@ dependencies = [
"directories",
"ely-ipc",
"env_logger",
"futures",
"getrandom 0.4.3",
"image",
"libc",
"log",
"lopdf",
"metal",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
@@ -2116,7 +2182,9 @@ dependencies = [
"sha2 0.10.9",
"tempfile",
"ureq",
"windows 0.62.2",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
@@ -2337,7 +2405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2833,7 +2901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
"weezl 0.1.12",
]
[[package]]
@@ -3769,7 +3837,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"block-padding 0.3.3",
"generic-array",
]
@@ -3779,6 +3847,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"block-padding 0.4.2",
"hybrid-array",
]
@@ -4256,6 +4325,33 @@ dependencies = [
"imgref",
]
[[package]]
name = "lopdf"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3"
dependencies = [
"aes 0.9.3",
"bitflags 2.13.2",
"brotli-decompressor",
"cbc 0.2.1",
"ecb",
"encoding_rs",
"flate2",
"getrandom 0.4.3",
"indexmap",
"itoa",
"log",
"md-5 0.11.0",
"nom 8.0.0",
"rand 0.10.3",
"rangemap",
"sha2 0.11.0",
"stringprep",
"thiserror 2.0.21",
"weezl 0.2.1",
]
[[package]]
name = "lru"
version = "0.18.5"
@@ -4389,6 +4485,16 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest 0.11.3",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -4505,7 +4611,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4670,7 +4776,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5238,7 +5344,7 @@ dependencies = [
"async-io",
"async-lock",
"blocking",
"cbc",
"cbc 0.1.2",
"cipher 0.4.4",
"digest 0.10.7",
"endi",
@@ -5247,7 +5353,7 @@ dependencies = [
"getrandom 0.4.3",
"hkdf",
"hmac",
"md-5",
"md-5 0.10.6",
"num",
"num-bigint-dig",
"pbkdf2",
@@ -5872,6 +5978,17 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
@@ -6422,7 +6539,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7010,7 +7127,7 @@ dependencies = [
"cfg-if",
"libc",
"psm",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7073,6 +7190,17 @@ dependencies = [
"quote",
]
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "strum"
version = "0.27.2"
@@ -7350,7 +7478,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7430,7 +7558,7 @@ dependencies = [
"flate2",
"half",
"quick-error 2.0.1",
"weezl",
"weezl 0.1.12",
"zune-jpeg",
]
@@ -7700,7 +7828,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8290,6 +8418,12 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "weezl"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2"
[[package]]
name = "wgpu"
version = "29.0.4"
@@ -8499,7 +8633,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
+3
View File
@@ -43,6 +43,9 @@ sha2 = "0.10"
tempfile = "3"
ureq = "3"
windows-sys = { version = "0.60", features = ["Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_Globalization", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Threading", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Com", "Win32_UI_Shell", "Win32_Security_Authentication_Identity"] }
windows = { version = "0.62", features = ["Data_Pdf", "Graphics_Imaging", "Storage_Streams", "Win32_Graphics_Gdi", "Win32_Graphics_Printing", "Win32_Storage_Xps", "Win32_System_Memory", "Win32_System_WinRT", "Win32_UI_Controls_Dialogs", "Win32_UI_Input_KeyboardAndMouse"] }
lopdf = { version = "0.45", default-features = false }
zbus = { version = "5", default-features = false, features = ["async-io"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["NSLocale", "NSPathUtilities", "NSString", "NSURL", "NSDictionary", "NSValue", "NSError", "NSRunLoop", "NSDate", "NSUserDefaults"] }
objc2-av-foundation = { version = "0.3", default-features = false, features = ["std", "AVPlayer", "AVPlayerItem", "AVPlayerItemOutput", "AVAsset", "objc2-core-media", "objc2-core-video", "objc2-core-foundation"] }
objc2-core-media = { version = "0.3", default-features = false, features = ["std", "CMTime"] }
+3 -1
View File
@@ -170,6 +170,7 @@ tests/scripts/recovery.sh 真窗口 ElyDoc:恢复点、崩溃后副本、
tests/scripts/pdf-recovery.sh 真窗口 ElyPDF:加锁文件的封存恢复点、空闲引擎退出后重开时再问密码(需辅助功能权限)
tests/scripts/autosave.sh 真窗口 ElyDoc:自动保存开关、停手即写、外部改动不覆盖(需辅助功能权限)
tests/scripts/print.sh 真窗口 ElyDoc:打印用文档自己的纸张,取消不动文档(需辅助功能权限)
cargo test -p ely-platform --test print Windows 经“Microsoft Print to PDF”出作业,核对纸张、方向与页面位置;Linux 起私有会话总线与替身打印门户,核对页面设置、令牌与交出的字节、取消(缺 dbus-daemon 时 SKIP)
tests/scripts/show-fullscreen.sh 真窗口 ElyShow:F5 放映的窗口盖住整块屏幕(菜单栏与程序坞之上,需辅助功能权限)
tests/scripts/dock-macos.sh dist 里 ElyDoc、ElyTable、ElyShow 与包外的 target/debug/ely-doc 各启动数次:LOK worker 从未登记为程序坞应用,图块照出
tests/scripts/windows-bundle.sh 合集安装程序逐个静默运行所选应用的安装程序;某个被拒即停下并报出其名与退出码(缺 makensis 时 SKIP)
@@ -197,7 +198,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定义删Line truncated
- 2026-10-04:LOK 命令与对话框。界面只经 `ely_lok::commands::COMMANDS` 发 UNO 命令,worker 拒绝表外命令;内核弹出的对话框(`LOK_CALLBACK_WINDOW` created 或 JSDialog)在回调里即刻关闭并经 `LokError` 报给应用,需要的对话框由 Ely 自建。起因:Calc 的 Backspace 弹同步模态“删除内容”,曾卡死 worker。
- 2026-10-04:ElyDoc 查找替换、拼写、比较、邮件合并(DOC-12 至 14)。查找经 `.uno:ExecuteSearch`:查全部计数并高亮,下一处按全部的矩形定当前序号;替换一处后重新计数,无当前项时“替换”先选下一处。拼写建议来自右键时内核给的上下文菜单,只取拼写部分,`ContextMenu::manual` 在插入点下打开。比较把当前文档存成工作副本、开成新标签、对它发 `.uno:CompareDocuments`,两个源文件都不动。邮件合并的字段在正文里写成 `«列名»`(同 Word 的显示,已定),预览与生成都是对模板副本整体替换;生成逐行写 DOCX 或 PDF 到所选文件夹,任一目标已存在即整体拒绝,从不发邮件。
- 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,macOS 用 PDFKit 的 `NSPrintOperation` 弹系统打印面板(`ely_platform::print`),其他平台报错,未实现。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。
- 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,交系统打印面板(`ely_platform::print`,三端见 R7.6)。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。
- 2026-10-04:ElyDoc 的标签、会话、打开、保存、另存为(按文件身份比对)、关闭询问、冲突、密码与标签条移入 `crates/ely-office`:应用实现 `Host`(格式表 `Kind`、每标签附加数据、内核回报钩子),共用逻辑以扩展 trait 提供(`use ely_office::prelude::*`)。ElyTable 用同一外壳。共用文案在 `office.*`。
- 2026-10-04:ElyTable(C09.1)。新建为空白 XLSX(首个工作表名随界面语言),保存保留 xlsx/ods/xls/fods,另存为 XLSX(默认)或 ODS,xlsm/xltm 进风险模式。上方名称框与公式栏(回车分别发 `.uno:GoToCell` 与 `.uno:EnterString`,Esc 交回网格),中间行列表头按内核几何自绘(点选整行整列,拖边改尺寸),下方工作表标签(右键与“工作表”菜单:插入、重命名、复制、左右移、隐藏、显示、删除;删除前列出引用该表的公式数与命名区域数)与状态栏(保存状态、内核给的选区统计)。“单元格”菜单:插删行列与单元格、合并拆分(多个数据格时先问移入或保留隐藏)、行高列宽(厘米)与最适、隐藏显示行列、冻结。填充柄拖到目标格发 `.uno:AutoFill`。选区统计文字由内核给出,当前为英文(内核界面语言未随 Ely 切换),待处理。
- 2026-10-04:ElyTable 公式、计算、名称(C09.2,TABLE-03 至 06)。公式语法为 Excel A1(用户定):`Sheet2!A1`、逗号分隔参数,由安装层 `packaging/libreoffice/ely.xcd` 设定;载入选项 `Language=en-US,DeviceFormFactor=desktop`,内核界面与数字区域都是 en-US(小数点、月/日/年输入)。单元格内编辑由内核完成:键入时内核给出匹配的函数(`editor::CompletionMenu` 显示在单元格下,上下键选,Tab 补全,经 `completeFunction` 入有序队列),参数提示(`editor::SignatureHelp`,取内核的用法文字),点选单元格插入引用并按内核颜色画框;内核只为点选的引用与再次编辑(F2)的已有公式画框,新键入的引用不画(同桌面版)。第一下 Esc 关提示,第二下取消(同内核)。语法错误的公式照原样输入,状态栏按界面语言解释当前格的错误(内核英文错误串换成界面文案,同统计);循环引用同此提示。“公式”菜单:自动计算开关(关时状态栏显示手动计算)、重新计算、全部重新计算、定义名称(名称、引用位置默认为所选区域的绝对引用、范围为工作簿或某表;名称先经内核判定)、名称列表(改引用、转到所命名的绝对区域、删除,失效引用标出;内核给出每个名称的区域,`.uno:SelectedRange` 给所选区域)、追踪错误、追踪引用与从属单元格、移去箭头。名称框可直接输入名称跳转,跳转后显示该名称。手动计算在 ods、xlsx、xls、fods 存后重开都保持。
@@ -312,4 +313,5 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-16(Gitea #6):设置窗口多出一行应用菜单(R7.2)。设置窗口沿用主窗口的外壳 `Shell`,Windows 与 Linux 上随之画出菜单栏(macOS 菜单在系统菜单栏,不受影响)与更新横幅。菜单栏与更新横幅只属主窗口:`Shell::secondary` 去掉两者,设置窗口用它;`settings/tests.rs` 量内容起点距标题栏底:只装菜单时主窗口大于零(macOS 为零),再有待下载的新版时更大,设置窗口为零。
- 2026-10-16(Gitea #4):装了宋体仍报缺少(R7.3)。内核按名找字体时比较 `GetEnglishSearchFontName`(小写 ASCII、去空格、中日韩本地名译为英文名,`宋体` 即 `simsun`),所以文档的 `宋体` 找到并用上 SimSun;缺字检查却比较原名与所用字体名,报为缺失。Linux 上内核以界面语言(英文)为 fontconfig 字体取名,文档里的本地名一律报缺。补丁 `0048-lok-fonts-missing-by-search-name.patch`:缺字检查同样比较检索名。实测(2026-10-16):Arch 容器装 ElyDoc 0.1.5 的 Arch 包与 simsun.ttc,40 行宋体五号、文档网格的 docx 报缺宋体,正文实为 SimSun(无此字体时为方框),与 Windows 同为一页;换上打过 0048 的内核后同一容器不再报缺、仍为一页。用户所说一页变两页未能复现,需其文件。缺字检查测试:`navigate/tests.rs` 以随包的 DejaVu Sans 写作 `DEJAVU SANS`,只报真缺的字体。另见:Linux 上属性栏的字体框为空(字体列表只有 SimSun,文档写宋体),未改。
- 2026-10-16(Gitea #5):每个 PDF 打开即报已损坏(R7.4)。C11.8 把画图块与导出时 MuPDF 的任何警告都算作损坏;读页面时载入未嵌入的字体也发警告(中文文件里写名而不嵌入的宋体等:`non-embedded font using identity encoding`),合法的文件因此转为修复副本、保存一律另存。现在警告监视(`keep_warning`,打开时的扫描、画图块、导出与 Markdown 的 PDF 共用)只略去这一句与紧随其后的 MuPDF 重复计数(`... repeated N times...`);其余照算损坏,含内容的语法错误、无字体写字、坏的嵌入字体与图片流。读文字与搜索也受监视(坏图片读过即被缓存,之后画图块、导出与检查都不再报警告):其间遇到的损坏记入报告与该文档,下一次画图块或检查图片报出,报出之前导出图片与文字一律拒绝。监视开始时,MuPDF 尚未报出的重复计数(来自其余未受监视的读取)按它所重复的那句归类(`ctx->warn.message`):字体提示的重复略去,其余照算。`tests/output.rs` 核对:两个同名未嵌入字体的文件先读文字再画图块、导出图片与文字都不算损坏;无字体写字与 `BT ] ET` 的图块为损坏;`[/FlateDecode /ASCIIHexDecode]` 下校验和错的图片使两种导出都被拒、图块为损坏。先读文字或搜索后,这样的两张图片、颜色键掩码无效的一张图片、以及它在两个未嵌入字体之前或之后画出,画图块、导出与检查图片仍报损坏。
- 2026-10-16(Gitea #3):Windows 与 Linux 打印(R7.6)。`print_pdf` 改为异步:对话框在应用线程上,读页与出作业在自己的线程上(`print::off`),应用不卡。Windows:系统打印对话框(`PrintDlgEx`,Windows 11 显示为新式对话框),打开前把默认打印机的设置改成文档的纸张与方向(按打印机自己的纸型匹配(纸型须装得下页面,至多大 2 毫米,Linux 的 PWG 名同此);没有即自定义尺寸),设置须连同打印机名(`DEVNAMES`)一起交给对话框,否则对话框改用打印机自己的偏好(实测:Letter 文档印成 A4);所选页码范围照办,份数交驱动;勾“打印到文件”时文档输出为 `FILE:`,由打印子系统问文件名。驱动不支持所设纸张时会换成别的(实测“Microsoft Print to PDF”没有自定义尺寸,1000×600 点的页面换成 A4),所以出作业之前(`StartDoc` 之前)按打印机实际的纸张核对每一页:超出纸张半点以上(纸张尺寸至多舍入到整点,`print::ROUNDING`)、转过来也超出即报错,什么也不印(实测:600×842 点的页面原在 A4 上印出、右缘被裁)。页面由 Windows 自带的 PDF 渲染(`Windows.Data.Pdf`)按打印机分辨率分条画出,按原大从纸张的角上放(扣去不可打印的偏移),只在转过来才放得下时左转 90 度;渲染器的尺寸按 DIP 算、乘显示缩放,每次作业先试画一次量出倍数。`ELY_PRINT_TO` 时不弹对话框,经“Microsoft Print to PDF”写成文件(测试用)。`GlobalFree` 成功时返回空,windows-rs 读成错误码 0 的错误,须当作成功(否则打印报“操作成功完成”)。Linux:经 xdg 桌面门户的 `org.freedesktop.portal.Print`(`PreparePrint` 后带其令牌 `Print`,交出 PDF 的文件描述符),页面设置为文档纸张(标准纸型给 PWG 名,否则自定义毫米尺寸),边距为零,只许输出 PDF(`supported_output_file_formats`,否则对话框可选 PostScript,写出的却是 PDF);门户照原样交出所给的字节,对话框的选择由应用排定(`print/linux/sheets.rs`):GTK 设置里的页码范围(从零起)、奇偶页、倒序、份数与逐份、缩放、每张页数与排列,排到对话框所选纸张与方向上,每页为一个表单对象(带可打印批注的外观;带 NoRotate 的批注在转过的页面上绕其左上角转回、保持正立,同 MuPDF;外观的资源名避开页面已有的;可打印却没有外观的批注即报错,链接与弹出说明本无外观),单页按原大乘缩放从纸张左上角放,超出半点以上而转过来放得下即左转,否则报错;方向照 GTK 的写法(`reverse-portrait`,门户文档的页面设置同此);奇偶页只在 GTK 的文件与 lpr 后端时由 Ely 排(设置里有 `output-uri` 或 `lpr-commandline`),CUPS 后端自己把它交给 CUPS(`cups-page-set`,GTK 3.24.52 实测),Ely 再排一次即丢页;份数、每张页数等 GTK 留给应用,Ely 照排;裁剪框超出媒体框的部分不印(取两者的交);页码范围的开放终点 GTK 写作 -1(`1--1`),倒着的范围即报错;多页按格居中,再交内存文件(`memfd`);直接用 zbus:ashpd 0.13.13 的 `PageSetup`、`Settings` 缺任一字段即解析失败,真门户的回复常不带 `PPDName`。页面尺寸由 lopdf 读(CropBox,否则 MediaBox,继承、`/Rotate` 与页面自己的 `/UserUnit`)。打印不再按平台禁用(原 `print::BUILT` 与“此平台尚未构建”删去)。替身门户的测试核对选页、缩放、倒序与份数后交出的页面与位置,与只许 PDF。ElyPDF 的打印副本(`ely_print_copy`)按打印的规则画:可打印(不隐藏)的批注与表单控件,外观画进页面内容(位置照 MuPDF 自己的 `pdf_annot_transform`,与屏上一致;无外观的先由 MuPDF 生成,MuPDF 原只在显示时放在本地表里、不存入文件),其余批注与表单去掉,只留链接;系统的渲染器按屏幕规则画(不打印的照画,“打印但不显示”的不画,实测 Windows),画进内容后各渲染器都只见要打印的;仍画不出的即报错;`tests/output.rs` 渲染副本核对。真窗口(Windows):ElyDoc 的 Ctrl+P、Ely 的打印对话框、系统对话框、“Microsoft Print to PDF”的另存为,印出的 PDF 为文档的 Letter;系统对话框取消时不打印、不报错(`.work/r76/print.ps1`)。未验证:Linux 真桌面上的门户(只对替身门户核对);实体打印机。
- 2026-10-16(用户定):ElyMark 打开的块保持原样(R7.5)。组件库 bff7ebc 起,可视模式里按下的块以原文显示在它合上时的框里(同样的边距与透明边框),字号、行高与颜色同合上时:段落、列表、引用等为正文,标题按其级数的字号、1.3 倍行高、加粗(六级为次要色),照原文写出的块(链接定义等)为等宽次要色;不再加深色底板与另一套边距,按下前后不挪位置。代码块、块公式与 front matter 仍有自己的底板(代码与 front matter 为等宽小号,front matter 为次要色,同合上时)。输入框的着色(源码模式同用)给标题与粗体同一字重(原为半粗,压过所设的加粗),六级标题里的字照为次要色(行内代码照为正文色,2e65b05),front matter 照 front matter 读(原读成分隔线加标题,键名变亮变粗)(1ff993d、962964e)。表格单元格照旧。打字机模式的居中另按两端余量判定(原只按块高与视图高,打开前后同高时首块不再居中)。同批带入组件库 cdc5ce8(标题栏拖动区里的控件按下归控件,Windows)。Windows 真窗口核对(`.work/r75`):段落、三级标题、代码块打开前后位置相同。
+5 -3
View File
@@ -64,7 +64,9 @@ fn a_failed_print_says_printing_failed() {
.expect("the window");
let gone = dir.path().join("gone.pdf");
update(&mut cx, &book, |_, cx| ely_office::print_later(gone, cx));
cx.run_until_parked();
let (toasts, prefix) = cx.update(|app| (root.read(app).toasts(app), ely_office::text(app, "office.print.failed", &[("error", "")])));
assert!(toasts.iter().any(|(text, severity)| *severity == Some(Severity::Danger) && text.starts_with(prefix.as_ref())), "{toasts:?}");
// The file is read on its own thread.
settle(&cx, |app| {
let prefix = ely_office::text(app, "office.print.failed", &[("error", "")]);
root.read(app).toasts(app).iter().any(|(text, severity)| *severity == Some(Severity::Danger) && text.starts_with(prefix.as_ref()))
});
}
+1 -1
View File
@@ -246,7 +246,7 @@ pub fn in_window<H: Host, R>(
pub fn print_later<H: Tabs>(pdf: std::path::PathBuf, cx: &mut Context<H>) {
// The panel's modal loop calls back into GPUI.
cx.spawn(async move |this, cx| {
let printed = ely_platform::print::print_pdf(&pdf);
let printed = ely_platform::print::print_pdf(&pdf).await;
// The copy may hold what a password guarded.
if let Err(error) = std::fs::remove_file(&pdf) {
log::error!("office: the print copy {} stays: {error}", pdf.display());
+1
View File
@@ -20,6 +20,7 @@ cc = "1"
[dev-dependencies]
ely-pdf-engine = { path = ".", features = ["test-support"] }
lopdf.workspace = true
tempfile.workspace = true
[lints]
+138 -1
View File
@@ -143,6 +143,143 @@ int ely_write_repaired(fz_context *ctx, fz_document *doc, const char *path)
return 0;
}
/* Marked for print and not hidden. */
static int prints(fz_context *ctx, pdf_annot *annot)
{
int flags = pdf_annot_flags(ctx, annot);
return (flags & PDF_ANNOT_IS_PRINT) && !(flags & (PDF_ANNOT_IS_HIDDEN | PDF_ANNOT_IS_INVISIBLE));
}
/* Asks MuPDF to make and keep a printable annotation's look. */
static void ask_look(fz_context *ctx, pdf_annot *annot)
{
if (prints(ctx, annot))
pdf_annot_request_synthesis(ctx, annot);
}
/* Draws a printable look into `buf`, named in `xobj`. */
static void draw_look(fz_context *ctx, pdf_annot *annot, int page, pdf_obj *xobj, fz_buffer *buf)
{
pdf_obj *type = pdf_dict_get(ctx, pdf_annot_obj(ctx, annot), PDF_NAME(Subtype));
pdf_obj *ap;
fz_matrix m;
char name[32];
int n = 0;
/* Links and popups have no look of their own. */
if (!prints(ctx, annot) || type == PDF_NAME(Link) || type == PDF_NAME(Popup))
return;
if (!pdf_dict_getp(ctx, pdf_annot_obj(ctx, annot), "AP/N"))
fz_throw(ctx, FZ_ERROR_FORMAT, "a %s annotation on page %d cannot be drawn", pdf_to_name(ctx, type), page + 1);
/* A state with no look of its own draws nothing. */
ap = pdf_annot_ap(ctx, annot);
if (!ap)
return;
do
fz_snprintf(name, sizeof name, "ElyAnnot%d", n++);
while (pdf_dict_gets(ctx, xobj, name));
pdf_dict_puts(ctx, xobj, name, ap);
pdf_dict_put(ctx, ap, PDF_NAME(Type), PDF_NAME(XObject));
pdf_dict_put(ctx, ap, PDF_NAME(Subtype), PDF_NAME(Form));
m = pdf_annot_transform(ctx, annot);
fz_append_printf(ctx, buf, "q %g %g %g %g %g %g cm /%s Do Q\n", m.a, m.b, m.c, m.d, m.e, m.f, name);
}
/* Draws the printable looks in; drops all but links. */
static void flatten(fz_context *ctx, pdf_document *pdf, pdf_page *page, int number)
{
pdf_obj *annots = pdf_dict_get(ctx, page->obj, PDF_NAME(Annots));
pdf_obj *res, *xobj, *contents, *links = NULL, *prologue = NULL;
fz_buffer *buf = NULL;
pdf_annot *annot;
int prepend, append, i;
if (pdf_array_len(ctx, annots) == 0)
return;
res = pdf_dict_get(ctx, page->obj, PDF_NAME(Resources));
if (!res)
{
res = pdf_dict_get_inheritable(ctx, page->obj, PDF_NAME(Resources));
if (res)
pdf_dict_put(ctx, page->obj, PDF_NAME(Resources), res);
else
res = pdf_dict_put_dict(ctx, page->obj, PDF_NAME(Resources), 4);
}
xobj = pdf_dict_get(ctx, res, PDF_NAME(XObject));
if (!xobj)
xobj = pdf_dict_put_dict(ctx, res, PDF_NAME(XObject), 8);
fz_var(buf);
fz_var(links);
fz_var(prologue);
fz_try(ctx)
{
/* The page's own state is closed before the looks. */
contents = pdf_dict_get(ctx, page->obj, PDF_NAME(Contents));
pdf_count_q_balance(ctx, pdf, res, contents, &prepend, &append);
buf = fz_new_buffer(ctx, 1024);
while (prepend-- > 0)
fz_append_string(ctx, buf, "q\n");
prologue = pdf_add_stream(ctx, pdf, buf, NULL, 0);
fz_clear_buffer(ctx, buf);
while (append-- > 0)
fz_append_string(ctx, buf, "Q\n");
for (annot = pdf_first_annot(ctx, page); annot; annot = pdf_next_annot(ctx, annot))
draw_look(ctx, annot, number, xobj, buf);
for (annot = pdf_first_widget(ctx, page); annot; annot = pdf_next_widget(ctx, annot))
draw_look(ctx, annot, number, xobj, buf);
links = pdf_new_array(ctx, pdf, 4);
for (i = 0; i < pdf_array_len(ctx, annots); i++)
if (pdf_dict_get(ctx, pdf_array_get(ctx, annots, i), PDF_NAME(Subtype)) == PDF_NAME(Link))
pdf_array_push(ctx, links, pdf_array_get(ctx, annots, i));
pdf_dict_put(ctx, page->obj, PDF_NAME(Annots), links);
if (!pdf_is_array(ctx, contents))
{
pdf_obj *all = pdf_dict_put_array(ctx, page->obj, PDF_NAME(Contents), 3);
if (contents)
pdf_array_push(ctx, all, contents);
contents = all;
}
pdf_array_insert(ctx, contents, prologue, 0);
pdf_array_push_drop(ctx, contents, pdf_add_stream(ctx, pdf, buf, NULL, 0));
}
fz_always(ctx)
{
fz_drop_buffer(ctx, buf);
pdf_drop_obj(ctx, links);
pdf_drop_obj(ctx, prologue);
}
fz_catch(ctx)
fz_rethrow(ctx);
}
/* Every page as it prints; no form is left. */
static void flatten_all(fz_context *ctx, fz_document *copy, void *arg)
{
pdf_document *pdf = ely_pdf_of(ctx, copy);
pdf_page *page = NULL;
pdf_annot *annot;
int i, n = pdf_count_pages(ctx, pdf);
fz_var(page);
fz_try(ctx)
{
for (i = 0; i < n; i++)
{
page = pdf_load_page(ctx, pdf, i);
for (annot = pdf_first_annot(ctx, page); annot; annot = pdf_next_annot(ctx, annot))
ask_look(ctx, annot);
for (annot = pdf_first_widget(ctx, page); annot; annot = pdf_next_widget(ctx, annot))
ask_look(ctx, annot);
pdf_update_page(ctx, page);
flatten(ctx, pdf, page, i);
fz_drop_page(ctx, &page->super);
page = NULL;
}
pdf_dict_del(ctx, pdf_dict_get(ctx, pdf_trailer(ctx, pdf), PDF_NAME(Root)), PDF_NAME(AcroForm));
}
fz_always(ctx)
fz_drop_page(ctx, (fz_page *)page);
fz_catch(ctx)
fz_rethrow(ctx);
}
/* Leave to print was checked; the printer gets no lock. */
int ely_print_copy(fz_context *ctx, fz_document *doc, const char *raw, const char *out, const char *password)
{
@@ -150,7 +287,7 @@ int ely_print_copy(fz_context *ctx, fz_document *doc, const char *raw, const cha
{
pdf_write_options opts = pdf_default_write_options;
opts.do_encrypt = PDF_ENCRYPT_NONE;
ely_save_copy(ctx, ely_pdf_of(ctx, doc), raw, out, password, &opts, NULL, NULL);
ely_save_copy(ctx, ely_pdf_of(ctx, doc), raw, out, password, &opts, flatten_all, NULL);
}
fz_catch(ctx)
return ely_fail(ctx);
+43 -4
View File
@@ -171,6 +171,45 @@ fn a_print_copy_carries_no_lock() {
assert_eq!(third.pages(opened.doc).expect("pages"), 2);
}
#[test]
fn a_print_copy_shows_what_prints_to_any_screen_renderer() {
let dir = tempfile::tempdir().expect("a temp dir");
// No looks of their own: MuPDF makes them.
let square = |flags: u8, rect: &str, color: &str| format!("<< /Type /Annot /Subtype /Square /F {flags} /Rect [{rect}] /C [{color}] /IC [{color}] >>");
let annots = [
square(4, "50 100 150 180", "1 0 0"),
square(0, "200 100 300 180", "0 1 0"),
// Print and NoView: printed, never shown.
square(36, "350 100 450 180", "0 0 1"),
// Print and Hidden: neither.
square(6, "50 300 150 380", "0 1 0"),
"<< /Type /Annot /Subtype /Link /F 4 /Rect [200 300 300 380] /A << /S /URI /URI (https://example.com) >> >>".to_string(),
];
let path = dir.path().join("doc.pdf");
std::fs::write(&path, one_page("", " /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R]", "", &annots)).expect("writes");
let (mut worker, doc) = opened(&path);
let print = dir.path().join("print.pdf");
worker.print_copy(doc, &print).expect("writes");
// Only the link stays an annotation.
let copy = lopdf::Document::load(&print).expect("a PDF");
let left = copy.get_dictionary(copy.get_pages()[&1]).and_then(|page| page.get(b"Annots")).and_then(lopdf::Object::as_array).expect("annotations");
let kinds: Vec<&[u8]> = left.iter().map(|annot| copy.dereference(annot).and_then(|(_, a)| a.as_dict()?.get(b"Subtype")?.as_name()).expect("a kind")).collect();
assert_eq!(kinds, [b"Link".as_slice()]);
assert!(copy.catalog().expect("a catalog").get(b"AcroForm").is_err(), "no form left");
// MuPDF draws by screen rules, as Windows' renderer does.
let mut third = self::worker();
let printed = third.open(&print).expect("opens").doc;
let bgra = third.render(printed, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra;
let at = |x: usize, y: usize| -> [u8; 3] {
let p = &bgra[((792 - y) * 612 + x) * 4..][..3];
[p[2], p[1], p[0]]
};
assert_eq!(at(100, 140), [255, 0, 0], "printable");
assert_eq!(at(250, 140), [255, 255, 255], "not for print");
assert_eq!(at(400, 140), [0, 0, 255], "print, not view");
assert_eq!(at(100, 340), [255, 255, 255], "hidden");
}
#[test]
fn a_broken_picture_shows_when_drawn_or_checked() {
let dir = tempfile::tempdir().expect("a temp dir");
@@ -299,18 +338,18 @@ fn a_broken_picture_read_before_is_still_damage() {
// Reading caches the pictures; their repeat waits.
let read: [fn(&mut Worker, Doc); 2] = [|w, d| drop(w.text(d, 0).expect("reads")), |w, d| drop(w.search(d, 0, "x").expect("searches"))];
for (path, first) in files.iter().flat_map(|p| read.map(|r| (p, r))) {
let (mut worker, doc) = opened(&path);
let (mut worker, doc) = opened(path);
first(&mut worker, doc);
let tile = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws");
assert!(tile.damaged, "the picture draws as damage");
assert!(worker.repair_report(doc).expect("asks").is_some());
let (mut worker, doc) = opened(&path);
let (mut worker, doc) = opened(path);
first(&mut worker, doc);
assert!(worker.export_image(doc, 0, 72.0, &dir.path().join("page.png")).is_err(), "its page read whole");
let (mut worker, doc) = opened(&path);
let (mut worker, doc) = opened(path);
first(&mut worker, doc);
assert!(worker.export_text(doc, &dir.path().join("doc.txt")).is_err(), "its text read whole");
let (mut worker, doc) = opened(&path);
let (mut worker, doc) = opened(path);
first(&mut worker, doc);
assert!(worker.check_pictures(doc).expect("checks"), "the check finds it");
}
+9
View File
@@ -12,6 +12,7 @@ arboard.workspace = true
directories.workspace = true
ely-ipc.workspace = true
env_logger.workspace = true
futures.workspace = true
getrandom.workspace = true
image.workspace = true
log.workspace = true
@@ -38,17 +39,21 @@ security-framework.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys.workspace = true
windows.workspace = true
[target.'cfg(unix)'.dependencies]
libc.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
lopdf.workspace = true
zbus.workspace = true
rfd = { workspace = true, features = ["xdg-portal"] }
[target.'cfg(not(target_os = "linux"))'.dependencies]
rfd.workspace = true
[dev-dependencies]
lopdf.workspace = true
tempfile.workspace = true
[target.'cfg(target_os = "macos")'.dev-dependencies]
@@ -60,3 +65,7 @@ workspace = true
[[test]]
name = "media"
harness = false
[[test]]
name = "print"
harness = false
+32 -73
View File
@@ -1,79 +1,32 @@
//! Prints a PDF through the system print panel.
//! Prints a PDF through the system print dialog.
use std::path::Path;
/// This build has a system print panel.
pub const BUILT: bool = cfg!(target_os = "macos");
/// Shows the print panel for `pdf`; false when cancelled.
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
pub fn print_pdf(pdf: &Path) -> anyhow::Result<bool> {
use objc2::{AllocAnyThread, MainThreadMarker};
use objc2_app_kit::{NSPaperOrientation, NSPrintInfo, NSPrintJobSavingURL, NSPrintSaveJob};
use objc2_foundation::{NSCopying, NSSize, NSString, NSURL};
use objc2_pdf_kit::{PDFDisplayBox, PDFDocument, PDFPrintScalingMode};
mod mac;
#[cfg(windows)]
mod win;
let mtm =
MainThreadMarker::new().ok_or_else(|| anyhow::anyhow!("printing needs the main thread"))?;
let url = NSURL::fileURLWithPath(&NSString::from_str(&pdf.to_string_lossy()));
let document = unsafe { PDFDocument::initWithURL(PDFDocument::alloc(), &url) }
.ok_or_else(|| anyhow::anyhow!("{} is no readable PDF", pdf.display()))?;
let sizes: Vec<(f64, f64)> = (0..unsafe { document.pageCount() })
.filter_map(|n| unsafe { document.pageAtIndex(n) })
.map(|page| {
let size = unsafe { page.boundsForBox(PDFDisplayBox::MediaBox) }.size;
// A page turned a quarter shows its other side up.
match unsafe { page.rotation() }.rem_euclid(180) == 90 {
true => (size.height, size.width),
false => (size.width, size.height),
}
})
.collect();
let (width, height) = paper_for(&sizes).ok_or_else(|| anyhow::anyhow!("{} has no pages", pdf.display()))?;
let paper = NSSize::new(width, height);
if let Some(scale) = crate::env::value("ELY_PRINT_SHARED_SCALE") {
// Tests start from a shared scale the copy must undo.
NSPrintInfo::sharedPrintInfo().setScalingFactor(scale.parse()?);
}
// The PDF already holds the document's paper, margins and scale.
let info = NSPrintInfo::sharedPrintInfo().copy();
info.setScalingFactor(1.0);
info.setOrientation(match paper.width > paper.height {
true => NSPaperOrientation::Landscape,
false => NSPaperOrientation::Portrait,
});
info.setPaperSize(paper);
info.setTopMargin(0.0);
info.setBottomMargin(0.0);
info.setLeftMargin(0.0);
info.setRightMargin(0.0);
log::info!("print: paper {}x{} points", paper.width, paper.height);
let operation = unsafe {
document.printOperationForPrintInfo_scalingMode_autoRotate(
Some(&info),
PDFPrintScalingMode::PageScaleNone,
true,
mtm,
)
}
.ok_or_else(|| anyhow::anyhow!("no print operation for {}", pdf.display()))?;
// Tests read the real job: saved to a file.
let saved = crate::env::value("ELY_PRINT_TO");
if let Some(to) = &saved {
let info = operation.printInfo();
info.setJobDisposition(unsafe { NSPrintSaveJob });
let to = NSURL::fileURLWithPath(&NSString::from_str(to));
unsafe { info.dictionary().insert(NSPrintJobSavingURL, &to) };
}
operation.setShowsPrintPanel(saved.is_none());
operation.setShowsProgressPanel(saved.is_none());
let printed = operation.runOperation();
log::info!("print: the panel closed, printed {printed}");
Ok(printed)
#[cfg(target_os = "linux")]
pub use linux::print_pdf;
#[cfg(target_os = "macos")]
pub use mac::print_pdf;
#[cfg(windows)]
pub use win::print_pdf;
/// Paper sizes round to whole points at worst.
#[cfg(not(target_os = "macos"))]
const ROUNDING: f64 = 0.5;
/// Whether a named paper, short side first, holds a page.
#[cfg(not(target_os = "macos"))]
fn holds((across, down): (f64, f64), (short, long): (f64, f64)) -> bool {
// A named paper may run 2 mm over, never under.
let suits = |paper: f64, page: f64| paper >= page - ROUNDING && paper - page <= 2.0 * 72.0 / 25.4;
suits(across, short) && suits(down, long)
}
/// One paper holding every page, turned as needed.
#[cfg(any(target_os = "macos", test))]
fn paper_for(sizes: &[(f64, f64)]) -> Option<(f64, f64)> {
let long = sizes.iter().map(|(w, h)| w.max(*h)).fold(None, |most: Option<f64>, side| Some(most.map_or(side, |m| m.max(side))))?;
let short = sizes.iter().map(|(w, h)| w.min(*h)).fold(0.0, f64::max);
@@ -87,10 +40,16 @@ fn paper_for(sizes: &[(f64, f64)]) -> Option<(f64, f64)> {
Some(if wide { (long, short) } else { (short, long) })
}
/// Printing needs a system panel this platform build lacks.
/// Runs `work` on its own thread, off the app's.
#[cfg(not(target_os = "macos"))]
pub fn print_pdf(_: &Path) -> anyhow::Result<bool> {
anyhow::bail!("printing is not built on this platform")
async fn off<T: Send + 'static>(work: impl FnOnce() -> anyhow::Result<T> + Send + 'static) -> anyhow::Result<T> {
let (sender, answer) = futures::channel::oneshot::channel();
std::thread::Builder::new().name("ely-print".into()).spawn(move || {
if sender.send(work()).is_err() {
log::warn!("print: the job ended after its caller left");
}
})?;
answer.await.map_err(|_| anyhow::anyhow!("the print thread ended without an answer"))?
}
#[cfg(test)]
+187
View File
@@ -0,0 +1,187 @@
//! Linux: the desktop portal's print dialog, over D-Bus.
use std::{
collections::HashMap,
fs::File,
io::{Seek as _, Write as _},
os::fd::{AsFd, FromRawFd as _},
path::Path,
sync::atomic::{AtomicU32, Ordering},
};
use anyhow::{Context as _, anyhow, bail};
use futures::StreamExt as _;
use lopdf::Document;
use serde::Serialize;
use zbus::{
Connection, Proxy,
zvariant::{DynamicType, Fd, OwnedObjectPath, OwnedValue, Value},
};
const TITLE: &str = "Ely Office";
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
const PORTAL: &str = "/org/freedesktop/portal/desktop";
/// Standard papers: name, label, width and height in millimetres.
const FORMS: [(&str, &str, f64, f64); 6] = [
("iso_a3", "A3", 297.0, 420.0),
("iso_a4", "A4", 210.0, 297.0),
("iso_a5", "A5", 148.0, 210.0),
("iso_b5", "B5", 176.0, 250.0),
("na_letter", "US Letter", 215.9, 279.4),
("na_legal", "US Legal", 215.9, 355.6),
];
type Options = HashMap<&'static str, Value<'static>>;
mod choices;
mod sheets;
/// Shows the portal's print dialog for `pdf`; false when cancelled.
pub async fn print_pdf(pdf: &Path) -> anyhow::Result<bool> {
let sizes = {
let pdf = pdf.to_owned();
super::off(move || sizes(&pdf)).await?
};
let paper = super::paper_for(&sizes).ok_or_else(|| anyhow!("{} has no pages", pdf.display()))?;
log::info!("print: paper {}x{} points", paper.0, paper.1);
let bus = Connection::session().await.context("no session bus for the print portal")?;
let portal = Proxy::new(&bus, DESKTOP, PORTAL, "org.freedesktop.portal.Print").await?;
let settings = Options::new();
let prepared = request(&bus, &portal, "PreparePrint", |mut options| {
// We hand over PDF; the dialog must not offer others.
options.insert("supported_output_file_formats", Value::from(vec!["pdf"]));
("", TITLE, settings, page_setup(paper), options)
})
.await?;
let Some(mut prepared) = prepared else {
log::info!("print: the dialog was cancelled");
return Ok(false);
};
let token = prepared.get("token").context("the portal gave no print token")?.downcast_ref::<u32>()?;
let mut part = |key: &str| -> anyhow::Result<HashMap<String, OwnedValue>> {
Ok(prepared.remove(key).with_context(|| format!("the portal gave no {key}"))?.try_into()?)
};
let choices = choices::chosen(&part("settings")?, &part("page-setup")?)?;
log::info!("print: {} copies, {}%, {} up", choices.copies, choices.scale * 100.0, choices.up);
// The portal prints our bytes as given: we lay out.
let laid = {
let pdf = pdf.to_owned();
super::off(move || sheets::compose(&pdf, &choices)).await?
};
let file = in_memory(&laid)?;
let fd = Fd::from(file.as_fd());
let printed = request(&bus, &portal, "Print", |mut options| {
options.insert("token", Value::from(token));
("", TITLE, fd, options)
})
.await?;
log::info!("print: the portal took the job, printed {}", printed.is_some());
Ok(printed.is_some())
}
/// Calls `method`, awaits its answer; None when cancelled.
async fn request<B: Serialize + DynamicType>(
bus: &Connection,
portal: &Proxy<'_>,
method: &str,
body: impl FnOnce(Options) -> B,
) -> anyhow::Result<Option<HashMap<String, OwnedValue>>> {
static NEXT: AtomicU32 = AtomicU32::new(0);
let token = format!("ely{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
let sender = bus.unique_name().context("the bus gave no name")?.trim_start_matches(':').replace('.', "_");
let path = format!("{PORTAL}/request/{sender}/{token}");
// Listen before asking: the answer may come first.
let request = Proxy::new(bus, DESKTOP, path.as_str(), "org.freedesktop.portal.Request").await?;
let mut answers = request.receive_signal("Response").await?;
let handle: OwnedObjectPath = portal.call(method, &body(Options::from([("handle_token", Value::from(token.clone()))]))).await?;
if handle.as_str() != path {
bail!("the portal answers at {handle}, not {path}");
}
let answer = answers.next().await.context("the portal closed the request")?;
let (code, results): (u32, HashMap<String, OwnedValue>) = answer.body().deserialize()?;
match code {
0 => Ok(Some(results)),
1 => Ok(None),
_ => bail!("the portal ended {method} ({code})"),
}
}
/// The portal's page setup for a paper, margins zero.
fn page_setup((width, height): (f64, f64)) -> Options {
let millimetres = |points: f64| points * 25.4 / 72.0;
let (short, long) = (millimetres(width.min(height)), millimetres(width.max(height)));
let points = |mm: f64| mm * 72.0 / 25.4;
let page = (width.min(height), width.max(height));
let (name, label) = match FORMS.iter().find(|(.., across, down)| super::holds((points(*across), points(*down)), page)) {
Some((name, label, ..)) => (name.to_string(), label.to_string()),
None => (format!("custom_{short:.0}x{long:.0}mm"), format!("{short:.0} × {long:.0} mm")),
};
let orientation = if width > height { "landscape" } else { "portrait" };
Options::from([
("Name", Value::from(name)),
("DisplayName", Value::from(label)),
("Width", Value::from(short)),
("Height", Value::from(long)),
("MarginTop", Value::from(0.0)),
("MarginBottom", Value::from(0.0)),
("MarginLeft", Value::from(0.0)),
("MarginRight", Value::from(0.0)),
("Orientation", Value::from(orientation)),
])
}
/// Each page's size in points, as it shows.
fn sizes(pdf: &Path) -> anyhow::Result<Vec<(f64, f64)>> {
let document = Document::load(pdf)?;
document.get_pages().into_values().map(|page| Ok(sheets::Shown::of(&document, page)?.size())).collect()
}
/// `bytes` in an unnamed file, read from its start.
fn in_memory(bytes: &[u8]) -> anyhow::Result<File> {
let fd = unsafe { libc::memfd_create(c"ely-print".as_ptr(), libc::MFD_CLOEXEC) };
if fd < 0 {
bail!("no file for the print job: {}", std::io::Error::last_os_error());
}
let mut file = unsafe { File::from_raw_fd(fd) };
file.write_all(bytes)?;
file.rewind()?;
Ok(file)
}
#[cfg(test)]
mod tests {
use super::{page_setup, sizes};
#[test]
fn a_known_paper_goes_by_its_name() {
let setup = page_setup((842.0, 595.0));
let text = |key: &str| setup[key].downcast_ref::<&str>().expect("text");
let number = |key: &str| setup[key].downcast_ref::<f64>().expect("a number").round();
assert_eq!((text("Name"), text("Orientation")), ("iso_a4", "landscape"));
assert_eq!((number("Width"), number("Height"), number("MarginLeft")), (210.0, 297.0, 0.0));
let custom = page_setup((283.5, 425.2));
assert_eq!(custom["Name"].downcast_ref::<&str>().expect("text"), "custom_100x150mm");
assert_eq!(custom["Orientation"].downcast_ref::<&str>().expect("text"), "portrait");
}
#[test]
fn page_sizes_inherit_turn_and_scale() {
use lopdf::{Document, Object, dictionary};
let mut document = Document::with_version("1.7");
let tree = document.new_object_id();
let media: Vec<Object> = vec![0.into(), 0.into(), 595.into(), 842.into()];
let upright = document.add_object(dictionary! { "Type" => "Page", "Parent" => tree });
let turned = document.add_object(dictionary! { "Type" => "Page", "Parent" => tree, "Rotate" => 90 });
let cropped = document.add_object(dictionary! { "Type" => "Page", "Parent" => tree, "CropBox" => vec![10.into(), 10.into(), 110.into(), 60.into()] });
let large = document.add_object(dictionary! { "Type" => "Page", "Parent" => tree, "UserUnit" => 2 });
let kids: Vec<Object> = vec![upright.into(), turned.into(), cropped.into(), large.into()];
document.objects.insert(tree, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => 4, "MediaBox" => media }.into());
let catalog = document.add_object(dictionary! { "Type" => "Catalog", "Pages" => tree });
document.trailer.set("Root", catalog);
let folder = tempfile::tempdir().expect("a folder");
let pdf = folder.path().join("pages.pdf");
document.save(&pdf).expect("saves");
assert_eq!(sizes(&pdf).expect("reads"), [(595.0, 842.0), (842.0, 595.0), (100.0, 50.0), (1190.0, 1684.0)]);
}
}
@@ -0,0 +1,245 @@
//! What the portal's dialog chose: its settings and page setup.
use std::collections::HashMap;
use anyhow::{Context as _, bail};
use zbus::zvariant::OwnedValue;
type Map = HashMap<String, OwnedValue>;
/// Which sheets print, counting from one.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Set {
All,
Even,
Odd,
}
/// How several pages fill one sheet.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Order {
pub across_first: bool,
pub leftward: bool,
pub upward: bool,
}
/// The dialog's choices; the app applies every one.
#[derive(Debug, PartialEq)]
pub(super) struct Choices {
/// Pages from zero, both ends kept; None is every page.
pub ranges: Option<Vec<(u32, u32)>>,
pub set: Set,
pub reverse: bool,
pub copies: u32,
pub collate: bool,
pub scale: f64,
pub up: u32,
pub order: Order,
/// The sheet as it lies, in points.
pub sheet: (f64, f64),
pub upside_down: bool,
}
/// GTK's settings and page setup, as the portal passes them.
pub(super) fn chosen(settings: &Map, setup: &Map) -> anyhow::Result<Choices> {
let ranges = match text(settings, "print-pages")?.unwrap_or("all") {
"all" => None,
"ranges" => Some(ranges(text(settings, "page-ranges")?.context("the print dialog gave no page ranges")?)?),
other => bail!("the print dialog chose the {other} pages; Ely offers none"),
};
let set = match text(settings, "page-set")?.unwrap_or("all") {
"all" => Set::All,
"even" => Set::Even,
"odd" => Set::Odd,
other => bail!("the print dialog chose the page set {other}"),
};
// GTK's CUPS backend sends even or odd to CUPS.
let set = match settings.contains_key("output-uri") || settings.contains_key("lpr-commandline") {
true => set,
false => Set::All,
};
let scale = number(settings, "scale", 100.0)? / 100.0;
if !(scale.is_finite() && scale > 0.0) {
bail!("the print dialog chose a scale of {}%", scale * 100.0);
}
let order = order(text(settings, "number-up-layout")?.unwrap_or("lrtb"))?;
let millimetres = |key: &str| -> anyhow::Result<f64> {
let value = setup.get(key).with_context(|| format!("the page setup gives no {key}"))?;
Ok(value.downcast_ref::<f64>().with_context(|| format!("the page setup's {key} is no number"))? * 72.0 / 25.4)
};
let (width, height) = (millimetres("Width")?, millimetres("Height")?);
let way = setup.get("Orientation").context("the page setup gives no orientation")?;
let (sheet, upside_down) = match way.downcast_ref::<&str>().context("the page setup's orientation is no text")? {
"portrait" => ((width, height), false),
"landscape" => ((height, width), false),
"reverse-portrait" => ((width, height), true),
"reverse-landscape" => ((height, width), true),
other => bail!("the page setup's orientation is {other}"),
};
Ok(Choices {
ranges,
set,
reverse: flag(settings, "reverse", false)?,
copies: count(settings, "n-copies")?,
collate: flag(settings, "collate", true)?,
scale,
up: count(settings, "number-up")?,
order,
sheet,
upside_down,
})
}
/// GTK's ranges, "0-2,4" from zero; end -1 runs on.
fn ranges(text: &str) -> anyhow::Result<Vec<(u32, u32)>> {
let page = |part: &str| part.trim().parse::<u32>().with_context(|| format!("the page range {text} is unreadable"));
let ranges = text
.split(',')
.filter(|part| !part.trim().is_empty())
.map(|part| {
let (from, to) = match part.split_once('-') {
Some((from, to)) if to.trim() == "-1" => (page(from)?, u32::MAX),
Some((from, to)) => (page(from)?, page(to)?),
None => (page(part)?, page(part)?),
};
if from > to {
bail!("the page range {text} runs backwards");
}
Ok((from, to))
})
.collect::<anyhow::Result<Vec<_>>>()?;
if ranges.is_empty() {
bail!("the page ranges are empty");
}
Ok(ranges)
}
/// GTK's n-up nick, such as "lrtb": first way, then next.
fn order(nick: &str) -> anyhow::Result<Order> {
let (first, then) = nick.split_at_checked(2).with_context(|| format!("the n-up layout {nick} is unknown"))?;
let across = |way: &str| matches!(way, "lr" | "rl");
let down = |way: &str| matches!(way, "tb" | "bt");
if !(across(first) && down(then) || down(first) && across(then)) {
bail!("the n-up layout {nick} is unknown");
}
Ok(Order { across_first: across(first), leftward: nick.contains("rl"), upward: nick.contains("bt") })
}
fn text<'a>(settings: &'a Map, key: &str) -> anyhow::Result<Option<&'a str>> {
let value = settings.get(key).map(|value| value.downcast_ref::<&str>());
value.transpose().with_context(|| format!("the print setting {key} is no text"))
}
fn number(settings: &Map, key: &str, unset: f64) -> anyhow::Result<f64> {
match text(settings, key)? {
Some(text) => text.trim().parse().with_context(|| format!("the print setting {key} is {text}")),
None => Ok(unset),
}
}
/// A whole number, at least one; one when unset.
fn count(settings: &Map, key: &str) -> anyhow::Result<u32> {
let count = match text(settings, key)? {
Some(text) => text.trim().parse().with_context(|| format!("the print setting {key} is {text}"))?,
None => 1,
};
if count == 0 {
bail!("the print setting {key} is zero");
}
Ok(count)
}
fn flag(settings: &Map, key: &str, unset: bool) -> anyhow::Result<bool> {
match text(settings, key)? {
None => Ok(unset),
Some("true") => Ok(true),
Some("false") => Ok(false),
Some(other) => bail!("the print setting {key} is {other}"),
}
}
#[cfg(test)]
mod tests {
use zbus::zvariant::{OwnedValue, Value};
use super::{Choices, Map, Order, Set, chosen};
fn map(pairs: &[(&str, Value<'static>)]) -> Map {
pairs.iter().map(|(key, value)| (key.to_string(), OwnedValue::try_from(value.clone()).expect("owned"))).collect()
}
fn a4(orientation: &'static str) -> Map {
map(&[("Width", Value::from(210.0)), ("Height", Value::from(297.0)), ("Orientation", Value::from(orientation))])
}
#[test]
fn unset_settings_print_everything_once() {
let choices = chosen(&Map::new(), &a4("portrait")).expect("reads");
let order = Order { across_first: true, leftward: false, upward: false };
let (width, height) = (210.0 * 72.0 / 25.4, 297.0 * 72.0 / 25.4);
assert_eq!(
choices,
Choices {
ranges: None,
set: Set::All,
reverse: false,
copies: 1,
collate: true,
scale: 1.0,
up: 1,
order,
sheet: (width, height),
upside_down: false
}
);
}
#[test]
fn gtk_settings_read_as_given() {
let settings = map(&[
("print-pages", Value::from("ranges")),
("page-ranges", Value::from("0-1, 4,7--1")),
("page-set", Value::from("even")),
("reverse", Value::from("true")),
("n-copies", Value::from("3")),
("collate", Value::from("false")),
("scale", Value::from("50")),
("number-up", Value::from("4")),
("number-up-layout", Value::from("btrl")),
("output-uri", Value::from("file:///tmp/out.pdf")),
]);
let choices = chosen(&settings, &a4("reverse-landscape")).expect("reads");
assert_eq!(choices.ranges, Some(vec![(0, 1), (4, 4), (7, u32::MAX)]));
assert_eq!((choices.set, choices.reverse, choices.copies, choices.collate), (Set::Even, true, 3, false));
assert_eq!((choices.scale, choices.up), (0.5, 4));
assert_eq!(choices.order, Order { across_first: false, leftward: true, upward: true });
assert_eq!((choices.sheet.0.round(), choices.sheet.1.round(), choices.upside_down), (842.0, 595.0, true));
}
#[test]
fn even_pages_are_ours_only_where_cups_leaves_them() {
let set = |backend: &[(&str, Value<'static>)]| {
let mut pairs = vec![("page-set", Value::from("even"))];
pairs.extend_from_slice(backend);
chosen(&map(&pairs), &a4("portrait")).expect("reads").set
};
// GTK's CUPS backend sends cups-page-set itself.
assert_eq!(set(&[("printer", Value::from("Office"))]), Set::All);
assert_eq!(set(&[("output-uri", Value::from("file:///tmp/out.pdf"))]), Set::Even);
assert_eq!(set(&[("lpr-commandline", Value::from("lpr"))]), Set::Even);
}
#[test]
fn what_cannot_be_honoured_fails() {
let bad = |pairs: &[(&str, Value<'static>)]| chosen(&map(pairs), &a4("portrait")).is_err();
assert!(bad(&[("print-pages", Value::from("selection"))]));
assert!(bad(&[("print-pages", Value::from("ranges")), ("page-ranges", Value::from("two"))]));
assert!(bad(&[("print-pages", Value::from("ranges")), ("page-ranges", Value::from("3-1"))]));
assert!(chosen(&Map::new(), &a4("reverse_portrait")).is_err(), "GTK writes reverse-portrait");
assert!(bad(&[("scale", Value::from("0"))]));
assert!(bad(&[("n-copies", Value::from("0"))]));
assert!(bad(&[("number-up-layout", Value::from("lrrl"))]));
assert!(bad(&[("reverse", Value::from(1u32))]));
assert!(chosen(&Map::new(), &map(&[("Width", Value::from(210.0))])).is_err(), "no height");
}
}
@@ -0,0 +1,388 @@
//! The chosen pages laid on the chosen sheets, as PDF.
use std::collections::BTreeMap;
use anyhow::{Context as _, anyhow, bail};
use lopdf::{Dictionary, Document, Object, ObjectId, Stream, dictionary};
use super::choices::{Choices, Set};
use crate::print::ROUNDING;
/// Catalog entries that name pages no longer there.
const STALE: [&str; 4] = ["Outlines", "OpenAction", "AcroForm", "PageLabels"];
/// A PDF matrix, `[a b c d e f]`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Matrix(pub [f64; 6]);
impl Matrix {
/// This, then `next`.
fn then(self, next: Matrix) -> Matrix {
let ([a, b, c, d, e, f], [g, h, i, j, k, l]) = (self.0, next.0);
Matrix([a * g + b * i, a * h + b * j, c * g + d * i, c * h + d * j, e * g + f * i + k, e * h + f * j + l])
}
pub(super) fn apply(self, (x, y): (f64, f64)) -> (f64, f64) {
let [a, b, c, d, e, f] = self.0;
(a * x + c * y + e, b * x + d * y + f)
}
}
/// A page's box, unit and turn.
pub(super) struct Shown {
corners: [f64; 4],
unit: f64,
turn: i64,
}
impl Shown {
pub(super) fn of(document: &Document, page: ObjectId) -> anyhow::Result<Self> {
let corners = |bounds: &Object| -> anyhow::Result<[f64; 4]> {
let numbers = bounds.as_array()?.iter().map(|n| Ok(f64::from(n.as_float()?))).collect::<anyhow::Result<Vec<_>>>()?;
let [x0, y0, x1, y1] = numbers[..] else {
bail!("a page box has {} numbers", numbers.len());
};
Ok([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
};
let media = corners(inherited(document, page, b"MediaBox")?.ok_or_else(|| anyhow!("a page has no media box"))?)?;
// A crop box is cut to the media box.
let [x0, y0, x1, y1] = match inherited(document, page, b"CropBox")? {
Some(crop) => {
let crop = corners(crop)?;
[crop[0].max(media[0]), crop[1].max(media[1]), crop[2].min(media[2]), crop[3].min(media[3])]
}
None => media,
};
if x1 <= x0 || y1 <= y0 {
bail!("a page's crop box lies outside its media box");
}
// UserUnit is the page's own, never inherited.
let unit = match document.get_dictionary(page)?.get(b"UserUnit") {
Ok(unit) => f64::from(document.dereference(unit)?.1.as_float()?),
Err(_) => 1.0,
};
let turn = inherited(document, page, b"Rotate")?.map_or(Ok(0), Object::as_i64)?.rem_euclid(360);
if !(unit > 0.0 && turn % 90 == 0) {
bail!("a page has a unit of {unit} or a turn of {turn}");
}
Ok(Self { corners: [x0, y0, x1, y1], unit, turn })
}
/// Width and height in points, as it shows.
pub(super) fn size(&self) -> (f64, f64) {
let [x0, y0, x1, y1] = self.corners;
let (width, height) = ((x1 - x0) * self.unit, (y1 - y0) * self.unit);
// A page turned a quarter shows its other side up.
if self.turn % 180 == 90 { (height, width) } else { (width, height) }
}
/// From the page's own space to its shown points.
fn matrix(&self) -> Matrix {
let [x0, y0, x1, y1] = self.corners;
let (w, h) = ((x1 - x0) * self.unit, (y1 - y0) * self.unit);
let unit = Matrix([self.unit, 0.0, 0.0, self.unit, -x0 * self.unit, -y0 * self.unit]);
// Rotate turns the page clockwise as shown.
unit.then(Matrix(match self.turn {
90 => [0.0, -1.0, 1.0, 0.0, 0.0, w],
180 => [-1.0, 0.0, 0.0, -1.0, w, h],
270 => [0.0, 1.0, -1.0, 0.0, h, 0.0],
_ => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
}))
}
}
/// `key` on the page or its nearest tree node.
pub(super) fn inherited<'a>(document: &'a Document, page: ObjectId, key: &[u8]) -> anyhow::Result<Option<&'a Object>> {
let mut node = document.get_dictionary(page)?;
// A loop in the tree must not hang.
for _ in 0..64 {
if let Ok(value) = node.get(key) {
return Ok(Some(document.dereference(value)?.1));
}
let Ok(parent) = node.get(b"Parent") else {
return Ok(None);
};
node = document.dereference(parent)?.1.as_dict()?;
}
bail!("the page tree is deeper than 64")
}
/// The PDF at `pdf`, laid out as `choices` asks.
pub(super) fn compose(pdf: &std::path::Path, choices: &Choices) -> anyhow::Result<Vec<u8>> {
let mut document = Document::load(pdf)?;
let pages: Vec<ObjectId> = document.get_pages().into_values().collect();
let picked = picked(choices, pages.len())?;
let mut forms = BTreeMap::new();
for &n in &picked {
if let std::collections::btree_map::Entry::Vacant(slot) = forms.entry(n) {
slot.insert(form(&mut document, pages[n])?);
}
}
let shown = picked.iter().map(|&n| Shown::of(&document, pages[n])).collect::<anyhow::Result<Vec<_>>>()?;
let laid = lay(choices, &picked, &shown)?;
let tree = document.new_object_id();
let (width, height) = choices.sheet;
let mut sheets = Vec::with_capacity(laid.len());
for placed in &laid {
let mut drawn = String::new();
let mut objects = Dictionary::new();
for &(n, Matrix([a, b, c, d, e, f])) in placed {
drawn.push_str(&format!("q {a:.4} {b:.4} {c:.4} {d:.4} {e:.4} {f:.4} cm /P{n} Do Q\n"));
objects.set(format!("P{n}"), forms[&n]);
}
let mut stream = Stream::new(Dictionary::new(), drawn.into_bytes());
stream.compress()?;
let contents = document.add_object(stream);
let media: Vec<Object> = vec![0.into(), 0.into(), (width as f32).into(), (height as f32).into()];
let mut sheet = dictionary! {
"Type" => "Page",
"Parent" => tree,
"MediaBox" => media,
"Resources" => dictionary! { "XObject" => objects },
"Contents" => contents,
};
if choices.upside_down {
sheet.set("Rotate", 180);
}
sheets.push(sheet);
}
let kids: Vec<Object> = arrange(choices, sheets.len())?.into_iter().map(|n| document.add_object(sheets[n].clone()).into()).collect();
let count = kids.len() as i64;
document.objects.insert(tree, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => count }.into());
let root = document.trailer.get(b"Root")?.as_reference()?;
let catalog = document.get_object_mut(root)?.as_dict_mut()?;
catalog.set("Pages", tree);
for key in STALE {
catalog.remove(key.as_bytes());
}
document.prune_objects();
let mut bytes = Vec::new();
document.save_to(&mut bytes)?;
Ok(bytes)
}
/// The chosen pages, from zero, in the dialog's order.
fn picked(choices: &Choices, count: usize) -> anyhow::Result<Vec<usize>> {
let last = count.checked_sub(1).context("the document has no pages")? as u32;
let picked: Vec<usize> = match &choices.ranges {
None => (0..count).collect(),
Some(ranges) => ranges.iter().filter(|(from, _)| *from <= last).flat_map(|&(from, to)| from as usize..=to.min(last) as usize).collect(),
};
if picked.is_empty() {
bail!("none of the chosen pages are in the document");
}
Ok(picked)
}
/// Sheets in print order: set, reverse, then copies.
fn arrange(choices: &Choices, sheets: usize) -> anyhow::Result<Vec<usize>> {
let mut order: Vec<usize> = (0..sheets)
.filter(|n| match choices.set {
Set::All => true,
Set::Even => n % 2 == 1,
Set::Odd => n % 2 == 0,
})
.collect();
if order.is_empty() {
bail!("the chosen page set leaves no sheet to print");
}
if choices.reverse {
order.reverse();
}
let copies = choices.copies as usize;
Ok(match choices.collate {
true => order.iter().copied().cycle().take(order.len() * copies).collect(),
false => order.into_iter().flat_map(|n| std::iter::repeat_n(n, copies)).collect(),
})
}
/// Each sheet's pages: which page, and where it goes.
fn lay(choices: &Choices, picked: &[usize], shown: &[Shown]) -> anyhow::Result<Vec<Vec<(usize, Matrix)>>> {
let (width, height) = choices.sheet;
if choices.up == 1 {
return picked
.iter()
.zip(shown)
.map(|(&n, page)| {
let (w, h) = page.size();
let k = choices.scale;
let fits = |across: f64, down: f64| k * across <= width + ROUNDING && k * down <= height + ROUNDING;
// Placed from the sheet's top left; turned left if needed.
let place = match (fits(w, h), fits(h, w)) {
(true, _) => Matrix([k, 0.0, 0.0, k, 0.0, height - k * h]),
(false, true) => Matrix([0.0, k, -k, 0.0, k * h, height - k * w]),
(false, false) => bail!(
"page {} at {:.0}% is larger than the {:.0} × {:.0} mm paper",
n + 1,
k * 100.0,
width * 25.4 / 72.0,
height * 25.4 / 72.0
),
};
Ok(vec![(n, page.matrix().then(place))])
})
.collect();
}
let (cols, rows) = grid(choices.up, choices.sheet, shown);
let (cell_w, cell_h) = (width / f64::from(cols), height / f64::from(rows));
let mut sheets = Vec::new();
for (pages, looks) in picked.chunks(choices.up as usize).zip(shown.chunks(choices.up as usize)) {
let placed = pages.iter().zip(looks).enumerate().map(|(slot, (&n, page))| {
let (col, row) = cell(slot as u32, (cols, rows), choices);
let (w, h) = page.size();
let k = (cell_w / w).min(cell_h / h) * choices.scale;
// Centred in its cell.
let left = f64::from(col) * cell_w + (cell_w - k * w) / 2.0;
let bottom = height - f64::from(row + 1) * cell_h + (cell_h - k * h) / 2.0;
(n, page.matrix().then(Matrix([k, 0.0, 0.0, k, left, bottom])))
});
sheets.push(placed.collect());
}
Ok(sheets)
}
/// Columns and rows for `up` pages; the largest fit best.
fn grid(up: u32, (width, height): (f64, f64), shown: &[Shown]) -> (u32, u32) {
let (w, h) = shown.iter().map(Shown::size).fold((0.0, 0.0), |(a, b), (w, h)| (w.max(a), h.max(b)));
let fit = |(cols, rows): (u32, u32)| (width / f64::from(cols) / w).min(height / f64::from(rows) / h);
let grids = (1..=up).filter(|&cols| up.is_multiple_of(cols)).map(|cols| (cols, up / cols));
grids.fold((up, 1), |best, grid| if fit(grid) > fit(best) { grid } else { best })
}
/// Slot `n`'s column and row, by the dialog's n-up order.
fn cell(n: u32, (cols, rows): (u32, u32), choices: &Choices) -> (u32, u32) {
let order = choices.order;
let (col, row) = if order.across_first { (n % cols, n / cols) } else { (n / rows, n % rows) };
(if order.leftward { cols - 1 - col } else { col }, if order.upward { rows - 1 - row } else { row })
}
/// The page as a form, printable annotations drawn on top.
fn form(document: &mut Document, page: ObjectId) -> anyhow::Result<ObjectId> {
let mut drawn = b"q\n".to_vec();
for content in document.get_page_contents(page) {
drawn.extend(document.get_object(content)?.as_stream()?.decompressed_content()?);
drawn.push(b'\n');
}
drawn.extend(b"Q\n");
let mut resources = match inherited(document, page, b"Resources")? {
Some(resources) => resources.as_dict()?.clone(),
None => Dictionary::new(),
};
let mut objects = match resources.get(b"XObject") {
Ok(objects) => document.dereference(objects)?.1.as_dict()?.clone(),
Err(_) => Dictionary::new(),
};
let mut n = 0;
for (look, place) in appearances(document, page)? {
// Names the page already uses stay its own.
while objects.has(format!("ElyAnnot{n}").as_bytes()) {
n += 1;
}
let [a, b, c, d, e, f] = place.0;
drawn.extend(format!("q {a:.4} {b:.4} {c:.4} {d:.4} {e:.4} {f:.4} cm /ElyAnnot{n} Do Q\n").into_bytes());
objects.set(format!("ElyAnnot{n}"), look);
let look = document.get_object_mut(look)?.as_stream_mut()?;
if look.dict.get(b"Subtype").is_err() {
look.dict.set("Subtype", "Form");
}
}
resources.set("XObject", objects);
let [x0, y0, x1, y1] = Shown::of(document, page)?.corners;
let bounds: Vec<Object> = [x0, y0, x1, y1].into_iter().map(|n| (n as f32).into()).collect();
let mut dict = dictionary! { "Type" => "XObject", "Subtype" => "Form", "BBox" => bounds, "Resources" => resources };
if let Some(group) = inherited(document, page, b"Group")? {
dict.set("Group", group.clone());
}
let mut stream = Stream::new(dict, drawn);
stream.compress()?;
Ok(document.add_object(stream))
}
/// Printable annotations' looks, and where each goes on the page.
fn appearances(document: &Document, page: ObjectId) -> anyhow::Result<Vec<(ObjectId, Matrix)>> {
let Ok(annots) = document.get_dictionary(page)?.get(b"Annots") else {
return Ok(Vec::new());
};
let turn = Shown::of(document, page)?.turn;
let mut looks = Vec::new();
for annot in document.dereference(annots)?.1.as_array()? {
let annot = document.dereference(annot)?.1.as_dict()?;
let flags = annot.get(b"F").and_then(Object::as_i64).unwrap_or(0);
// Hidden, or not marked for print: left out.
if flags & 2 != 0 || flags & 4 == 0 {
continue;
}
let Some(look) = appearance(document, annot)? else {
continue;
};
let stream = document.get_object(look)?.as_stream()?;
let numbers = |object: &Object| -> anyhow::Result<Vec<f64>> {
document.dereference(object)?.1.as_array()?.iter().map(|n| Ok(f64::from(n.as_float()?))).collect()
};
let bounds = numbers(stream.dict.get(b"BBox")?)?;
let rect = numbers(annot.get(b"Rect")?)?;
let own = match stream.dict.get(b"Matrix") {
Ok(matrix) => Matrix(numbers(matrix)?.try_into().map_err(|_| anyhow!("an appearance matrix is not six numbers"))?),
Err(_) => Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
};
let ([bx0, by0, bx1, by1], [rx0, ry0, rx1, ry1]) = (four(&bounds)?, four(&rect)?);
// The look's box, through its matrix, fills the rectangle.
let corners = [(bx0, by0), (bx1, by0), (bx0, by1), (bx1, by1)].map(|corner| own.apply(corner));
let (lx0, ly0) = corners.iter().fold((f64::MAX, f64::MAX), |(x, y), &(a, b)| (x.min(a), y.min(b)));
let (lx1, ly1) = corners.iter().fold((f64::MIN, f64::MIN), |(x, y), &(a, b)| (x.max(a), y.max(b)));
if lx1 - lx0 <= 0.0 || ly1 - ly0 <= 0.0 {
continue;
}
let (sx, sy) = ((rx1 - rx0).abs() / (lx1 - lx0), (ry1 - ry0).abs() / (ly1 - ly0));
let mut place = Matrix([sx, 0.0, 0.0, sy, rx0.min(rx1) - sx * lx0, ry0.min(ry1) - sy * ly0]);
// NoRotate: upright as shown, turned about its top left.
if flags & 16 != 0 {
place = place.then(unturned(turn, (rx0.min(rx1), ry0.max(ry1))));
}
looks.push((look, place));
}
Ok(looks)
}
/// Turns back a page's `turn` about `(x, y)`.
fn unturned(turn: i64, (x, y): (f64, f64)) -> Matrix {
let (cos, sin) = match turn {
90 => (0.0, 1.0),
180 => (-1.0, 0.0),
270 => (0.0, -1.0),
_ => (1.0, 0.0),
};
Matrix([cos, sin, -sin, cos, x - cos * x + sin * y, y - sin * x - cos * y])
}
fn four(numbers: &[f64]) -> anyhow::Result<[f64; 4]> {
numbers.try_into().map_err(|_| anyhow!("an annotation's box is not four numbers"))
}
/// An annotation's normal look in its state; None draws nothing.
fn appearance(document: &Document, annot: &Dictionary) -> anyhow::Result<Option<ObjectId>> {
let subtype = annot.get(b"Subtype")?.as_name()?;
let normal = match annot.get(b"AP") {
Ok(looks) => document.dereference(looks)?.1.as_dict()?.get(b"N").ok(),
Err(_) => None,
};
let Some(normal) = normal else {
// Links and popups have no look of their own.
if matches!(subtype, b"Link" | b"Popup") {
return Ok(None);
}
bail!("a printable {} annotation has no appearance", String::from_utf8_lossy(subtype));
};
if let Ok(id) = normal.as_reference()
&& document.get_object(id)?.as_stream().is_ok()
{
return Ok(Some(id));
}
// A look per state: the one /AS names, if any.
let states = document.dereference(normal)?.1.as_dict()?;
let state = annot.get(b"AS").and_then(Object::as_name).context("an annotation with states names none")?;
Ok(states.get(state).ok().map(Object::as_reference).transpose()?)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,307 @@
use lopdf::{Document, Object, Stream, content::Content, dictionary};
use super::{Choices, Set, Shown, compose};
use crate::print::linux::choices::Order;
/// Pages with these boxes, each with these extra entries.
fn pages(boxes: &[([f32; 4], lopdf::Dictionary)]) -> (tempfile::TempDir, std::path::PathBuf) {
let mut document = Document::with_version("1.7");
let tree = document.new_object_id();
let kids: Vec<Object> = boxes
.iter()
.map(|(corners, extra)| {
let content = document.add_object(Stream::new(dictionary! {}, b"0 g 0 0 10 10 re f".to_vec()));
let media: Vec<Object> = corners.iter().map(|&n| n.into()).collect();
let mut page = dictionary! { "Type" => "Page", "Parent" => tree, "MediaBox" => media, "Contents" => content };
page.extend(extra);
document.add_object(page).into()
})
.collect();
let count = kids.len() as i64;
document.objects.insert(tree, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => count }.into());
let catalog = document.add_object(dictionary! { "Type" => "Catalog", "Pages" => tree });
document.trailer.set("Root", catalog);
let folder = tempfile::tempdir().expect("a folder");
let pdf = folder.path().join("pages.pdf");
document.save(&pdf).expect("saves");
(folder, pdf)
}
fn plain(box_: [f32; 4]) -> ([f32; 4], lopdf::Dictionary) {
(box_, dictionary! {})
}
fn choices(sheet: (f64, f64)) -> Choices {
Choices {
ranges: None,
set: Set::All,
reverse: false,
copies: 1,
collate: true,
scale: 1.0,
up: 1,
order: Order { across_first: true, leftward: false, upward: false },
sheet,
upside_down: false,
}
}
/// A sheet's size; each form's width and placed corners.
type Sheet = ((i64, i64), Vec<(i64, [i64; 4])>);
fn sheets(bytes: &[u8]) -> Vec<Sheet> {
let document = Document::load_mem(bytes).expect("a PDF");
let round = |n: f64| n.round() as i64;
document
.get_pages()
.into_values()
.map(|page| {
let dict = document.get_dictionary(page).expect("a page");
let media: Vec<f64> = dict.get(b"MediaBox").and_then(Object::as_array).expect("a box").iter().map(|n| f64::from(n.as_float().expect("a number"))).collect();
let content = Content::decode(&document.get_page_content(page)).expect("content");
let objects = dict.get(b"Resources").and_then(Object::as_dict).and_then(|r| r.get(b"XObject")).and_then(Object::as_dict).expect("forms");
let mut matrix = [1.0; 6];
let mut placed = Vec::new();
for op in content.operations {
match op.operator.as_str() {
"cm" => matrix = op.operands.iter().map(|n| f64::from(n.as_float().expect("a number"))).collect::<Vec<_>>().try_into().expect("six"),
"Do" => {
let form = objects.get(op.operands[0].as_name().expect("a name")).and_then(Object::as_reference).expect("a form");
let bounds: Vec<f64> = document.get_object(form).and_then(Object::as_stream).expect("a stream").dict.get(b"BBox").and_then(Object::as_array).expect("a box").iter().map(|n| f64::from(n.as_float().expect("a number"))).collect();
let m = super::Matrix(matrix);
let corners = [(bounds[0], bounds[1]), (bounds[2], bounds[3])].map(|corner| m.apply(corner));
let (x0, x1) = (corners[0].0.min(corners[1].0), corners[0].0.max(corners[1].0));
let (y0, y1) = (corners[0].1.min(corners[1].1), corners[0].1.max(corners[1].1));
placed.push((round(bounds[2] - bounds[0]), [x0, y0, x1, y1].map(round)));
}
_ => {}
}
}
((round(media[2]), round(media[3])), placed)
})
.collect()
}
#[test]
fn a_user_unit_scales_the_page() {
let (_folder, pdf) = pages(&[([0.0, 0.0, 421.0, 297.5], dictionary! { "UserUnit" => 2 })]);
let document = Document::load(&pdf).expect("loads");
let page = document.get_pages()[&1];
assert_eq!(Shown::of(&document, page).expect("reads").size(), (842.0, 595.0));
// On A4 landscape it fills the sheet, from its corner.
let laid = sheets(&compose(&pdf, &choices((842.0, 595.0))).expect("composes"));
assert_eq!(laid, [((842, 595), vec![(421, [0, 0, 842, 595])])]);
}
#[test]
fn chosen_pages_print_scaled_from_the_top_left() {
let (_folder, pdf) = pages(&[plain([0.0, 0.0, 595.0, 842.0]), plain([0.0, 0.0, 420.0, 595.0]), plain([0.0, 0.0, 300.0, 300.0])]);
let only_second = Choices { ranges: Some(vec![(1, 1)]), scale: 0.5, ..choices((595.0, 842.0)) };
let laid = sheets(&compose(&pdf, &only_second).expect("composes"));
assert_eq!(laid, [((595, 842), vec![(420, [0, 545, 210, 842])])]);
}
#[test]
fn a_page_that_fits_only_turned_turns_left() {
let (_folder, pdf) = pages(&[plain([0.0, 0.0, 842.0, 595.0])]);
let bytes = compose(&pdf, &choices((595.0, 842.0))).expect("composes");
assert_eq!(sheets(&bytes), [((595, 842), vec![(842, [0, 0, 595, 842])])]);
// Its top left lands at the sheet's bottom left.
let document = Document::load_mem(&bytes).expect("a PDF");
let content = Content::decode(&document.get_page_content(document.get_pages()[&1])).expect("content");
let cm = content.operations.iter().find(|op| op.operator == "cm").expect("a matrix");
let m: Vec<f64> = cm.operands.iter().map(|n| f64::from(n.as_float().expect("a number"))).collect();
let corner = super::Matrix(m.try_into().expect("six")).apply((0.0, 595.0));
assert_eq!((corner.0.round(), corner.1.round()), (0.0, 0.0));
}
#[test]
fn a_crop_box_past_the_media_box_is_cut_to_it() {
let crop = |corners: [i64; 4]| -> Object { corners.map(Object::from).to_vec().into() };
let (_folder, pdf) = pages(&[([0.0, 0.0, 612.0, 792.0], dictionary! { "CropBox" => crop([0, 0, 700, 792]) })]);
// The form's box clips at 612: x 650 stays hidden.
let laid = sheets(&compose(&pdf, &choices((612.0, 792.0))).expect("composes"));
assert_eq!(laid, [((612, 792), vec![(612, [0, 0, 612, 792])])]);
let (_folder, pdf) = pages(&[([0.0, 0.0, 612.0, 792.0], dictionary! { "CropBox" => crop([700, 0, 800, 792]) })]);
assert!(compose(&pdf, &choices((612.0, 792.0))).is_err(), "nothing left to show");
}
#[test]
fn a_rotated_page_shows_turned() {
let (_folder, pdf) = pages(&[([0.0, 0.0, 595.0, 842.0], dictionary! { "Rotate" => 90 })]);
let document = Document::load(&pdf).expect("loads");
let shown = Shown::of(&document, document.get_pages()[&1]).expect("reads");
assert_eq!(shown.size(), (842.0, 595.0));
// Turned clockwise, the page's top left becomes its top right.
assert_eq!(shown.matrix().apply((0.0, 842.0)), (842.0, 595.0));
}
#[test]
fn too_large_a_page_fails() {
let (_folder, pdf) = pages(&[plain([0.0, 0.0, 1000.0, 600.0])]);
let error = compose(&pdf, &choices((595.0, 842.0))).expect_err("does not fit");
assert!(error.to_string().contains("page 1 at 100% is larger than the 210 × 297 mm paper"), "{error}");
let (_folder, pdf) = pages(&[plain([0.0, 0.0, 595.0, 842.0])]);
assert!(compose(&pdf, &Choices { scale: 2.0, ..choices((595.0, 842.0)) }).is_err(), "scaled past the paper");
// A4 is 595.28 points wide: 600 would lose its edge.
let a4 = (210.0 * 72.0 / 25.4, 297.0 * 72.0 / 25.4);
let (_folder, pdf) = pages(&[plain([0.0, 0.0, 600.0, 842.0])]);
assert!(compose(&pdf, &choices(a4)).is_err(), "a page 1.6 mm wider than A4");
// A4 in whole points still takes an A4 page.
let (_folder, pdf) = pages(&[plain([0.0, 0.0, a4.0 as f32, a4.1 as f32])]);
assert!(compose(&pdf, &choices((595.0, 842.0))).is_ok(), "rounding is no overflow");
}
#[test]
fn an_open_gtk_range_runs_to_the_last_page() {
use zbus::zvariant::{OwnedValue, Value};
let widths = [100.0, 110.0, 120.0];
let boxes: Vec<_> = widths.iter().map(|&w| plain([0.0, 0.0, w, 100.0])).collect();
let (_folder, pdf) = pages(&boxes);
let owned = |value: Value<'static>| OwnedValue::try_from(value).expect("owned");
let settings = [("print-pages", "ranges"), ("page-ranges", "1--1")].map(|(key, value)| (key.to_string(), owned(Value::from(value))));
let setup = [("Width", Value::from(210.0)), ("Height", Value::from(297.0)), ("Orientation", Value::from("portrait"))];
let setup = setup.map(|(key, value)| (key.to_string(), owned(value)));
let chosen = super::super::choices::chosen(&settings.into(), &setup.into()).expect("reads");
let printed: Vec<i64> = sheets(&compose(&pdf, &chosen).expect("composes")).iter().map(|sheet| sheet.1[0].0).collect();
assert_eq!(printed, [110, 120], "pages two and three");
}
#[test]
fn four_up_fills_cells_in_the_chosen_order() {
let sizes = [100.0, 110.0, 120.0, 130.0, 140.0];
let boxes: Vec<_> = sizes.iter().map(|&w| plain([0.0, 0.0, w, w * 1.5])).collect();
let (_folder, pdf) = pages(&boxes);
let up = Choices { up: 4, ..choices((400.0, 600.0)) };
let laid = sheets(&compose(&pdf, &up).expect("composes"));
assert_eq!(laid.len(), 2, "five pages, four a sheet");
let corner = |(width, [x0, _, _, y1]): &(i64, [i64; 4])| (*width, x0 / 200, (600 - y1) / 300);
let cells: Vec<_> = laid[0].1.iter().map(corner).collect();
assert_eq!(cells, [(100, 0, 0), (110, 1, 0), (120, 0, 1), (130, 1, 1)]);
// Every page fills its 200 × 300 cell, centred.
assert_eq!(laid[1].1, [(140, [0, 300, 200, 600])]);
let upward = Choices { order: Order { across_first: false, leftward: true, upward: true }, ..up };
let laid = sheets(&compose(&pdf, &upward).expect("composes"));
let cells: Vec<_> = laid[0].1.iter().map(corner).collect();
assert_eq!(cells, [(100, 1, 1), (110, 1, 0), (120, 0, 1), (130, 0, 0)]);
}
#[test]
fn sets_order_and_copies_choose_the_sheets() {
let widths = [100.0, 110.0, 120.0];
let boxes: Vec<_> = widths.iter().map(|&w| plain([0.0, 0.0, w, 100.0])).collect();
let (_folder, pdf) = pages(&boxes);
let order = |chosen: Choices| -> Vec<i64> { sheets(&compose(&pdf, &chosen).expect("composes")).iter().map(|sheet| sheet.1[0].0).collect() };
let base = choices((595.0, 842.0));
assert_eq!(order(Choices { set: Set::Odd, ..choices((595.0, 842.0)) }), [100, 120]);
assert_eq!(order(Choices { set: Set::Even, ..choices((595.0, 842.0)) }), [110]);
assert_eq!(order(Choices { reverse: true, ..choices((595.0, 842.0)) }), [120, 110, 100]);
assert_eq!(order(Choices { copies: 2, ..choices((595.0, 842.0)) }), [100, 110, 120, 100, 110, 120]);
assert_eq!(order(Choices { copies: 2, collate: false, ..base }), [100, 100, 110, 110, 120, 120]);
let none = compose(&pdf, &Choices { ranges: Some(vec![(5, 9)]), ..choices((595.0, 842.0)) });
assert!(none.is_err(), "no chosen page is in the document");
}
#[test]
fn printable_annotations_are_drawn_where_they_sit() {
let mut document = Document::with_version("1.7");
let look = document.add_object(Stream::new(
dictionary! { "Subtype" => "Form", "BBox" => vec![0.into(), 0.into(), 10.into(), 20.into()] },
b"0 g 0 0 10 20 re f".to_vec(),
));
let rect = |x: i64| -> Object { vec![x.into(), 100.into(), (x + 30).into(), 160.into()].into() };
let shown = dictionary! { "Subtype" => "Square", "F" => 4, "Rect" => rect(50), "AP" => dictionary! { "N" => look } };
let unprinted = dictionary! { "Subtype" => "Square", "F" => 0, "Rect" => rect(200), "AP" => dictionary! { "N" => look } };
let annots: Vec<Object> = vec![document.add_object(shown).into(), document.add_object(unprinted).into()];
let tree = document.new_object_id();
let content = document.add_object(Stream::new(dictionary! {}, Vec::new()));
let media: Vec<Object> = vec![0.into(), 0.into(), 595.into(), 842.into()];
let page = document.add_object(dictionary! { "Type" => "Page", "Parent" => tree, "MediaBox" => media, "Contents" => content, "Annots" => annots });
document.objects.insert(tree, dictionary! { "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1 }.into());
let looks = super::appearances(&document, page).expect("reads");
assert_eq!(looks.len(), 1, "only the printable one");
let (id, place) = looks[0];
assert_eq!(id, look);
assert_eq!((place.apply((0.0, 0.0)), place.apply((10.0, 20.0))), ((50.0, 100.0), (80.0, 160.0)));
}
/// One A4 page, turned `rotate`: its annotations and own forms.
fn annotated(rotate: i64, build: impl FnOnce(&mut Document) -> (Vec<lopdf::Dictionary>, lopdf::Dictionary)) -> (Document, lopdf::ObjectId) {
let mut document = Document::with_version("1.7");
let (annots, own) = build(&mut document);
let annots: Vec<Object> = annots.into_iter().map(|annot| document.add_object(annot).into()).collect();
let tree = document.new_object_id();
let content = document.add_object(Stream::new(dictionary! {}, b"/ElyAnnot0 Do".to_vec()));
let media: Vec<Object> = vec![0.into(), 0.into(), 595.into(), 842.into()];
let resources = dictionary! { "XObject" => own };
let page = dictionary! { "Type" => "Page", "Parent" => tree, "MediaBox" => media, "Contents" => content, "Annots" => annots, "Rotate" => rotate, "Resources" => resources };
let page = document.add_object(page);
document.objects.insert(tree, dictionary! { "Type" => "Pages", "Kids" => vec![page.into()], "Count" => 1 }.into());
(document, page)
}
fn square(document: &mut Document, flags: i64) -> lopdf::Dictionary {
let look = document.add_object(Stream::new(
dictionary! { "Subtype" => "Form", "BBox" => vec![0.into(), 0.into(), 100.into(), 80.into()] },
b"1 0 0 rg 0 0 100 80 re f".to_vec(),
));
let rect: Vec<Object> = vec![50.into(), 100.into(), 150.into(), 180.into()];
dictionary! { "Subtype" => "Square", "F" => flags, "Rect" => rect, "AP" => dictionary! { "N" => look } }
}
#[test]
fn a_no_rotate_annotation_stays_upright_about_its_top_left() {
let (document, page) = annotated(90, |document| (vec![square(document, 20)], dictionary! {}));
let (_, place) = super::appearances(&document, page).expect("reads")[0];
let shown = Shown::of(&document, page).expect("reads").matrix();
let at = |corner| {
let (x, y) = shown.apply(place.apply(corner));
(x.round(), y.round())
};
// Shown: its top left stays put, its top runs right.
assert_eq!(at((0.0, 80.0)), (180.0, 545.0));
assert_eq!(at((100.0, 80.0)), (280.0, 545.0));
assert_eq!(at((0.0, 0.0)), (180.0, 465.0));
// Without the flag, it turns with the page.
let (document, page) = annotated(90, |document| (vec![square(document, 4)], dictionary! {}));
let (_, place) = super::appearances(&document, page).expect("reads")[0];
let (x, y) = shown.apply(place.apply((0.0, 80.0)));
assert_eq!((x.round(), y.round()), (180.0, 545.0));
let (x, y) = shown.apply(place.apply((100.0, 80.0)));
assert_eq!((x.round(), y.round()), (180.0, 445.0));
}
#[test]
fn an_annotation_name_leaves_the_page_its_own() {
let mut blue = None;
let (mut document, page) = annotated(0, |document| {
let form = dictionary! { "Subtype" => "Form", "BBox" => vec![0.into(), 0.into(), 1.into(), 1.into()] };
let own = *blue.insert(document.add_object(Stream::new(form, b"0 0 1 rg".to_vec())));
(vec![square(document, 4)], dictionary! { "ElyAnnot0" => own })
});
let blue = blue.expect("made");
let form = super::form(&mut document, page).expect("a form");
let form = document.get_object(form).and_then(Object::as_stream).expect("a stream");
let objects = form.dict.get(b"Resources").and_then(Object::as_dict).and_then(|r| r.get(b"XObject")).and_then(Object::as_dict).expect("forms");
assert_eq!(objects.get(b"ElyAnnot0").and_then(Object::as_reference).expect("kept"), blue);
let drawn = String::from_utf8(form.decompressed_content().expect("content")).expect("text");
assert!(drawn.contains("/ElyAnnot0 Do") && drawn.contains("/ElyAnnot1 Do"), "{drawn}");
let added = objects.get(b"ElyAnnot1").and_then(Object::as_reference).expect("the annotation's");
assert_ne!(added, blue);
}
#[test]
fn a_printable_annotation_without_a_look_fails() {
let rect: Vec<Object> = vec![50.into(), 100.into(), 150.into(), 180.into()];
let bare = dictionary! { "Subtype" => "Square", "F" => 4, "Rect" => rect.clone() };
let (document, page) = annotated(0, |_| (vec![bare], dictionary! {}));
let error = super::appearances(&document, page).expect_err("cannot be drawn");
assert!(error.to_string().contains("a printable Square annotation has no appearance"), "{error}");
// A link draws nothing; an unset state draws nothing.
let link = dictionary! { "Subtype" => "Link", "F" => 4, "Rect" => rect.clone() };
let (mut document, page) = annotated(0, |_| (vec![link], dictionary! {}));
assert!(super::appearances(&document, page).expect("reads").is_empty());
let on = document.add_object(Stream::new(dictionary! { "BBox" => vec![0.into(), 0.into(), 1.into(), 1.into()] }, Vec::new()));
let off = dictionary! { "Subtype" => "Widget", "F" => 4, "Rect" => rect, "AS" => "Off", "AP" => dictionary! { "N" => dictionary! { "Yes" => on } } };
assert_eq!(super::appearance(&document, &off).expect("reads"), None);
}
+69
View File
@@ -0,0 +1,69 @@
//! macOS: PDFKit's print operation and panel.
use std::path::Path;
/// Shows the print panel for `pdf`; false when cancelled.
pub async fn print_pdf(pdf: &Path) -> anyhow::Result<bool> {
use objc2::{AllocAnyThread, MainThreadMarker};
use objc2_app_kit::{NSPaperOrientation, NSPrintInfo, NSPrintJobSavingURL, NSPrintSaveJob};
use objc2_foundation::{NSCopying, NSSize, NSString, NSURL};
use objc2_pdf_kit::{PDFDisplayBox, PDFDocument, PDFPrintScalingMode};
let mtm =
MainThreadMarker::new().ok_or_else(|| anyhow::anyhow!("printing needs the main thread"))?;
let url = NSURL::fileURLWithPath(&NSString::from_str(&pdf.to_string_lossy()));
let document = unsafe { PDFDocument::initWithURL(PDFDocument::alloc(), &url) }
.ok_or_else(|| anyhow::anyhow!("{} is no readable PDF", pdf.display()))?;
let sizes: Vec<(f64, f64)> = (0..unsafe { document.pageCount() })
.filter_map(|n| unsafe { document.pageAtIndex(n) })
.map(|page| {
let size = unsafe { page.boundsForBox(PDFDisplayBox::MediaBox) }.size;
// A page turned a quarter shows its other side up.
match unsafe { page.rotation() }.rem_euclid(180) == 90 {
true => (size.height, size.width),
false => (size.width, size.height),
}
})
.collect();
let (width, height) = super::paper_for(&sizes).ok_or_else(|| anyhow::anyhow!("{} has no pages", pdf.display()))?;
let paper = NSSize::new(width, height);
if let Some(scale) = crate::env::value("ELY_PRINT_SHARED_SCALE") {
// Tests start from a shared scale the copy must undo.
NSPrintInfo::sharedPrintInfo().setScalingFactor(scale.parse()?);
}
// The PDF already holds the document's paper, margins and scale.
let info = NSPrintInfo::sharedPrintInfo().copy();
info.setScalingFactor(1.0);
info.setOrientation(match paper.width > paper.height {
true => NSPaperOrientation::Landscape,
false => NSPaperOrientation::Portrait,
});
info.setPaperSize(paper);
info.setTopMargin(0.0);
info.setBottomMargin(0.0);
info.setLeftMargin(0.0);
info.setRightMargin(0.0);
log::info!("print: paper {}x{} points", paper.width, paper.height);
let operation = unsafe {
document.printOperationForPrintInfo_scalingMode_autoRotate(
Some(&info),
PDFPrintScalingMode::PageScaleNone,
true,
mtm,
)
}
.ok_or_else(|| anyhow::anyhow!("no print operation for {}", pdf.display()))?;
// Tests read the real job: saved to a file.
let saved = crate::env::value("ELY_PRINT_TO");
if let Some(to) = &saved {
let info = operation.printInfo();
info.setJobDisposition(unsafe { NSPrintSaveJob });
let to = NSURL::fileURLWithPath(&NSString::from_str(to));
unsafe { info.dictionary().insert(NSPrintJobSavingURL, &to) };
}
operation.setShowsPrintPanel(saved.is_none());
operation.setShowsProgressPanel(saved.is_none());
let printed = operation.runOperation();
log::info!("print: the panel closed, printed {printed}");
Ok(printed)
}
+375
View File
@@ -0,0 +1,375 @@
//! Windows: the system print dialog; Windows' PDF renderer draws.
use std::path::Path;
use anyhow::{Context as _, anyhow, bail};
use windows::{
Data::Pdf::{PdfDocument, PdfPage, PdfPageRenderOptions},
Foundation::{Rect, Size},
Graphics::Imaging::BitmapEncoder,
Storage::{
StorageFile,
Streams::{DataReader, InMemoryRandomAccessStream},
},
Win32::{
Foundation::ERROR_CANCELLED,
Graphics::Gdi::{
BITMAPINFO, CreateDCW, DIB_RGB_COLORS, DeleteDC, GM_ADVANCED, GetDeviceCaps, HDC, LOGPIXELSX, LOGPIXELSY,
PHYSICALHEIGHT, PHYSICALOFFSETX, PHYSICALOFFSETY, PHYSICALWIDTH, SRCCOPY, SetGraphicsMode, SetWorldTransform,
StretchDIBits, XFORM,
},
Storage::Xps::{AbortDoc, DOCINFOW, EndDoc, EndPage, StartDocW, StartPage},
System::WinRT::{RO_INIT_MULTITHREADED, RoInitialize, RoUninitialize},
UI::{
Controls::Dialogs::{
PD_NOCURRENTPAGE, PD_NOSELECTION, PD_PAGENUMS, PD_PRINTTOFILE, PD_RESULT_PRINT, PD_USEDEVMODECOPIESANDCOLLATE,
PRINTDLGEX_FLAGS, PRINTDLGEXW, PRINTPAGERANGE, PrintDlgExW, START_PAGE_GENERAL,
},
Input::KeyboardAndMouse::GetActiveWindow,
},
},
core::{HSTRING, PCWSTR},
};
mod printer;
use printer::{Devmode, default_printer, device, free, global, names, settings};
/// The printer tests write their job to a file with.
const TO_FILE: &str = "Microsoft Print to PDF";
/// Bytes one rendered band may take.
const BAND: usize = 32 << 20;
/// The probe's width, in pixels asked.
const PROBE: u32 = 200;
/// Shows the print dialog, then prints; false when cancelled.
pub async fn print_pdf(pdf: &Path) -> anyhow::Result<bool> {
let pdf = std::path::absolute(pdf)?;
let sizes = {
let pdf = pdf.clone();
super::off(move || winrt(|| sizes(&open(&pdf)?))).await?
};
let paper = super::paper_for(&sizes).ok_or_else(|| anyhow!("{} has no pages", pdf.display()))?;
log::info!("print: paper {}x{} points", paper.0, paper.1);
let every = (0..sizes.len() as u32).collect();
// Tests read the real job: written by the file printer.
let job = match crate::env::value("ELY_PRINT_TO") {
Some(to) => {
let printer = wide(TO_FILE);
let settings = settings(&printer, paper)?;
Job { printer, settings, pages: every, output: Some(wide(&to)) }
}
None => match ask(every, paper)? {
Some(job) => job,
None => {
log::info!("print: the dialog was cancelled");
return Ok(false);
}
},
};
let printed = super::off(move || winrt(|| job.print(&pdf))).await?;
log::info!("print: the job ended, printed {printed}");
Ok(printed)
}
/// What the dialog chose.
struct Job {
printer: Vec<u16>,
settings: Devmode,
pages: Vec<u32>,
output: Option<Vec<u16>>,
}
impl Job {
/// Draws the chosen pages; false when its save prompt cancels.
fn print(self, pdf: &Path) -> anyhow::Result<bool> {
let document = open(pdf)?;
let dc = unsafe { CreateDCW(PCWSTR::null(), PCWSTR(self.printer.as_ptr()), PCWSTR::null(), Some(self.settings.as_ptr())) };
if dc.is_invalid() {
bail!("the printer cannot be opened: {}", std::io::Error::last_os_error());
}
let printed = self.draw(&document, dc);
if !unsafe { DeleteDC(dc) }.as_bool() {
log::warn!("print: the printer's context stays open");
}
printed
}
fn draw(&self, document: &PdfDocument, dc: HDC) -> anyhow::Result<bool> {
// The driver may have swapped our paper; nothing is cut.
let paper = Paper::of(dc);
for &n in &self.pages {
let size = document.GetPage(n)?.Size()?;
if paper.fit(size).is_none() {
let (width, height) = paper.millimetres();
let mm = |dips: f32| f64::from(dips) * 25.4 / 96.0;
bail!(
"page {} is {:.0} × {:.0} mm; the printer's paper is {width:.0} × {height:.0} mm",
n + 1,
mm(size.Width),
mm(size.Height)
);
}
}
let name = wide("Ely Office");
let info = DOCINFOW {
cbSize: size_of::<DOCINFOW>() as i32,
lpszDocName: PCWSTR(name.as_ptr()),
lpszOutput: self.output.as_ref().map_or(PCWSTR::null(), |output| PCWSTR(output.as_ptr())),
..Default::default()
};
if unsafe { StartDocW(dc, &info) } <= 0 {
let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(ERROR_CANCELLED.0 as i32) {
return Ok(false);
}
bail!("the printer refused the job: {error}");
}
let drawn = scale(document).and_then(|scale| self.pages.iter().try_for_each(|&n| page(document, n, dc, &paper, scale)));
if let Err(error) = drawn {
unsafe { AbortDoc(dc) };
return Err(error);
}
if unsafe { EndDoc(dc) } <= 0 {
bail!("the printer did not finish the job: {}", std::io::Error::last_os_error());
}
Ok(true)
}
}
/// Shows the dialog over the active window; None when cancelled.
fn ask(every: Vec<u32>, paper: (f64, f64)) -> anyhow::Result<Option<Job>> {
let owner = unsafe { GetActiveWindow() };
if owner.is_invalid() {
bail!("no window is active to own the print dialog");
}
let printer = default_printer()?;
let settings = settings(&printer, paper)?;
let mut ranges = [PRINTPAGERANGE::default(); 32];
let mut dialog = PRINTDLGEXW {
lStructSize: size_of::<PRINTDLGEXW>() as u32,
hwndOwner: owner,
hDevMode: global(settings.bytes())?,
hDevNames: global(&names(&printer))?,
Flags: PD_NOSELECTION | PD_NOCURRENTPAGE | PD_USEDEVMODECOPIESANDCOLLATE,
nMaxPageRanges: ranges.len() as u32,
lpPageRanges: ranges.as_mut_ptr(),
nMinPage: 1,
nMaxPage: every.len() as u32,
nCopies: 1,
nStartPage: START_PAGE_GENERAL,
..Default::default()
};
let shown = unsafe { PrintDlgExW(&mut dialog) };
let chosen = shown.map_err(anyhow::Error::from).and_then(|()| {
if dialog.dwResultAction != PD_RESULT_PRINT {
return Ok(None);
}
let (pages, output) = chosen(dialog.Flags, &ranges[..dialog.nPageRanges as usize], every.len() as u32);
Ok(Some(Job { printer: device(dialog.hDevNames)?, settings: Devmode::copied(dialog.hDevMode)?, pages, output }))
});
// The dialog may replace either block; we free both.
free(dialog.hDevMode)?;
free(dialog.hDevNames)?;
chosen
}
/// The pages and output the dialog's answer chose.
fn chosen(flags: PRINTDLGEX_FLAGS, ranges: &[PRINTPAGERANGE], count: u32) -> (Vec<u32>, Option<Vec<u16>>) {
let pages = match flags.contains(PD_PAGENUMS) {
true => ranges.iter().flat_map(|range| range.nFromPage.max(1) - 1..range.nToPage.min(count)).collect(),
false => (0..count).collect(),
};
// "FILE:" makes the spooler ask for the file's name.
let output = flags.contains(PD_PRINTTOFILE).then(|| wide("FILE:"));
(pages, output)
}
/// The printer's paper, in its device pixels.
struct Paper {
dpi: (i32, i32),
size: (i32, i32),
offset: (i32, i32),
}
impl Paper {
fn of(dc: HDC) -> Self {
let caps = |index| unsafe { GetDeviceCaps(Some(dc), index) };
Self {
dpi: (caps(LOGPIXELSX), caps(LOGPIXELSY)),
size: (caps(PHYSICALWIDTH), caps(PHYSICALHEIGHT)),
offset: (caps(PHYSICALOFFSETX), caps(PHYSICALOFFSETY)),
}
}
/// Pixels, and whether it turns; None if too large.
fn fit(&self, size: Size) -> Option<((i32, i32), bool)> {
let ((dpi_x, dpi_y), (paper_x, paper_y)) = (self.dpi, self.size);
let pixels = |dips: f32, dpi: i32| (f64::from(dips) * f64::from(dpi) / 96.0).round() as i32;
// A page fits upright or turned, within rounding.
let fits = |pixels: i32, paper: i32, dpi: i32| f64::from(pixels - paper) <= f64::from(dpi) * super::ROUNDING / 72.0;
let (across, down) = (pixels(size.Width, dpi_x), pixels(size.Height, dpi_y));
if fits(across, paper_x, dpi_x) && fits(down, paper_y, dpi_y) {
return Some(((across, down), false));
}
let (across, down) = (pixels(size.Width, dpi_y), pixels(size.Height, dpi_x));
(fits(across, paper_y, dpi_y) && fits(down, paper_x, dpi_x)).then_some(((across, down), true))
}
fn millimetres(&self) -> (f64, f64) {
let mm = |pixels: i32, dpi: i32| f64::from(pixels) * 25.4 / f64::from(dpi);
(mm(self.size.0, self.dpi.0), mm(self.size.1, self.dpi.1))
}
}
/// Pixels given per pixel asked: the renderer counts DIPs.
fn scale(document: &PdfDocument) -> anyhow::Result<f64> {
let options = PdfPageRenderOptions::new()?;
options.SetDestinationWidth(PROBE)?;
options.SetDestinationHeight(PROBE)?;
let (across, _) = bitmap(&rendered(&document.GetPage(0)?, &options)?)?.size;
log::info!("print: the renderer gives {across} pixels per {PROBE} asked");
Ok(f64::from(across) / f64::from(PROBE))
}
/// Draws page `n` at its size from the paper's corner.
fn page(document: &PdfDocument, n: u32, dc: HDC, paper: &Paper, scale: f64) -> anyhow::Result<()> {
let page = document.GetPage(n)?;
let size = page.Size()?;
let ((width, height), turned) = paper.fit(size).with_context(|| format!("page {} does not fit the paper", n + 1))?;
let (offset_x, offset_y) = paper.offset;
if unsafe { StartPage(dc) } <= 0 {
bail!("the printer refused page {}: {}", n + 1, std::io::Error::last_os_error());
}
// Device pixels start at the printable area, not the paper.
let place = match turned {
// A turned page's top runs up its left edge.
true => XFORM { eM11: 0.0, eM12: -1.0, eM21: 1.0, eM22: 0.0, eDx: -offset_x as f32, eDy: (width - offset_y) as f32 },
false => XFORM { eM11: 1.0, eM12: 0.0, eM21: 0.0, eM22: 1.0, eDx: -offset_x as f32, eDy: -offset_y as f32 },
};
if unsafe { SetGraphicsMode(dc, GM_ADVANCED) } == 0 || !unsafe { SetWorldTransform(dc, &place) }.as_bool() {
bail!("the printer cannot place page {}", n + 1);
}
let rows = (BAND / (width.max(1) as usize * 4)).max(1) as i32;
let mut top = 0;
while top < height {
let band = rows.min(height - top);
let bmp = render(&page, size, (width, height), (top, band), scale)?;
draw(dc, &bmp, top, width, band)?;
top += band;
}
if unsafe { EndPage(dc) } <= 0 {
bail!("the printer did not take page {}: {}", n + 1, std::io::Error::last_os_error());
}
Ok(())
}
/// The page's band from row `top`, as a BMP file.
fn render(page: &PdfPage, size: Size, (width, height): (i32, i32), (top, rows): (i32, i32), scale: f64) -> anyhow::Result<Vec<u8>> {
let options = PdfPageRenderOptions::new()?;
let row = f64::from(size.Height) / f64::from(height);
options.SetSourceRect(Rect {
X: 0.0,
Y: (f64::from(top) * row) as f32,
Width: size.Width,
Height: (f64::from(rows) * row) as f32,
})?;
let asked = |pixels: i32| ((f64::from(pixels) / scale).round() as u32).max(1);
options.SetDestinationWidth(asked(width))?;
options.SetDestinationHeight(asked(rows))?;
rendered(page, &options)
}
/// The page as `options` asks, as a BMP file.
fn rendered(page: &PdfPage, options: &PdfPageRenderOptions) -> anyhow::Result<Vec<u8>> {
options.SetBitmapEncoderId(BitmapEncoder::BmpEncoderId()?)?;
let stream = InMemoryRandomAccessStream::new()?;
page.RenderWithOptionsToStreamAsync(&stream, options)?.join()?;
let length = u32::try_from(stream.Size()?)?;
let reader = DataReader::CreateDataReader(&stream.GetInputStreamAt(0)?)?;
reader.LoadAsync(length)?.join()?;
let mut bytes = vec![0; length as usize];
reader.ReadBytes(&mut bytes)?;
Ok(bytes)
}
/// A BMP file's pixel size and where its pixels start.
struct Bitmap {
size: (i32, i32),
start: usize,
}
fn bitmap(bmp: &[u8]) -> anyhow::Result<Bitmap> {
let word = |at: usize| bmp.get(at..at + 4).map(|bytes| u32::from_le_bytes(bytes.try_into().expect("four bytes")));
let (Some(start), Some(across), Some(down)) = (word(10), word(18), word(22)) else {
bail!("the renderer gave no bitmap");
};
if !bmp.starts_with(b"BM") || start as usize >= bmp.len() {
bail!("the renderer gave a broken bitmap");
}
// Rows run up the file when its height is positive.
Ok(Bitmap { size: (across as i32, (down as i32).abs()), start: start as usize })
}
/// Stretches one BMP band over `width` by `rows` at `top`.
fn draw(dc: HDC, bmp: &[u8], top: i32, width: i32, rows: i32) -> anyhow::Result<()> {
let Bitmap { size: (across, down), start } = bitmap(bmp)?;
// Asking in DIPs rounds; a few pixels off is that.
if (across - width).abs() > 4 || (down - rows).abs() > 4 {
bail!("the renderer gave a {across}x{down} bitmap for {width}x{rows}");
}
let info = bmp[14..].as_ptr().cast::<BITMAPINFO>();
let bits = bmp[start..].as_ptr().cast();
let copied = unsafe { StretchDIBits(dc, 0, top, width, rows, 0, 0, across, down, Some(bits), info, DIB_RGB_COLORS, SRCCOPY) };
if copied <= 0 {
bail!("the printer took none of the page: {}", std::io::Error::last_os_error());
}
Ok(())
}
/// Opens `pdf` with Windows' PDF renderer.
fn open(pdf: &Path) -> anyhow::Result<PdfDocument> {
let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(pdf))?
.join()
.with_context(|| format!("{} cannot be opened", pdf.display()))?;
PdfDocument::LoadFromFileAsync(&file)?.join().with_context(|| format!("{} is no readable PDF", pdf.display()))
}
/// Each page's size in points, as it shows.
fn sizes(document: &PdfDocument) -> anyhow::Result<Vec<(f64, f64)>> {
let points = |dips: f32| f64::from(dips) * 72.0 / 96.0;
(0..document.PageCount()?)
.map(|n| {
let size = document.GetPage(n)?.Size()?;
Ok((points(size.Width), points(size.Height)))
})
.collect()
}
/// Runs `work` with the thread joined to the Windows Runtime.
fn winrt<T>(work: impl FnOnce() -> anyhow::Result<T>) -> anyhow::Result<T> {
unsafe { RoInitialize(RO_INIT_MULTITHREADED) }?;
let result = work();
unsafe { RoUninitialize() };
result
}
fn wide(text: &str) -> Vec<u16> {
text.encode_utf16().chain([0]).collect()
}
#[cfg(test)]
mod tests {
use windows::Win32::UI::Controls::Dialogs::{PD_PAGENUMS, PD_PRINTTOFILE, PRINTDLGEX_FLAGS, PRINTPAGERANGE};
use super::{chosen, wide};
#[test]
fn the_dialog_answer_picks_pages_and_output() {
let ranges = [PRINTPAGERANGE { nFromPage: 2, nToPage: 3 }, PRINTPAGERANGE { nFromPage: 5, nToPage: 9 }];
assert_eq!(chosen(PRINTDLGEX_FLAGS(0), &ranges, 3), (vec![0, 1, 2], None));
assert_eq!(chosen(PD_PAGENUMS, &ranges, 6), (vec![1, 2, 4, 5], None));
// Print to file: the spooler asks where.
assert_eq!(chosen(PD_PAGENUMS | PD_PRINTTOFILE, &ranges[..1], 6), (vec![1, 2], Some(wide("FILE:"))));
}
}
@@ -0,0 +1,209 @@
//! The printer's settings and the global blocks the dialog trades.
use anyhow::{Context as _, bail};
use windows::{
Win32::{
Foundation::{GlobalFree, HGLOBAL, POINT},
Graphics::{
Gdi::{
DEVMODEW, DM_IN_BUFFER, DM_ORIENTATION, DM_OUT_BUFFER, DM_PAPERLENGTH, DM_PAPERSIZE, DM_PAPERWIDTH,
DMORIENT_LANDSCAPE, DMORIENT_PORTRAIT, DMPAPER_USER,
},
Printing::{ClosePrinter, DocumentPropertiesW, GetDefaultPrinterW, OpenPrinterW, PRINTER_HANDLE},
},
Storage::Xps::{DC_PAPERS, DC_PAPERSIZE, DeviceCapabilitiesW},
System::Memory::{GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock},
UI::Controls::Dialogs::DEVNAMES,
},
core::{PCWSTR, PWSTR},
};
use super::wide;
/// `printer`'s settings, set to the document's paper.
pub(super) fn settings(printer: &[u16], (width, height): (f64, f64)) -> anyhow::Result<Devmode> {
let name = PCWSTR(printer.as_ptr());
let mut handle = PRINTER_HANDLE::default();
unsafe { OpenPrinterW(name, &mut handle, None) }
.with_context(|| format!("the printer {} cannot be opened", String::from_utf16_lossy(&printer[..printer.len() - 1])))?;
let set = || -> anyhow::Result<Devmode> {
let bytes = unsafe { DocumentPropertiesW(None, handle, name, None, None, 0) };
let mut settings = Devmode::zeroed(usize::try_from(bytes).ok().filter(|&b| b >= size_of::<DEVMODEW>()).context("the printer gives no settings")?);
answered(unsafe { DocumentPropertiesW(None, handle, name, Some(settings.as_mut_ptr()), None, DM_OUT_BUFFER.0) })?;
// Forms stand upright, in tenths of a millimetre.
let tenths = |points: f64| (points * 254.0 / 72.0).round();
let (short, long) = (tenths(width.min(height)), tenths(width.max(height)));
if long > f64::from(i16::MAX) {
bail!("the page is larger than any paper");
}
let points = |tenths: i32| f64::from(tenths) * 72.0 / 254.0;
let page = (width.min(height), width.max(height));
let form = forms(name, &settings)?.into_iter().find(|&(_, across, down)| crate::print::holds((points(across), points(down)), page));
let fields = settings.fields();
unsafe {
let paper = &mut fields.Anonymous1.Anonymous1;
match form {
Some((id, ..)) => {
paper.dmPaperSize = id as i16;
fields.dmFields = (fields.dmFields | DM_PAPERSIZE) & !(DM_PAPERWIDTH | DM_PAPERLENGTH);
}
None => {
paper.dmPaperSize = DMPAPER_USER as i16;
paper.dmPaperWidth = short as i16;
paper.dmPaperLength = long as i16;
fields.dmFields |= DM_PAPERSIZE | DM_PAPERWIDTH | DM_PAPERLENGTH;
}
}
paper.dmOrientation = if width > height { DMORIENT_LANDSCAPE } else { DMORIENT_PORTRAIT } as i16;
}
fields.dmFields |= DM_ORIENTATION;
// The driver settles what it cannot do.
let asked = settings.clone();
answered(unsafe {
DocumentPropertiesW(None, handle, name, Some(settings.as_mut_ptr()), Some(asked.as_ptr()), DM_IN_BUFFER.0 | DM_OUT_BUFFER.0)
})?;
Ok(settings)
};
let settings = set();
unsafe { ClosePrinter(handle) }?;
settings
}
/// The printer's forms: id, then size in tenth millimetres.
fn forms(printer: PCWSTR, settings: &Devmode) -> anyhow::Result<Vec<(u16, i32, i32)>> {
let count = unsafe { DeviceCapabilitiesW(printer, PCWSTR::null(), DC_PAPERS, None, Some(settings.as_ptr())) };
let count = usize::try_from(count).context("the printer lists no forms")?;
let mut ids = vec![0u16; count];
let mut sizes = vec![POINT::default(); count];
let listed = [
unsafe { DeviceCapabilitiesW(printer, PCWSTR::null(), DC_PAPERS, Some(PWSTR(ids.as_mut_ptr())), Some(settings.as_ptr())) },
unsafe { DeviceCapabilitiesW(printer, PCWSTR::null(), DC_PAPERSIZE, Some(PWSTR(sizes.as_mut_ptr().cast())), Some(settings.as_ptr())) },
];
if listed != [count as i32; 2] {
bail!("the printer's forms changed while read");
}
Ok(ids.into_iter().zip(sizes).map(|(id, size)| (id, size.x, size.y)).collect())
}
/// The default printer's name, ending in a nul.
pub(super) fn default_printer() -> anyhow::Result<Vec<u16>> {
let mut length = 0;
// Asked with no buffer, it fails and gives the length.
let _ = unsafe { GetDefaultPrinterW(None, &mut length) };
if length == 0 {
bail!("no default printer is set up");
}
let mut name = vec![0u16; length as usize];
if !unsafe { GetDefaultPrinterW(Some(PWSTR(name.as_mut_ptr())), &mut length) }.as_bool() {
bail!("the default printer cannot be read: {}", std::io::Error::last_os_error());
}
Ok(name)
}
/// Names `printer`, so the dialog keeps our settings.
pub(super) fn names(printer: &[u16]) -> Vec<u8> {
let driver = wide("winspool");
let header = (size_of::<DEVNAMES>() / 2) as u16;
let offsets = [header, header + driver.len() as u16, header + (driver.len() + printer.len()) as u16, 0];
let words = offsets.into_iter().chain(driver).chain(printer.iter().copied()).chain([0]);
words.flat_map(u16::to_le_bytes).collect()
}
/// A DEVNAMES block's device name, ending in a nul.
pub(super) fn device(names: HGLOBAL) -> anyhow::Result<Vec<u16>> {
let base = lock(names)?.cast::<u16>();
let offset = unsafe { (*base.cast::<DEVNAMES>()).wDeviceOffset } as usize;
let start = unsafe { base.add(offset) };
let length = (0..).take_while(|&n| unsafe { *start.add(n) } != 0).count();
let name = unsafe { std::slice::from_raw_parts(start, length + 1) }.to_vec();
unlock(names);
Ok(name)
}
/// A DEVMODE with its driver's tail, kept aligned.
#[derive(Clone)]
pub(super) struct Devmode {
words: Vec<u64>,
bytes: usize,
}
impl Devmode {
fn zeroed(bytes: usize) -> Self {
Self { words: vec![0; bytes.div_ceil(8)], bytes }
}
/// A copy of the DEVMODE in `block`.
pub(super) fn copied(block: HGLOBAL) -> anyhow::Result<Self> {
let bytes = unsafe { GlobalSize(block) };
if bytes < size_of::<DEVMODEW>() {
bail!("the dialog gave no printer settings");
}
let mut settings = Self::zeroed(bytes);
let source = lock(block)?;
unsafe { std::ptr::copy_nonoverlapping(source.cast::<u8>(), settings.words.as_mut_ptr().cast(), bytes) };
unlock(block);
Ok(settings)
}
pub(super) fn as_ptr(&self) -> *const DEVMODEW {
self.words.as_ptr().cast()
}
fn as_mut_ptr(&mut self) -> *mut DEVMODEW {
self.words.as_mut_ptr().cast()
}
fn fields(&mut self) -> &mut DEVMODEW {
unsafe { &mut *self.as_mut_ptr() }
}
pub(super) fn bytes(&self) -> &[u8] {
unsafe { std::slice::from_raw_parts(self.words.as_ptr().cast(), self.bytes) }
}
}
/// A movable global block holding `bytes`.
pub(super) fn global(bytes: &[u8]) -> anyhow::Result<HGLOBAL> {
let block = unsafe { GlobalAlloc(GMEM_MOVEABLE, bytes.len()) }?;
let target = lock(block)?;
unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), target.cast(), bytes.len()) };
unlock(block);
Ok(block)
}
fn lock(block: HGLOBAL) -> anyhow::Result<*mut core::ffi::c_void> {
let pointer = unsafe { GlobalLock(block) };
if pointer.is_null() {
bail!("a printer block cannot be read: {}", std::io::Error::last_os_error());
}
Ok(pointer)
}
fn unlock(block: HGLOBAL) {
// An error here only says the block is now unlocked.
if let Err(error) = unsafe { GlobalUnlock(block) }
&& error.code().is_err()
{
log::warn!("print: a printer block stays locked: {error}");
}
}
pub(super) fn free(block: HGLOBAL) -> anyhow::Result<()> {
if block.is_invalid() {
return Ok(());
}
// Freed, it answers null; windows-rs calls that an error.
match unsafe { GlobalFree(Some(block)) } {
Err(error) if error.code().is_ok() => Ok(()),
Err(error) => Err(error.into()),
Ok(_) => bail!("a printer block cannot be freed: {}", std::io::Error::last_os_error()),
}
}
/// DocumentProperties answers IDOK on success.
fn answered(code: i32) -> anyhow::Result<()> {
if code < 0 {
bail!("the printer refused its settings");
}
Ok(())
}
+339
View File
@@ -0,0 +1,339 @@
//! Printing through a Windows file printer and fake Linux portal.
#[cfg(not(target_os = "macos"))]
use std::path::Path;
/// Pages of `sizes` points, each with an inch square.
#[cfg(not(target_os = "macos"))]
fn fixture(path: &Path, sizes: &[(f32, f32)]) {
use lopdf::{Document, Object, Stream, dictionary};
let mut document = Document::with_version("1.7");
let tree = document.new_object_id();
let kids: Vec<Object> = sizes
.iter()
.map(|&(width, height)| {
let square = format!("0 g 72 {} 72 72 re f", height - 144.0);
let content = document.add_object(Stream::new(dictionary! {}, square.into_bytes()));
let media: Vec<Object> = vec![0.into(), 0.into(), width.into(), height.into()];
document
.add_object(dictionary! { "Type" => "Page", "Parent" => tree, "MediaBox" => media, "Contents" => content })
.into()
})
.collect();
let count = kids.len() as i64;
document.objects.insert(tree, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => count }.into());
let catalog = document.add_object(dictionary! { "Type" => "Catalog", "Pages" => tree });
document.trailer.set("Root", catalog);
document.save(path).expect("the fixture saves");
}
#[cfg(windows)]
fn main() {
let folder = tempfile::tempdir().expect("a folder");
unsafe { windows::Win32::System::WinRT::RoInitialize(windows::Win32::System::WinRT::RO_INIT_MULTITHREADED) }.expect("the runtime");
// A5, then wide A5: the paper stays upright.
let mixed = printed(folder.path(), "mixed", &[(420.0, 595.0), (595.0, 420.0)]);
assert_eq!(papers(&mixed), [(420, 595), (420, 595)]);
let square = |page| windows_page::dark(&mixed, page, (108, 108));
assert_eq!((square(0), square(1)), (true, false), "the upright page keeps its corner");
// Turned left, its top left lands bottom left.
assert!(windows_page::dark(&mixed, 1, (108, 487)), "the turned page's square");
assert!(!windows_page::dark(&mixed, 0, (312, 108)), "nothing at the upright page's other corner");
// A4 landscape alone turns the paper.
let wide = printed(folder.path(), "wide", &[(842.0, 595.0)]);
assert_eq!(papers(&wide), [(842, 595)]);
assert!(windows_page::dark(&wide, 0, (108, 108)), "the wide page keeps its corner");
// A paper the printer lacks: refused, nothing cut or written.
let source = folder.path().join("custom.pdf");
fixture(&source, &[(1000.0, 600.0)]);
let job = folder.path().join("custom-job.pdf");
unsafe { std::env::set_var("ELY_PRINT_TO", &job) };
let error = futures::executor::block_on(ely_platform::print::print_pdf(&source)).expect_err("no such paper");
assert!(error.to_string().contains("page 1 is 353 × 212 mm; the printer's paper is"), "{error}");
assert!(!job.exists(), "nothing printed");
// 1.6 mm past A4: refused, not cut.
fixture(&source, &[(600.0, 842.0)]);
let error = futures::executor::block_on(ely_platform::print::print_pdf(&source)).expect_err("past A4");
assert!(error.to_string().contains("page 1 is 212 × 297 mm; the printer's paper is"), "{error}");
assert!(!job.exists(), "nothing printed");
println!("print: windows ok");
}
/// Prints `sizes` through the file printer; the job's PDF.
#[cfg(windows)]
fn printed(folder: &Path, name: &str, sizes: &[(f32, f32)]) -> std::path::PathBuf {
let source = folder.join(format!("{name}.pdf"));
fixture(&source, sizes);
let job = folder.join(format!("{name}-job.pdf"));
// Before any thread starts: the job goes to a file.
unsafe { std::env::set_var("ELY_PRINT_TO", &job) };
assert!(futures::executor::block_on(ely_platform::print::print_pdf(&source)).expect("prints"), "printed");
job
}
/// Each printed page's paper in whole points.
#[cfg(windows)]
fn papers(job: &Path) -> Vec<(i64, i64)> {
let document = lopdf::Document::load(job).expect("the job is a PDF");
document
.get_pages()
.into_values()
.map(|page| {
let media = document.get_dictionary(page).and_then(|page| page.get(b"MediaBox")).expect("a media box");
let corners: Vec<f32> = media.as_array().expect("an array").iter().map(|n| n.as_float().expect("a number")).collect();
((corners[2] - corners[0]).round() as i64, (corners[3] - corners[1]).round() as i64)
})
.collect()
}
/// Reads printed pages back through Windows' own renderer.
#[cfg(windows)]
mod windows_page {
use std::path::Path;
use windows::{
Data::Pdf::{PdfDocument, PdfPageRenderOptions},
Storage::{
StorageFile,
Streams::{DataReader, InMemoryRandomAccessStream},
},
core::HSTRING,
};
/// Whether `(x, y)` points from the top left is dark.
pub fn dark(job: &Path, page: u32, (x, y): (u32, u32)) -> bool {
let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(job)).and_then(|file| file.join()).expect("the job opens");
let document = PdfDocument::LoadFromFileAsync(&file).and_then(|document| document.join()).expect("the job reads");
let page = document.GetPage(page).expect("the page");
let size = page.Size().expect("its size");
let options = PdfPageRenderOptions::new().expect("options");
// Asked in DIPs; scaled back to one pixel per point.
options.SetDestinationWidth((size.Width * 0.75).round() as u32).expect("a width");
let stream = InMemoryRandomAccessStream::new().expect("a stream");
page.RenderWithOptionsToStreamAsync(&stream, &options).and_then(|render| render.join()).expect("renders");
let length = stream.Size().expect("a size") as u32;
let reader = DataReader::CreateDataReader(&stream.GetInputStreamAt(0).expect("a start")).expect("a reader");
reader.LoadAsync(length).and_then(|load| load.join()).expect("loads");
let mut png = vec![0; length as usize];
reader.ReadBytes(&mut png).expect("reads");
let image = image::load_from_memory(&png).expect("a PNG").to_luma8();
let scale = f64::from(image.width()) / f64::from(size.Width * 0.75);
let at = |point: u32| (f64::from(point) * scale) as u32;
image.get_pixel(at(x), at(y)).0[0] < 64
}
}
#[cfg(target_os = "linux")]
fn main() {
use std::io::BufRead as _;
// A private session bus: the stand-in portal and nothing else.
let bus = std::process::Command::new("dbus-daemon")
.args(["--session", "--nofork", "--nopidfile", "--print-address=1"])
.stdout(std::process::Stdio::piped())
.spawn();
let Ok(mut bus) = bus else {
println!("SKIP: no dbus-daemon");
return;
};
let mut address = String::new();
std::io::BufReader::new(bus.stdout.take().expect("its address")).read_line(&mut address).expect("reads the address");
// Before any thread starts: the portal client finds this bus.
unsafe { std::env::set_var("DBUS_SESSION_BUS_ADDRESS", address.trim()) };
let folder = tempfile::tempdir().expect("a folder");
let wide = folder.path().join("wide.pdf");
fixture(&wide, &[(842.0, 595.0)]);
let three = folder.path().join("three.pdf");
fixture(&three, &[(595.0, 842.0), (420.0, 595.0), (300.0, 300.0)]);
futures::executor::block_on(portal::check(&wide, &three));
bus.kill().expect("the bus stops");
println!("print: linux ok");
}
/// A stand-in for org.freedesktop.portal.Print.
#[cfg(target_os = "linux")]
mod portal {
use std::{
collections::HashMap,
io::Read as _,
path::Path,
sync::{Arc, Mutex},
};
use zbus::{
Connection,
message::Header,
zvariant::{OwnedFd, OwnedObjectPath, OwnedValue, Value},
};
#[derive(Default)]
struct Seen {
cancel: bool,
/// What the dialog answers the user chose.
choices: Vec<(&'static str, &'static str)>,
setup: HashMap<String, OwnedValue>,
formats: Option<Vec<String>>,
token: Option<u32>,
bytes: Option<Vec<u8>>,
}
struct Portal(Arc<Mutex<Seen>>);
/// The request object the caller listens on.
fn handle(header: &Header<'_>, options: &HashMap<String, OwnedValue>) -> OwnedObjectPath {
let sender = header.sender().expect("a sender").trim_start_matches(':').replace('.', "_");
let token: &str = options.get("handle_token").expect("a handle token").downcast_ref().expect("a string");
OwnedObjectPath::try_from(format!("/org/freedesktop/portal/desktop/request/{sender}/{token}")).expect("a path")
}
async fn answer(connection: &Connection, handle: &OwnedObjectPath, code: u32, results: HashMap<&str, Value<'_>>) {
connection
.emit_signal(None::<()>, handle, "org.freedesktop.portal.Request", "Response", &(code, results))
.await
.expect("the response goes out");
}
#[zbus::interface(name = "org.freedesktop.portal.Print")]
impl Portal {
// The portal's own signature.
#[allow(clippy::too_many_arguments)]
async fn prepare_print(
&self,
#[zbus(header)] header: Header<'_>,
#[zbus(connection)] connection: &Connection,
_parent: &str,
_title: &str,
_settings: HashMap<String, OwnedValue>,
page_setup: HashMap<String, OwnedValue>,
options: HashMap<String, OwnedValue>,
) -> OwnedObjectPath {
let handle = handle(&header, &options);
let formats = options.get("supported_output_file_formats").map(|formats| formats.try_clone().expect("a copy").try_into().expect("strings"));
let (cancel, choices) = {
let mut seen = self.0.lock().expect("the record");
(seen.setup, seen.formats) = (page_setup.clone(), formats);
(seen.cancel, seen.choices.clone())
};
// GTK hands every setting back as text.
let settings: HashMap<String, Value> = choices.into_iter().map(|(key, value)| (key.to_string(), Value::from(value))).collect();
let results = match cancel {
true => HashMap::new(),
false => HashMap::from([
("settings", Value::from(settings)),
("page-setup", Value::from(page_setup)),
("token", Value::from(7u32)),
]),
};
answer(connection, &handle, u32::from(cancel), results).await;
handle
}
async fn print(
&self,
#[zbus(header)] header: Header<'_>,
#[zbus(connection)] connection: &Connection,
_parent: &str,
_title: &str,
fd: OwnedFd,
options: HashMap<String, OwnedValue>,
) -> OwnedObjectPath {
let handle = handle(&header, &options);
let mut bytes = Vec::new();
std::fs::File::from(std::os::fd::OwnedFd::from(fd)).read_to_end(&mut bytes).expect("the job reads");
let token = options.get("token").map(|token| token.downcast_ref::<u32>().expect("a number"));
{
let mut seen = self.0.lock().expect("the record");
(seen.token, seen.bytes) = (token, Some(bytes));
}
answer(connection, &handle, 0, HashMap::new()).await;
handle
}
#[zbus(property, name = "version")]
fn version(&self) -> u32 {
3
}
}
/// A sheet's size, then each form's width and placed corners.
type Sheet = ((i64, i64), Vec<(i64, [i64; 4])>);
fn sheets(bytes: &[u8]) -> Vec<Sheet> {
use lopdf::{Document, Object, content::Content};
let document = Document::load_mem(bytes).expect("a PDF");
let numbers = |object: &Object| -> Vec<f64> { object.as_array().expect("numbers").iter().map(|n| f64::from(n.as_float().expect("a number"))).collect() };
let round = |n: f64| n.round() as i64;
let pages = document.get_pages().into_values().map(|page| {
let dict = document.get_dictionary(page).expect("a page");
let media = numbers(dict.get(b"MediaBox").expect("a box"));
let resources = dict.get(b"Resources").and_then(Object::as_dict).expect("resources");
let forms = resources.get(b"XObject").and_then(Object::as_dict).expect("forms");
let content = Content::decode(&document.get_page_content(page)).expect("content");
let mut m = vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
let mut placed = Vec::new();
for op in content.operations {
match op.operator.as_str() {
"cm" => m = op.operands.iter().map(|n| f64::from(n.as_float().expect("a number"))).collect(),
"Do" => {
let form = forms.get(op.operands[0].as_name().expect("a name")).and_then(Object::as_reference).expect("a form");
let stream = document.get_object(form).and_then(Object::as_stream).expect("a stream");
let b = numbers(stream.dict.get(b"BBox").expect("a box"));
let at = |x: f64, y: f64| (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]);
let ((x0, y0), (x1, y1)) = (at(b[0], b[1]), at(b[2], b[3]));
placed.push((round(b[2] - b[0]), [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)].map(round)));
}
_ => {}
}
}
((round(media[2]), round(media[3])), placed)
});
pages.collect()
}
pub async fn check(source: &Path, three: &Path) {
let seen = Arc::new(Mutex::new(Seen::default()));
let _portal = zbus::connection::Builder::session()
.and_then(|builder| builder.name("org.freedesktop.portal.Desktop"))
.and_then(|builder| builder.serve_at("/org/freedesktop/portal/desktop", Portal(seen.clone())))
.expect("a portal")
.build()
.await
.expect("the portal serves");
assert!(ely_platform::print::print_pdf(source).await.expect("prints"), "printed");
{
let seen = seen.lock().expect("the record");
let text = |key: &str| seen.setup.get(key).map(|value| value.downcast_ref::<String>().expect("text"));
let number = |key: &str| seen.setup.get(key).map(|value| value.downcast_ref::<f64>().expect("a number").round());
assert_eq!(text("Name").as_deref(), Some("iso_a4"));
assert_eq!(text("Orientation").as_deref(), Some("landscape"));
assert_eq!((number("Width"), number("Height"), number("MarginTop")), (Some(210.0), Some(297.0), Some(0.0)));
assert_eq!(seen.token, Some(7), "the print carries the dialog's token");
assert_eq!(seen.formats.as_deref(), Some(["pdf".to_string()].as_slice()), "PDF is all we hand over");
let printed = sheets(seen.bytes.as_deref().expect("a job"));
assert_eq!(printed, [((842, 595), vec![(842, [0, 0, 842, 595])])], "the page fills its sheet");
}
// Page two at half size, from the sheet's top left.
let choices = vec![("print-pages", "ranges"), ("page-ranges", "1"), ("scale", "50")];
*seen.lock().expect("the record") = Seen { choices, ..Seen::default() };
assert!(ely_platform::print::print_pdf(three).await.expect("prints"), "printed");
let printed = sheets(seen.lock().expect("the record").bytes.as_deref().expect("a job"));
assert_eq!(printed, [((595, 842), vec![(420, [0, 545, 210, 842])])]);
// Two copies, uncollated, of the last two pages, in reverse.
let choices = vec![("print-pages", "ranges"), ("page-ranges", "1--1"), ("reverse", "true"), ("n-copies", "2"), ("collate", "false")];
*seen.lock().expect("the record") = Seen { choices, ..Seen::default() };
assert!(ely_platform::print::print_pdf(three).await.expect("prints"), "printed");
let printed = sheets(seen.lock().expect("the record").bytes.as_deref().expect("a job"));
let widths: Vec<i64> = printed.iter().map(|(_, placed)| placed[0].0).collect();
assert_eq!(widths, [300, 300, 420, 420]);
// Cancelled, nothing prints.
*seen.lock().expect("the record") = Seen { cancel: true, ..Seen::default() };
assert!(!ely_platform::print::print_pdf(source).await.expect("cancels"), "not printed");
assert_eq!(seen.lock().expect("the record").bytes, None, "no job went out");
}
}
/// macOS prints through tests/scripts/print.sh, in a real window.
#[cfg(target_os = "macos")]
fn main() {}
+1 -7
View File
@@ -131,9 +131,6 @@ fn label(command: &Command, cx: &App) -> SharedString {
/// Why `id` cannot run; a document lifts the no-document block.
pub fn blocked(id: &str, document: bool, cx: &App) -> Option<SharedString> {
if id == PRINT.id && !ely_platform::print::BUILT {
return Some(i18n::text(cx, "command.blocked.unbuilt", &[]));
}
let command = RENAMING.iter().find(|command| command.id == id)?;
let key = command.blocked.filter(|_| !document)?;
Some(i18n::text(cx, key, &[]))
@@ -190,11 +187,8 @@ pub fn menus(
}
pub fn bind_keys(cx: &mut App) {
// Without a print panel, Print stays off the keyboard.
if ely_platform::print::BUILT {
cx.bind_keys([KeyBinding::new(PRINT.keys, Print, Some(CONTEXT))]);
}
cx.bind_keys([
KeyBinding::new(PRINT.keys, Print, Some(CONTEXT)),
KeyBinding::new(NEW.keys, New, Some(CONTEXT)),
KeyBinding::new(OPEN.keys, Open, Some(CONTEXT)),
KeyBinding::new(SAVE.keys, Save, Some(CONTEXT)),
-2
View File
@@ -41,7 +41,6 @@ const EN: &[(&str, &str)] = &[
("command.file.export", "Export…"),
("command.file.print", "Print…"),
("command.blocked.no_document", "No open document"),
("command.blocked.unbuilt", "Not built for this platform yet"),
("palette.group.file", "File"),
("gallery.ready", "Ready"),
("gallery.nav", "Navigation"),
@@ -97,7 +96,6 @@ const ZH_CN: &[(&str, &str)] = &[
("command.file.export", "导出…"),
("command.file.print", "打印…"),
("command.blocked.no_document", "没有打开的文档"),
("command.blocked.unbuilt", "此平台尚未构建"),
("palette.group.file", "文件"),
("gallery.ready", "就绪"),
("gallery.nav", "导航"),
+1 -1
View File
@@ -379,7 +379,7 @@ mod tests {
})
.expect("the window lives");
cx.run_until_parked();
// Save without a document is blocked; so is Print unbuilt.
// Save without a document is blocked.
window
.update(cx, |_, window, cx| root.update(cx, |root, cx| root.run_from_palette("file.save", window, cx)))
.expect("the window lives");
+6 -4
View File
@@ -16,6 +16,8 @@
| XLSX 公式 | 公式文本与缓存结果:整数、日期序号、`#DIV/0!` 错误值、文本严格相等,浮点按用例声明的 1e-12 容差;第二次会话的公式 | 通过 |
| PPTX 演示 | 三张幻灯片、隐藏的那张仍隐藏、备注文字(含中文) | 通过 |
打印:macOS 经 PDFKit 的打印面板,Windows 经系统打印对话框(真窗口核对到 Microsoft Print to PDF),Linux 经桌面门户(只对替身门户核对,真桌面未核对);纸张取文档的,按原大印。打印机没有该纸张、或页面放不下所选纸张时报错,不裁切。Linux 由 Ely 照对话框的选择排页(页码范围、奇偶页、倒序、份数与逐份、缩放、每张页数),只交出 PDF。
字体:缺失字体由内核报出,ElyDoc 页面上方列出缺失的字体(`doc.fonts.missing`),分页可能与有字体时不同;字体一致与缺失两种环境的对照未做。产品不带微软字体。
## ElyDoc
@@ -24,7 +26,7 @@
| --- | --- |
| 编辑与保存 | DOCX、ODT(保存保留原格式;另存为默认 DOCX) |
| 导入或受限 | DOC、RTF 按原格式保存;含宏的文件(docm、dotm、带 Basic 的 ODF/OLE)进风险模式,只能另存副本,宏不运行 |
| 输出 | PDF(页码范围、批注、修订标记)、打印(macOS) |
| 输出 | PDF(页码范围、批注、修订标记)、打印 |
已验证:文字与段落格式、样式、页面与分节、页眉页脚、表格、图片锚点与环绕、目录、脚注尾注、书签、交叉引用、题注、公式、批注与回复、修订、查找替换、拼写(英文)、比较、邮件合并。限制:交叉引用只指向书签;公式导出 DOCX 后在 Word 中是否可编辑未验证。
@@ -34,7 +36,7 @@
| --- | --- |
| 编辑与保存 | XLSX、ODS(保存保留 xlsx、ods、xls、fods;另存为默认 XLSX) |
| 导入或受限 | XLS;CSV、TSV、TXT 经导入对话框(编码、分隔符、引号、逐列类型)成为新工作簿,原文件不动;xlsm、xltm 进风险模式 |
| 输出 | PDF(全部工作表,按页面设置)、CSV 与 TSV(当前工作表的显示值,UTF-8)、打印(macOS) |
| 输出 | PDF(全部工作表,按页面设置)、CSV 与 TSV(当前工作表的显示值,UTF-8)、打印 |
公式语法为 Excel A1。XLSX 与非 LibreOffice 生成的 ODF 载入时总是重算。限制:不支持的函数显示 `#NAME?`,原公式保留,缓存值不保留;外部链接不刷新,显示保存时的值;1904 日期系统未专门验证。
@@ -44,7 +46,7 @@
| --- | --- |
| 编辑与保存 | PPTX、ODP(另存为默认 PPTX) |
| 导入或受限 | PPT;章节(`p14:sectionLst`)内核不保留;媒体默认嵌入 |
| 输出 | PDF(幻灯片或讲义 1、2、3、4、6、9 张一页)、PNG(每张 1920 宽)、打印(macOS) |
| 输出 | PDF(幻灯片或讲义 1、2、3、4、6、9 张一页)、PNG(每张 1920 宽)、打印 |
限制:Ely 提供一组基础切换与动画,文件自带的其他效果照原样保留并显示内核名称;批注不做回复(Impress 无回复结构);Windows 与 Linux 的媒体播放未构建。
@@ -54,7 +56,7 @@
| --- | --- |
| 编辑与保存 | 页面增删移转裁剪、文字与图片改动、批注、表单字段(文本、复选、单选、选择框)、书签、链接、属性、附件、OCR 文字层、内容删除与清理、密码与权限、数字签名 |
| 导入或受限 | XFA 表单不支持;已签名文件另存为未签名副本;修复过的文件只能另存;多选列表框按单选填写 |
| 输出 | PDF、页面图片(PNG 72/150/300 dpi)、文本、优化的 PDF、打印(macOS) |
| 输出 | PDF、页面图片(PNG 72/150/300 dpi)、文本、优化的 PDF、打印 |
保存经 MuPDF 写快照后紧缩写出;签名经增量更新写出并重开核对摘要。独立阅读器 PDFKit 读回页序、改动的文字与字段值(`crates/ely-pdf-engine/tests/independent.rs`)。限制:证书吊销状态不核验;Windows 与 Linux 的证书信任未构建。
+1 -1
View File
@@ -281,6 +281,6 @@
- [x] R7.3 装了宋体仍报缺少(#4,§7、§8):内核按 `宋体` 找到 SimSun,缺字检查却比较原名与所用字体的英文名,报为缺失;查明 Linux 上的版面是否真用 SimSun,一页变两页的根因(补丁 0048:缺字检查比较 `GetEnglishSearchFontName`;Windows 与 WSL 内核已重建;`navigate/tests.rs` 的 `DEJAVU SANS` 用例修前失败、修后通过,ElyDoc 单线程 55 项通过;Arch 容器装 0.1.5 包与 simsun.ttc:修前报缺宋体,正文实为 SimSun,一页;换上新内核不再报缺,仍一页。一页变两页未能复现,需报告者的文件 — 第 1 轮 PASS)
- [x] R7.4 打开 PDF 总说已损坏(#5,§11):画图块时 MuPDF 的任何警告都算损坏,未嵌入的中日韩字体(“non-embedded font using identity encoding”)即触发;损坏只认结构修复、读不出的对象与流、画不出的图片与内容
- [x] R7.5 ElyMark 打开的正文块变成一块深色框(§14,用户定):按下的段落保持原样,不加底色与内边距,不挪位置;代码块仍有自己的底板
- [ ] R7.6 Windows 与 Linux 不能打印(#3,§19.3):`print_pdf` 只有 macOS;Windows 用系统打印对话框,Linux 经 xdg 桌面门户的打印接口
- [x] R7.6 Windows 与 Linux 不能打印(#3,§19.3):`print_pdf` 只有 macOS;Windows 用系统打印对话框,Linux 经 xdg 桌面门户的打印接口
- [ ] R7.7 发布 0.1.6:版本号,三端建包、签名公证、上传,合集包一并上传,本机装上
- [ ] R7.8 答复 #3 至 #6:每条写已修于 0.1.6;#6 的工作区建议另开 [FEATURE REQUEST] 并链过去
+11
View File
@@ -0,0 +1,11 @@
FAIL
- crates/ely-platform/src/print/linux.rs:52 — The PreparePrint response is reduced to its token; returned page ranges and scaling are discarded before sending the original PDF. With a three-page document, selecting only page 2 and 50% scaling still prints all three pages at their original size through the GTK portal backend. The application probe confirmed unchanged bytes were sent; an independent GTK 3.24.52 Print to File job confirmed the resulting output was byte-for-byte identical to the three-page input (`.work/r76-review-r1/linux-probe.log`, `gtk-backend-result.log`). Follow the portal's prepare/format/print contract and apply the returned page selection and layout settings exactly once; test the resulting pages and geometry. See the [Print portal contract](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Print.html).
- crates/ely-platform/src/print/win/printer.rs:60 — Driver normalization silently replaces unsupported custom paper, and the accepted dimensions are never checked. Printing a 1000×600-point PDF through Microsoft Print to PDF produced an 842×595-point page, cutting off the right-hand content, while print_pdf returned true. The only indication was a log warning (`.work/r76-review-r1/windows-probe.log`). Correct the custom-paper configuration and validate the accepted paper dimensions; if the printer cannot honor them, surface that failure instead of reporting a successful, cropped print. Add a custom-paper output regression.
- crates/ely-platform/src/print/linux.rs:127 — Box coordinates are treated as points without applying the page's /UserUnit. A valid PDF with MediaBox [0 0 421 297.5] and UserUnit 2 is A4 landscape (842×595 points), but the public print path sent custom_105x149mm to the portal. The added probe failed the A4 assertion (`.work/r76-review-r1/linux-probe.log`); the existing MuPDF UserUnit test passed independently. Multiply box dimensions by the page's UserUnit before choosing paper and applying rotation, and cover this in the print tests so printed paper agrees with the preview.
- crates/ely-platform/src/print/win.rs:160 — Every job returned by the system dialog has output: None, discarding PD_PRINTTOFILE and the DEVNAMES output field. When the user selects an ordinary printer and enables Windows 11's “Print to file” switch, StartDocW receives a null output destination and submits to that printer instead of requesting an output filename. The current integration test bypasses this branch using ELY_PRINT_TO. Preserve the dialog's file-output selection and pass FILE: to DOCINFO.lpszOutput as required by the [PrintDlgEx contract](https://learn.microsoft.com/en-us/windows/win32/api/commdlg/ns-commdlg-printdlgexw); cover the dialog-result-to-job mapping.
- crates/ely-platform/src/print/linux.rs:47 — PreparePrint omits supported_output_file_formats, advertising support for PDF, PostScript and SVG even though this path always supplies PDF bytes. On the GTK portal, choosing PostScript in Print to File therefore writes a PDF under the requested .ps filename. The real GTK backend probe reproduced this: the .ps file began with %PDF-1.4 and was identical to the PDF input (`.work/r76-review-r1/gtk-backend-result.log`). Advertise only pdf in the portal options and verify that restriction in the portal test; the [portal contract](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Print.html) explicitly treats an omitted format list as support for all formats.
+13
View File
@@ -0,0 +1,13 @@
FAIL
- crates/ely-platform/src/print/win.rs:210; crates/ely-platform/src/print/linux/sheets.rs:199 — The 2 mm fit allowance still permits silent clipping, so round 1's paper-size finding is only partly fixed. A 600×842-point PDF containing a black mark at x=597–599 returns `Ok(true)` through Microsoft Print to PDF, which produces a 595.32-point-wide A4 page with that mark missing. Linux composition onto A4 also succeeds and removes the mark; an interior control square survives both paths (`.work/r76-review-r2/paper-comparison.log`). Separate paper-name matching tolerance from actual content fit: reject overflow beyond device-rounding error before submitting the job. Add a near-standard-size regression with content at the page edge.
- crates/ely-platform/src/print/linux/sheets.rs:332 — Printable annotations without `/AP` are silently omitted. A valid PDF 1.7 Square annotation with `/F 4`, `/Rect [50 100 150 180]`, red `/C` and `/IC`, and no appearance stream renders 8,000 colored pixels in the real PDF worker, with `damaged=false`. Its `print_copy` still has no `/AP`; composing that copy succeeds but produces a completely blank page (`.work/r76-review-r2/worker-probe.log`, `content-comparison.log`). Generate missing printable appearances before flattening, or use a renderer that synthesizes them. Test rendered output from the actual print-copy path, including an annotation without `/AP`.
- crates/ely-platform/src/print/linux/sheets.rs:321 — Annotation placement ignores the `NoRotate` flag. On a page with `/Rotate 90`, a printable Square annotation with `/F 20`, `/Rect [50 100 150 180]`, and a 100×80 appearance is rotated with the page instead of retaining its orientation about its anchor. The worker's print copy preserves the correct appearance, but the composed output moves it to a disjoint rectangle: 16,000 pixels differ, representing the missing original and incorrectly placed replacement (`.work/r76-review-r2/content-comparison.log`). Account for `NoRotate` when composing the annotation transform and add a rendered regression on a rotated page.
- crates/ely-platform/src/print/linux/sheets.rs:268 — Generated annotation resource names overwrite existing page XObjects. With an existing `/ElyAnnot0` drawing a blue square and one printable red annotation, `objects.set("ElyAnnot0", look)` redirects the original page's `/ElyAnnot0 Do` to the annotation appearance. Printing the real worker's copy replaces the blue square with another red rectangle; the ordinary annotation control renders unchanged (`.work/r76-review-r2/content-comparison.log`, `probe-source.log`). Allocate annotation names that are absent from the inherited resource dictionary, preserve existing bindings, and test both original content and the added annotation.
- crates/ely-platform/src/print/linux/choices.rs:70 — The page-setup parser expects `reverse_portrait` and `reverse_landscape`, but GTK serializes these orientations as `reverse-portrait` and `reverse-landscape`. Selecting either reverse orientation therefore fails before printing. Actual `Gtk.PageSetup.to_gvariant()` output and the exact-source parser reproduce both errors (`.work/r76-review-r2/gtk-settings.log`, `linux-exact-probe.log`); the existing test uses the wrong underscore spelling. Parse the page-setup values specified by the [portal contract](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Print.html) and test serialized GTK values.
- crates/ely-platform/src/print/linux/choices.rs:98 — Open-ended GTK ranges cannot be parsed. Entering `2-` in the dialog produces start=1, end=-1, serialized as `page-ranges="1--1"`; parsing the endpoint as `u32` fails with `invalid digit found in string`. GTK serialization and the exact-source parser reproduce this (`.work/r76-review-r2/gtk-settings.log`, `linux-exact-probe.log`). The existing tests supply `1-`, which is not GTK's serialization of that choice. Recognize GTK's -1 endpoint sentinel, expand it to the document's last page, and test the resulting printed pages.
+7
View File
@@ -0,0 +1,7 @@
FAIL
- crates/ely-platform/src/print/win.rs:287 — Windows prints annotations using screen visibility instead of their print flags. Passing the real worker's print copies through Microsoft Print to PDF returns `Ok(true)` for both a Square annotation with `/F 0` (not printable) and one with `/F 36` (Print + NoView), but the first prints 8,100 red pixels and the second prints none; the `/F 4` control prints correctly (`.work/r76-review-r3/windows-render.log`, `worker-probe.log`). These flags explicitly distinguish screen visibility from printing ([PDF reference, Table 8.11](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf)). Render with print semantics, or prepare a copy containing only flattened printable appearances before using the screen renderer. Add rendered Windows regressions for both flags through `print_copy`.
- crates/ely-platform/src/print/linux.rs:69; crates/ely-platform/src/print/linux/sheets.rs:168 — Odd/even selection is applied locally and then applied again by the GTK/CUPS backend through the unchanged print token. For a four-page document with Even selected, the exact compositor submits original pages `[2, 4]`; the real GTK 3.24.52 CUPS backend still puts `cups-page-set=even` on its job (`.work/r76-review-r3/linux-probe.log`, `gtk-cups.log`). CUPS therefore selects only the second submitted sheet, losing original page 2. The [portal retains the chosen settings](https://github.com/flatpak/xdg-desktop-portal-gtk/blob/main/src/print.c), and [GTK forwards page-set to CUPS](https://github.com/GNOME/gtk/blob/gtk-3-24/modules/printbackends/cups/gtkprintbackendcups.c). Coordinate manual processing with backend processing so each setting is applied once, and cover the real CUPS backend as well as print-to-file; the current fake portal only records the supplied bytes.
- crates/ely-platform/src/print/linux/sheets.rs:40 — `Shown::of` uses CropBox without intersecting it with MediaBox, expanding the printed page and exposing clipped content. A PDF with MediaBox `[0 0 612 792]`, CropBox `[0 0 700 792]`, and a red square at x=650–680 opens with `damaged=false`; the worker's preview and print copy show a 612×792 page with only the interior blue control. Linux composition instead produces 700×792 and reveals the additional 900 red pixels (`.work/r76-review-r3/render-comparison.log`). PDF boundaries extending beyond MediaBox must be reduced to their intersection ([PDF reference, §8.6](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.3.pdf)). Use that effective rectangle consistently for sizing, placement, and the form's clipping box, with a rendered regression proving the outside content stays clipped.