From 89a059fb2dfa5bdb41a9ae3aaa14489e5a601b4c Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 26 Oct 2026 22:18:44 +0000 Subject: [PATCH 01/33] feat(installer): default to a per-user ArtCraft folder --- .github/workflows/packaging-lint.yml | 19 ++ packaging/windows/INSTALLATION.md | 35 +++ packaging/windows/check-install-policy.ps1 | 195 ++++++++++++++ packaging/windows/install-policy.json | 291 +++++++++++++++++++++ packaging/windows/install-policy.wxi | 54 ++++ packaging/windows/package.ps1 | 4 +- packaging/windows/test-install-policy.ps1 | 54 ++++ packaging/windows/wordcraft.wxs | 60 +---- 8 files changed, 664 insertions(+), 48 deletions(-) create mode 100644 packaging/windows/INSTALLATION.md create mode 100644 packaging/windows/check-install-policy.ps1 create mode 100644 packaging/windows/install-policy.json create mode 100644 packaging/windows/install-policy.wxi create mode 100644 packaging/windows/test-install-policy.ps1 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b9..7e847137f1 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,22 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 0000000000..982a5b4202 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 0000000000..dd28956435 --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,195 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + if (-not $explicit) { + if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + foreach ($choice in @('user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 0000000000..168670be9c --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,291 @@ +{ + "id": "wordcraft", + "name": "WordCraft", + "upgradeCode": "075497F9-E861-45D0-9C7F-A74F8C34E6BA", + "sources": [ + "wordcraft.wxs" + ], + "icon": "assets/app-icon/wordcraft.ico", + "localization": [], + "extensions": [ + "UI", + "Util" + ], + "payloads": [ + "wordcraft-cli.exe", + "wordcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\wordcraft.exe", + "", + "[#WordcraftExe]" + ], + [ + "-1", + "Software\\RegisteredApplications", + "WordCraft", + "Software\\WordCraft\\Capabilities" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities", + "ApplicationName", + "WordCraft" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities", + "ApplicationDescription", + "Write and design documents; open and save Word files." + ], + [ + "-1", + "Software\\WordCraft\\Capabilities", + "ApplicationIcon", + "\"[#WordcraftExe]\",0" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe", + "FriendlyAppName", + "WordCraft" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\WordCraft.Document\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.docx\\OpenWithProgids", + "WordCraft.Document", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".docx", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".docx", + "WordCraft.Document" + ], + [ + "-1", + "Software\\Classes\\WordCraft.DocumentMacroEnabled\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.docm\\OpenWithProgids", + "WordCraft.DocumentMacroEnabled", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".docm", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".docm", + "WordCraft.DocumentMacroEnabled" + ], + [ + "-1", + "Software\\Classes\\WordCraft.Template\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.dotx\\OpenWithProgids", + "WordCraft.Template", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".dotx", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".dotx", + "WordCraft.Template" + ], + [ + "-1", + "Software\\Classes\\WordCraft.TemplateMacroEnabled\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.dotm\\OpenWithProgids", + "WordCraft.TemplateMacroEnabled", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".dotm", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".dotm", + "WordCraft.TemplateMacroEnabled" + ], + [ + "-1", + "Software\\Classes\\WordCraft.LegacyDocument\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.doc\\OpenWithProgids", + "WordCraft.LegacyDocument", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".doc", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".doc", + "WordCraft.LegacyDocument" + ], + [ + "-1", + "Software\\Classes\\WordCraft.LegacyTemplate\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.dot\\OpenWithProgids", + "WordCraft.LegacyTemplate", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".dot", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".dot", + "WordCraft.LegacyTemplate" + ], + [ + "-1", + "Software\\Classes\\WordCraft.RichText\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.rtf\\OpenWithProgids", + "WordCraft.RichText", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".rtf", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".rtf", + "WordCraft.RichText" + ], + [ + "-1", + "Software\\Classes\\WordCraft.OpenDocumentText\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.odt\\OpenWithProgids", + "WordCraft.OpenDocumentText", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".odt", + "" + ], + [ + "-1", + "Software\\WordCraft\\Capabilities\\FileAssociations", + ".odt", + "WordCraft.OpenDocumentText" + ], + [ + "-1", + "Software\\Classes\\WordCraft.Markdown\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.md\\OpenWithProgids", + "WordCraft.Markdown", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".md", + "" + ], + [ + "-1", + "Software\\Classes\\WordCraft.PlainText\\shell\\open\\command", + "", + "\"[#WordcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.txt\\OpenWithProgids", + "WordCraft.PlainText", + "" + ], + [ + "-1", + "Software\\Classes\\Applications\\wordcraft.exe\\SupportedTypes", + ".txt", + "" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 0000000000..0a23e558bf --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index e89a25e432..3289a7ba6a 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -156,9 +156,11 @@ foreach ($build in @(@($Msi, 'perMachine'), @($UserMsi, 'perUserOrMachine'))) { -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\wordcraft.ico')" ` -d "LicenseRtf=$LicenseRtf" ` - -d "AppNoticesDir=$AppNotices" -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" -d "Scope=$($build[1])" ` + -d "AppNoticesDir=$AppNotices" -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" -d "Scope=perUserOrMachine" ` -o $out } + # Check the compiled scope, path and registration contract before signing. + & (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($out, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $out diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 0000000000..21199ca7f4 --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch -wx @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} diff --git a/packaging/windows/wordcraft.wxs b/packaging/windows/wordcraft.wxs index 5be96705b1..bd28f31e19 100644 --- a/packaging/windows/wordcraft.wxs +++ b/packaging/windows/wordcraft.wxs @@ -1,47 +1,14 @@ - - - - - - + + + - + @@ -59,16 +26,16 @@ - + - + - + + + @@ -76,7 +43,7 @@ - + @@ -89,16 +56,15 @@ - + - + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 3289a7ba6a..9a0941f344 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -160,7 +160,9 @@ foreach ($build in @(@($Msi, 'perMachine'), @($UserMsi, 'perUserOrMachine'))) { -o $out } # Check the compiled scope, path and registration contract before signing. - & (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $out + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $out." } # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($out, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $out diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 index 21199ca7f4..a574122c08 100644 --- a/packaging/windows/test-install-policy.ps1 +++ b/packaging/windows/test-install-policy.ps1 @@ -12,6 +12,7 @@ $root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json [void](New-Item -ItemType Directory -Force -Path $OutputDirectory) $output = (Resolve-Path -LiteralPath $OutputDirectory).Path +& (Join-Path $PSScriptRoot 'test-packaging-loop.ps1') Add-Type -AssemblyName System.Drawing foreach ($arch in $Architectures) { $stage = Join-Path $output $arch diff --git a/packaging/windows/test-packaging-loop.ps1 b/packaging/windows/test-packaging-loop.ps1 new file mode 100644 index 0000000000..94b73c6c5a --- /dev/null +++ b/packaging/windows/test-packaging-loop.ps1 @@ -0,0 +1,21 @@ +<# Check the real packaging loop without compiling, signing or installing. #> +$ErrorActionPreference = 'Stop' +$tokens = $null; $errors = $null +$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'package.ps1'), [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw 'The packaging script does not parse.' } +$loop = @($ast.FindAll({ param($node) + $node -is [Management.Automation.Language.ForEachStatementAst] -and $node.Variable.VariablePath.UserPath -eq 'build' +}, $true)) +if ($loop.Count -ne 1) { throw 'Expected one artifact packaging loop.' } +$checks = @($loop[0].FindAll({ param($node) + $node -is [Management.Automation.Language.CommandAst] -and $node.InvocationOperator.ToString() -eq 'Ampersand' -and + @($node.CommandElements | Where-Object { $_.Extent.Text -match 'check-install-policy.ps1' }).Count -gt 0 +}, $true)) +if ($checks.Count -ne 1) { throw 'Every packaging iteration needs its own policy check.' } +$elements = $checks[0].CommandElements +$index = 0 +while ($index -lt $elements.Count -and $elements[$index].Extent.Text -ne '-MsiPath') { $index++ } +if ($index + 1 -ge $elements.Count -or $elements[$index + 1].Extent.Text -ne '$out') { + throw 'The policy check must inspect the current artifact ($out), not the first MSI.' +} +Write-Output 'PASS each WordCraft artifact is checked before signing.' From 1b0a59d4d225c89fb286728e5685bd4e5e56a741 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 11:46:37 +0000 Subject: [PATCH 03/33] fix(installer): harden install-policy checks and packaging-lint workflow --- .github/workflows/packaging-lint.yml | 9 +++++++-- packaging/windows/check-install-policy.ps1 | 13 ++++++++++--- packaging/windows/test-install-policy.ps1 | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 7e847137f1..ea61d84303 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -28,6 +28,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install the pinned packaging toolchain shell: pwsh run: | @@ -35,8 +37,11 @@ jobs: dotnet tool install --tool-path $tools wix --version 5.0.2 if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } $tools >> $env:GITHUB_PATH - & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } - name: Build and check non-executable installer fixtures shell: pwsh run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 00b683d708..451295a2e4 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -122,9 +122,11 @@ try { Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } - if (-not $explicit) { - if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } - } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } } @@ -145,6 +147,11 @@ try { throw 'Silent updates must restore the folder before directory costing.' } } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) # Model the full-UI upgrade entry with an existing machine-wide copy. Run diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 index a574122c08..23e51c8078 100644 --- a/packaging/windows/test-install-policy.ps1 +++ b/packaging/windows/test-install-policy.ps1 @@ -39,7 +39,7 @@ foreach ($arch in $Architectures) { $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) - & wix build @sources -arch $arch -wx @extensions ` + & wix build @sources -arch $arch @extensions ` -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` From 3031870ce3b80c9e6f402c66055032cad896812a Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:09:04 +0000 Subject: [PATCH 04/33] docs(installer): describe the per-user default install location and scope --- README.md | 9 +++++---- packaging/windows/package.ps1 | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b0445a01eb..130fdd60dd 100644 --- a/README.md +++ b/README.md @@ -227,10 +227,11 @@ closer pagination fidelity, a stability sweep and native printing. Details and e | arm64 (Snapdragon and other ARM PCs) | `wordcraft--windows-arm64.msi` | `wordcraft--windows-user-arm64.msi` | `wordcraft--windows-arm64-portable.zip` | | x86 (32-bit) | `wordcraft--windows-x86.msi` | `wordcraft--windows-user-x86.msi` | `wordcraft--windows-x86-portable.zip` | -The all-users installer puts WordCraft in Program Files and needs administrator rights. The -just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your -account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two -in the Start menu. Installers and executables are code-signed. +Both installers are the same dual-scope package. By default it installs for your account only, +into `%LOCALAPPDATA%\Programs\ArtCraft\WordCraft`, and needs no administrator rights; choosing +"Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` +and asks for an administrator. Install only one copy: a copy for another scope is refused, so +uninstall first to change who WordCraft is installed for. Installers and executables are code-signed. **If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default. Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 9a0941f344..3030cfb6e0 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -143,10 +143,10 @@ $rtf = '{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\fswiss\fcharset0 Tahoma;}}\vi ($paragraphs -join "\par`r`n") + "\par`r`n}`r`n" [System.IO.File]::WriteAllText($LicenseRtf, $rtf, [System.Text.Encoding]::ASCII) -# Two MSIs from one source: per-machine (Program Files, needs admin) and per-user (#39: no admin, -# into %LOCALAPPDATA%\Programs; see wordcraft.wxs). The per-user name puts "user" before the arch -# so the per-machine MSI sorts first: the shared windows-arm64 workflow installs the first *.msi -# it finds and checks Program Files. +# Two MSI file names from one dual-scope source (see wordcraft.wxs and install-policy.wxi): per user by +# default into %LOCALAPPDATA%\Programs\ArtCraft\WordCraft (#39), per machine into Program Files\ArtCraft +# on request. The user name puts "user" before the arch so the plain name sorts first: the shared +# windows-arm64 workflow installs the first *.msi it finds and checks the per-user folder. $Msi = Join-Path $Dist "wordcraft-$Version-windows-$Arch.msi" $UserMsi = Join-Path $Dist "wordcraft-$Version-windows-user-$Arch.msi" foreach ($build in @(@($Msi, 'perMachine'), @($UserMsi, 'perUserOrMachine'))) { From 72b8818ea681a163cf9764ba3b30f7a528e80e95 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:17:18 +0000 Subject: [PATCH 05/33] packaging(windows): ship one dual-scope MSI The package is dual-scope, so the separate -windows-user MSI was the same package under a second name. Build and policy-check the single plain-named MSI before signing, which is the name every sibling app, the launcher and the shared windows-arm64 workflow use. Replace the loop test with one that checks the MSI is policy-checked once, before it is signed. --- README.md | 12 +++--- docs/releasing.md | 2 +- packaging/windows/package.ps1 | 46 ++++++++++----------- packaging/windows/test-install-policy.ps1 | 2 +- packaging/windows/test-packaging-loop.ps1 | 21 ---------- packaging/windows/test-packaging-script.ps1 | 29 +++++++++++++ 6 files changed, 58 insertions(+), 54 deletions(-) delete mode 100644 packaging/windows/test-packaging-loop.ps1 create mode 100644 packaging/windows/test-packaging-script.ps1 diff --git a/README.md b/README.md index 130fdd60dd..08c7382b23 100644 --- a/README.md +++ b/README.md @@ -221,13 +221,13 @@ closer pagination fidelity, a stability sweep and native printing. Details and e ### Windows -| Build | Installer (all users) | Installer (just me) | Portable | -|---|---|---|---| -| x64 (64-bit Intel/AMD) | `wordcraft--windows-x64.msi` | `wordcraft--windows-user-x64.msi` | `wordcraft--windows-x64-portable.zip` | -| arm64 (Snapdragon and other ARM PCs) | `wordcraft--windows-arm64.msi` | `wordcraft--windows-user-arm64.msi` | `wordcraft--windows-arm64-portable.zip` | -| x86 (32-bit) | `wordcraft--windows-x86.msi` | `wordcraft--windows-user-x86.msi` | `wordcraft--windows-x86-portable.zip` | +| Build | Installer | Portable | +|---|---|---| +| x64 (64-bit Intel/AMD) | `wordcraft--windows-x64.msi` | `wordcraft--windows-x64-portable.zip` | +| arm64 (Snapdragon and other ARM PCs) | `wordcraft--windows-arm64.msi` | `wordcraft--windows-arm64-portable.zip` | +| x86 (32-bit) | `wordcraft--windows-x86.msi` | `wordcraft--windows-x86-portable.zip` | -Both installers are the same dual-scope package. By default it installs for your account only, +The installer is one dual-scope package. By default it installs for your account only, into `%LOCALAPPDATA%\Programs\ArtCraft\WordCraft`, and needs no administrator rights; choosing "Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` and asks for an administrator. Install only one copy: a copy for another scope is refused, so diff --git a/docs/releasing.md b/docs/releasing.md index fc2503550a..17d41e28cf 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -11,7 +11,7 @@ this file has the WordCraft specifics. | Platform | Artifacts | Signing | |---|---|---| | macOS | universal `WordCraft.app` in a `.dmg`, universal CLI zip | Developer ID + notarization (`APPLE_*` secrets) | -| Windows | x64, x86 and arm64 `.msi` (per-machine, and a per-user `-windows-user-.msi` that needs no admin rights) + portable `.zip` | Azure Trusted Signing (`AZURE_*` secrets) | +| Windows | x64, x86 and arm64 `.msi` (one dual-scope package: per-user by default, needs no admin rights; "Everyone on this computer" installs per machine) + portable `.zip` | Azure Trusted Signing (`AZURE_*` secrets) | | Linux | x86_64 and aarch64 `.AppImage` (+ `.zsync` for AppImageUpdate), `.flatpak` bundle, `.deb`, `.rpm`, `.tar.gz`; Flathub manifest | checksums | | Linux riscv64 | `.tar.gz` (cross-compiled on `ubuntu-24.04`, glibc >= 2.39; CLI smoke-tested under QEMU) | checksums | | FreeBSD | x86_64 `.tar.gz` | checksums | diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 3030cfb6e0..3126a18797 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -4,8 +4,9 @@ .DESCRIPTION Produces, in $env:DIST (default: dist/release): - wordcraft--windows-.msi per-machine installer (WiX v5) - wordcraft--windows-user-.msi per-user installer, no admin rights (#39) + wordcraft--windows-.msi installer (WiX v5), one dual-scope package: for the + current user by default, no admin rights (#39); + "Everyone on this computer" installs per machine wordcraft--windows--portable.zip wordcraft.exe + wordcraft-cli.exe The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable @@ -143,30 +144,25 @@ $rtf = '{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\fswiss\fcharset0 Tahoma;}}\vi ($paragraphs -join "\par`r`n") + "\par`r`n}`r`n" [System.IO.File]::WriteAllText($LicenseRtf, $rtf, [System.Text.Encoding]::ASCII) -# Two MSI file names from one dual-scope source (see wordcraft.wxs and install-policy.wxi): per user by -# default into %LOCALAPPDATA%\Programs\ArtCraft\WordCraft (#39), per machine into Program Files\ArtCraft -# on request. The user name puts "user" before the arch so the plain name sorts first: the shared -# windows-arm64 workflow installs the first *.msi it finds and checks the per-user folder. +# One MSI from one dual-scope source (see wordcraft.wxs and install-policy.wxi): per user by default into +# %LOCALAPPDATA%\Programs\ArtCraft\WordCraft (#39), per machine into Program Files\ArtCraft on request. +# The plain name is the one every sibling app, the launcher and the shared windows-arm64 workflow expect. $Msi = Join-Path $Dist "wordcraft-$Version-windows-$Arch.msi" -$UserMsi = Join-Path $Dist "wordcraft-$Version-windows-user-$Arch.msi" -foreach ($build in @(@($Msi, 'perMachine'), @($UserMsi, 'perUserOrMachine'))) { - $out = $build[0] - Invoke-Native "wix build ($($build[1]))" { - wix build (Join-Path $PSScriptRoot 'wordcraft.wxs') -arch $Arch ` - -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` - -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\wordcraft.ico')" ` - -d "LicenseRtf=$LicenseRtf" ` - -d "AppNoticesDir=$AppNotices" -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" -d "Scope=perUserOrMachine" ` - -o $out - } - # Check the compiled scope, path and registration contract before signing. - $shell = (Get-Process -Id $PID).Path - & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $out - if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $out." } - # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. - Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($out, '.wixpdb')) - & (Join-Path $PSScriptRoot 'sign.ps1') $out +Invoke-Native "wix build" { + wix build (Join-Path $PSScriptRoot 'wordcraft.wxs') -arch $Arch ` + -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` + -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\wordcraft.ico')" ` + -d "LicenseRtf=$LicenseRtf" ` + -d "AppNoticesDir=$AppNotices" -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" -d "Scope=perUserOrMachine" ` + -o $Msi } +# Check the compiled scope, path and registration contract before signing. +$shell = (Get-Process -Id $PID).Path +& $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi +if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $Msi." } +# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. +Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) +& (Join-Path $PSScriptRoot 'sign.ps1') $Msi # ---- portable zip ------------------------------------------------------------------------------ $Portable = Join-Path $TargetDir "windows-package\wordcraft-$Version-windows-$Arch-portable" @@ -195,4 +191,4 @@ if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') { } else { Write-Output "skipping wordcraft-cli --version: an $Arch build doesn't run on this $HostArch machine" } -Get-Item $Msi, $UserMsi, $Zip | Format-Table Name, Length +Get-Item $Msi, $Zip | Format-Table Name, Length diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 index 23e51c8078..8049bc72d9 100644 --- a/packaging/windows/test-install-policy.ps1 +++ b/packaging/windows/test-install-policy.ps1 @@ -12,7 +12,7 @@ $root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json [void](New-Item -ItemType Directory -Force -Path $OutputDirectory) $output = (Resolve-Path -LiteralPath $OutputDirectory).Path -& (Join-Path $PSScriptRoot 'test-packaging-loop.ps1') +& (Join-Path $PSScriptRoot 'test-packaging-script.ps1') Add-Type -AssemblyName System.Drawing foreach ($arch in $Architectures) { $stage = Join-Path $output $arch diff --git a/packaging/windows/test-packaging-loop.ps1 b/packaging/windows/test-packaging-loop.ps1 deleted file mode 100644 index 94b73c6c5a..0000000000 --- a/packaging/windows/test-packaging-loop.ps1 +++ /dev/null @@ -1,21 +0,0 @@ -<# Check the real packaging loop without compiling, signing or installing. #> -$ErrorActionPreference = 'Stop' -$tokens = $null; $errors = $null -$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'package.ps1'), [ref]$tokens, [ref]$errors) -if ($errors.Count) { throw 'The packaging script does not parse.' } -$loop = @($ast.FindAll({ param($node) - $node -is [Management.Automation.Language.ForEachStatementAst] -and $node.Variable.VariablePath.UserPath -eq 'build' -}, $true)) -if ($loop.Count -ne 1) { throw 'Expected one artifact packaging loop.' } -$checks = @($loop[0].FindAll({ param($node) - $node -is [Management.Automation.Language.CommandAst] -and $node.InvocationOperator.ToString() -eq 'Ampersand' -and - @($node.CommandElements | Where-Object { $_.Extent.Text -match 'check-install-policy.ps1' }).Count -gt 0 -}, $true)) -if ($checks.Count -ne 1) { throw 'Every packaging iteration needs its own policy check.' } -$elements = $checks[0].CommandElements -$index = 0 -while ($index -lt $elements.Count -and $elements[$index].Extent.Text -ne '-MsiPath') { $index++ } -if ($index + 1 -ge $elements.Count -or $elements[$index + 1].Extent.Text -ne '$out') { - throw 'The policy check must inspect the current artifact ($out), not the first MSI.' -} -Write-Output 'PASS each WordCraft artifact is checked before signing.' diff --git a/packaging/windows/test-packaging-script.ps1 b/packaging/windows/test-packaging-script.ps1 new file mode 100644 index 0000000000..662c043092 --- /dev/null +++ b/packaging/windows/test-packaging-script.ps1 @@ -0,0 +1,29 @@ +<# Check the real packaging script without compiling, signing or installing: one MSI, policy-checked before it is signed. #> +$ErrorActionPreference = 'Stop' +$tokens = $null; $errors = $null +$script = Join-Path $PSScriptRoot 'package.ps1' +$ast = [Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$errors) +if ($errors.Count) { throw 'The packaging script does not parse.' } +if ($ast.Extent.Text -match 'windows-user|UserMsi') { + throw 'WordCraft ships one dual-scope MSI; there is no separate per-user installer to package.' +} +$commands = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] }, $true)) +$checks = @($commands | Where-Object { + $_.InvocationOperator.ToString() -eq 'Ampersand' -and + @($_.CommandElements | Where-Object { $_.Extent.Text -match 'check-install-policy.ps1' }).Count -gt 0 +}) +if ($checks.Count -ne 1) { throw 'Expected exactly one policy check in the packaging script.' } +$elements = $checks[0].CommandElements +$index = 0 +while ($index -lt $elements.Count -and $elements[$index].Extent.Text -ne '-MsiPath') { $index++ } +if ($index + 1 -ge $elements.Count -or $elements[$index + 1].Extent.Text -ne '$Msi') { + throw 'The policy check must inspect the MSI being packaged ($Msi).' +} +$signs = @($commands | Where-Object { + $_.InvocationOperator.ToString() -eq 'Ampersand' -and + @($_.CommandElements | Where-Object { $_.Extent.Text -match 'sign.ps1' }).Count -gt 0 -and + $_.CommandElements[-1].Extent.Text -eq '$Msi' +}) +if ($signs.Count -ne 1) { throw 'Expected the MSI to be signed exactly once.' } +if ($checks[0].Extent.StartOffset -ge $signs[0].Extent.StartOffset) { throw 'The MSI must pass the policy check before it is signed.' } +Write-Output 'PASS the WordCraft MSI is policy-checked before it is signed.' From d603983a6153692d2897e1f561a03bce75558a0e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:25:21 +0000 Subject: [PATCH 06/33] docs(installer): note that the 32-bit all-users folder is Program Files (x86); tidy wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 08c7382b23..fbe286f7be 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ closer pagination fidelity, a stability sweep and native printing. Details and e The installer is one dual-scope package. By default it installs for your account only, into `%LOCALAPPDATA%\Programs\ArtCraft\WordCraft`, and needs no administrator rights; choosing -"Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` +"Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` (`Program Files (x86)` for the 32-bit build) and asks for an administrator. Install only one copy: a copy for another scope is refused, so uninstall first to change who WordCraft is installed for. Installers and executables are code-signed. From ffa35d62dbac6c9f4dc0bf24a7b78325e11f2c1d Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 15:32:42 +0000 Subject: [PATCH 07/33] fix(installer): resolve the wizard scope and folder correctly Real msiexec runs (per-user and all-users, silent and wizard) found two defects that the metadata fixtures cannot see: - "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as per-machine and skipped the existing per-user copy. An interactive upgrade left two registered products and uninstall left files behind. The page now leaves ALLUSERS unset for a per-user choice. - Windows Installer resolves Program Files once, for the context it started in, so choosing "Everyone" in the wizard installed into the user profile with a machine-wide registration. The wizard now sets the new-install folder itself after the choice (Program Files, Program Files (x86) for 32-bit, or the per-user Programs folder). Silent installs are unchanged. check-install-policy.ps1 now fails if either regresses. --- packaging/windows/check-install-policy.ps1 | 24 +++++++++++++++++++++- packaging/windows/install-policy.wxi | 15 +++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 451295a2e4..a4a14bc648 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -140,6 +140,28 @@ try { $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } + # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. + # The wizard therefore sets the new-install folder itself, and only in the wizard. + $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' }) + if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' } + foreach ($row in $defaults) { + if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." } + } + if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { + throw 'Silent installs must not use the wizard-only default folder actions.' + } + foreach ($scope in @('1', '')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' '' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } + if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { @@ -177,7 +199,7 @@ try { $allUsers = Get-SessionProperty $session 'ALLUSERS' $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or - ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) { throw 'Scope selector does not reset the requested installation context.' } } diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi index ffb78406f8..c5bc406c09 100644 --- a/packaging/windows/install-policy.wxi +++ b/packaging/windows/install-policy.wxi @@ -1,6 +1,9 @@ + @@ -25,6 +28,16 @@ + + + + + + + + + @@ -42,7 +55,7 @@ - + From 54e1b1e7f345cd562e53b966e27fe43ea5d1d5f0 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 19:13:02 +0000 Subject: [PATCH 08/33] fix(installer): check the exact architecture-specific machine folder The pre-signing check matched any folder named Program Files, so it rejected a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit root for an x86 package and accepted a different app name. Build the expected folder from the package architecture's resolved root and the policy name. --- packaging/windows/check-install-policy.ps1 | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index a4a14bc648..cc9416e9c9 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -32,6 +32,12 @@ function Set-SessionProperty($Session, [string] $Name, [string] $Value) { function Get-SessionProperty($Session, [string] $Name) { $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) } +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} $installer = $null; $database = $null; $session = $null try { $installer = New-Object -ComObject WindowsInstaller.Installer @@ -150,6 +156,12 @@ try { if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { throw 'Silent installs must not use the wizard-only default folder actions.' } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' foreach ($scope in @('1', '')) { Set-SessionProperty $session 'ALLUSERS' $scope Set-SessionProperty $session 'INSTALLFOLDER' '' @@ -159,8 +171,8 @@ try { if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } } $folder = Get-SessionProperty $session 'INSTALLFOLDER' - $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } - if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } From 0bd3420553269a4ad2a0a81ff59721c0a16d5cbc Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 27 Oct 2026 20:51:47 +0000 Subject: [PATCH 09/33] ci: use storytold/craft-common-actions at the merged installer-policy commit --- .github/workflows/windows-arm64.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index c09f5bd2bb..85a3f19518 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,7 +27,7 @@ permissions: jobs: windows-arm64: # Immutable supporting workflow revision until the new install contract lands upstream. - uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: wordcraft app-dir: WordCraft From 1d3e6d9ca8ccfe03c904f4ad89b85806a0683e7a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 01:06:47 +0000 Subject: [PATCH 10/33] Refactor doc for strict Clippy checks --- crates/doc/src/block.rs | 2 +- crates/doc/src/compare.rs | 2 +- crates/doc/src/document.rs | 4 +- crates/doc/src/math_edit.rs | 119 +++---- crates/doc/src/math_latex.rs | 447 ++++++++++++++------------- crates/doc/src/para/float.rs | 2 +- crates/doc/src/para/inline_kinds.rs | 2 +- crates/doc/src/para/inline_object.rs | 2 +- crates/doc/src/para/run.rs | 2 +- crates/doc/src/parts.rs | 2 +- crates/doc/src/position.rs | 2 +- crates/doc/src/props/char_props.rs | 40 ++- crates/doc/src/props/color.rs | 2 +- crates/doc/src/props/para_props.rs | 23 +- crates/doc/src/props/table_props.rs | 2 +- crates/doc/src/review.rs | 2 +- crates/doc/src/settings.rs | 2 +- crates/doc/src/text_walk.rs | 2 +- 18 files changed, 362 insertions(+), 297 deletions(-) diff --git a/crates/doc/src/block.rs b/crates/doc/src/block.rs index 251aa6e849..540876ec1d 100644 --- a/crates/doc/src/block.rs +++ b/crates/doc/src/block.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Deserialize, Paragraph, Serialize, Table}; /// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.) #[allow(clippy::large_enum_variant)] diff --git a/crates/doc/src/compare.rs b/crates/doc/src/compare.rs index 41ca3ca3d5..dc41383adb 100644 --- a/crates/doc/src/compare.rs +++ b/crates/doc/src/compare.rs @@ -1,6 +1,6 @@ use super::block::{Block, Blocks}; use super::parts::Part; -use super::*; +use super::{Arc, BTreeMap, BuildingBlock, Cell, Paragraph, Row, Table}; pub fn para_block(p: Paragraph) -> Arc { Arc::new(Block::Para(p)) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 318bfcc330..7d116858c2 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -5,7 +5,9 @@ use super::parts::{MAX_LINKED_BOXES, NoteSeparators, Part, PartKind, PermRange, use super::position::{Path, Pos, StoryRef}; use super::settings::{CoreProps, CustomProp, Settings}; use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk}; -use super::*; +use std::sync::Arc; + +use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section}; /// [`Document::media`] by content, so adding media is linear in the bytes added rather than a /// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back; diff --git a/crates/doc/src/math_edit.rs b/crates/doc/src/math_edit.rs index bc64d43cc4..9140a73cd1 100644 --- a/crates/doc/src/math_edit.rs +++ b/crates/doc/src/math_edit.rs @@ -1061,63 +1061,8 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str *kind = ScriptKind::SubSup; here(if action == "script.addSup" { 2 } else { 1 }) } - (MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => { - let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(1).max(1); - // Row and column of the caret's cell. - let mut k = 0; - let mut rc = (0, 0); - for (r, row) in rows.iter().enumerate() { - if c < k + row.len() { - rc = (r, c - k); - break; - } - k += row.len(); - } - let (r, col) = rc; - let (mut tr, mut tc) = (r, col); - match a { - "matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => { - let at = if a == "matrix.rowAbove" { r } else { r + 1 }; - rows.insert(at.min(rows.len()), vec![Vec::new(); ncols]); - tr = at; - } - "matrix.colBefore" | "matrix.colAfter" if ncols < 64 => { - let at = if a == "matrix.colBefore" { col } else { col + 1 }; - for row in rows.iter_mut() { - row.insert(at.min(row.len()), Vec::new()); - } - tc = at; - } - "matrix.deleteRow" if rows.len() > 1 => { - rows.remove(r.min(rows.len() - 1)); - tr = r.min(rows.len() - 1); - } - "matrix.deleteCol" if ncols > 1 => { - for row in rows.iter_mut() { - if col < row.len() { - row.remove(col); - } - } - tc = col.min(ncols - 2); - } - _ => {} - } - let flat: usize = rows.iter().take(tr).map(|x| x.len()).sum::() + tc; - here(flat) - } - (MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => match a { - "eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => { - let at = if a == "eqarr.rowAbove" { c } else { c + 1 }.min(rows.len()); - rows.insert(at, Vec::new()); - here(at) - } - "eqarr.deleteRow" if rows.len() > 1 => { - let k = c.min(rows.len() - 1); - rows.remove(k); - here(k.min(rows.len() - 1)) - } - _ => inner, - }, + (MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => apply_matrix_action(rows, c, a, &here), + (MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => apply_equation_array_action(rows, c, a, &here, inner), (MNode::GroupChr { top, chr, .. }, "group.flip") => { *top = !*top; *chr = match *chr { @@ -1141,6 +1086,66 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str Some(clamp(root, &out)) } +fn apply_matrix_action(rows: &mut Vec>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos) -> MathPos { + let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); + let (mut row_index, mut column) = (0, 0); + let mut offset = 0; + for (index, row) in rows.iter().enumerate() { + if caret < offset + row.len() { + row_index = index; + column = caret - offset; + break; + } + offset += row.len(); + } + let (mut target_row, mut target_column) = (row_index, column); + match action { + "matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => { + let at = if action == "matrix.rowAbove" { row_index } else { row_index + 1 }; + rows.insert(at.min(rows.len()), vec![Vec::new(); columns]); + target_row = at; + } + "matrix.colBefore" | "matrix.colAfter" if columns < 64 => { + let at = if action == "matrix.colBefore" { column } else { column + 1 }; + for row in rows.iter_mut() { + row.insert(at.min(row.len()), Vec::new()); + } + target_column = at; + } + "matrix.deleteRow" if rows.len() > 1 => { + rows.remove(row_index.min(rows.len() - 1)); + target_row = row_index.min(rows.len() - 1); + } + "matrix.deleteCol" if columns > 1 => { + for row in rows.iter_mut() { + if column < row.len() { + row.remove(column); + } + } + target_column = column.min(columns - 2); + } + _ => {} + } + let flat = rows.iter().take(target_row).map(Vec::len).sum::() + target_column; + here(flat) +} + +fn apply_equation_array_action(rows: &mut Vec, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos, inner: MathPos) -> MathPos { + match action { + "eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => { + let at = (if action == "eqarr.rowAbove" { caret } else { caret + 1 }).min(rows.len()); + rows.insert(at, Vec::new()); + here(at) + } + "eqarr.deleteRow" if rows.len() > 1 => { + let index = caret.min(rows.len() - 1); + rows.remove(index); + here(index.min(rows.len() - 1)) + } + _ => inner, + } +} + /// Set the limit placement of n-ary operators (`None`: Word's default by operator). pub fn set_limits(nodes: &mut Arg, loc: Option) { for n in nodes.iter_mut() { diff --git a/crates/doc/src/math_latex.rs b/crates/doc/src/math_latex.rs index 526b7966e1..92979534ce 100644 --- a/crates/doc/src/math_latex.rs +++ b/crates/doc/src/math_latex.rs @@ -863,233 +863,252 @@ fn group_chr_tex(chr: char, top: bool) -> &'static str { } } -fn node(n: &MNode, s: &mut String, depth: usize) { - match n { - MNode::Run(r) => { - let (open, close) = if r.nor { - ("\\text{", "}") - } else { - match (r.sty, r.scr) { - (_, MScr::DoubleStruck) => ("\\mathbb{", "}"), - (_, MScr::Script) => ("\\mathcal{", "}"), - (_, MScr::Fraktur) => ("\\mathfrak{", "}"), - (_, MScr::SansSerif) => ("\\mathsf{", "}"), - (_, MScr::Monospace) => ("\\mathtt{", "}"), - (Some(MSty::Bold), _) => ("\\mathbf{", "}"), - (Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"), - (Some(MSty::Plain), _) if r.text.chars().any(|c| c.is_alphabetic()) => { - if FUNCTION_NAMES.contains(&r.text.as_str()) { - s.push('\\'); - s.push_str(&r.text); - s.push(' '); - return; - } - ("\\mathrm{", "}") - } - _ => ("", ""), - } - }; - s.push_str(open); - if r.nor { - text_chars(&r.text, s); - } else { - chars(&r.text, s); - } - s.push_str(close); - } - MNode::Frac { kind, num, den } => match kind { - FracKind::NoBar => { - s.push_str("\\genfrac{}{}{0pt}{}"); - braced(num, s, depth); - braced(den, s, depth); - } - FracKind::Linear | FracKind::Skewed => { - braced(num, s, depth); - s.push('/'); - braced(den, s, depth); - } - FracKind::Bar => { - s.push_str("\\frac"); - braced(num, s, depth); - braced(den, s, depth); - } - }, - MNode::Script { kind, base, sub, sup } => { - if *kind == ScriptKind::Pre { - s.push_str("{}_"); - braced(sub, s, depth); - s.push('^'); - braced(sup, s, depth); - braced(base, s, depth); - return; - } - braced(base, s, depth); - if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { - s.push('_'); - braced(sub, s, depth); - } - if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { - s.push('^'); - braced(sup, s, depth); - } - } - MNode::Rad { deg, deg_hide, e } => { - s.push_str("\\sqrt"); - if !deg_hide && !deg.is_empty() { - s.push('['); - arg(deg, s, depth); - s.push(']'); - } - braced(e, s, depth); - } - MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } => { - match name_of(*chr) { - Some(n) => { - s.push('\\'); - s.push_str(n); - } - None => s.push(*chr), - } - match lim_loc { - Some(crate::math::LimLoc::UndOvr) => s.push_str("\\limits"), - Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => s.push_str("\\nolimits"), - _ => {} - } - if !sub_hide && !sub.is_empty() { - s.push('_'); - braced(sub, s, depth); - } - if !sup_hide && !sup.is_empty() { - s.push('^'); - braced(sup, s, depth); - } - s.push(' '); - braced(e, s, depth); - } - MNode::Delim { beg, end, sep, elems, .. } => { - s.push_str("\\left"); - s.push_str(&delim_tex(*beg)); - let mid = match sep { - Some(c) => format!("\\middle{}", delim_tex(Some(*c))), - None => "\\middle|".to_string(), - }; - for (i, e) in elems.iter().enumerate() { - if i > 0 { - s.push_str(&mid); - } - arg(e, s, depth); - } - s.push_str("\\right"); - s.push_str(&delim_tex(*end)); - } +fn node(node: &MNode, output: &mut String, depth: usize) { + match node { + MNode::Run(run) => run_node(run, output), + MNode::Frac { kind, num, den } => fraction_node(*kind, num, den, output, depth), + MNode::Script { kind, base, sub, sup } => script_node(*kind, base, sub, sup, output, depth), + MNode::Rad { deg, deg_hide, e } => radical_node(deg, *deg_hide, e, output, depth), + MNode::Nary { .. } => nary_node(node, output, depth), + MNode::Delim { beg, end, sep, elems, .. } => delimiter_node(*beg, *end, *sep, elems, output, depth), MNode::Func { name, e } => { - arg(name, s, depth); - braced(e, s, depth); - } - MNode::Lim { upper, e, lim } => { - // `\lim_{…}` keeps its name; other bases stack. - let simple = matches!(e.as_slice(), [MNode::Run(r)] if r.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&r.text.as_str())); - if simple && !upper { - arg(e, s, depth); - s.push('_'); - braced(lim, s, depth); - } else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() { - s.push_str(group_chr_tex(*chr, *top)); - braced(inner, s, depth); - s.push(if *upper { '^' } else { '_' }); - braced(lim, s, depth); - } else { - s.push_str(if *upper { "\\overset" } else { "\\underset" }); - braced(lim, s, depth); - braced(e, s, depth); - } - } - MNode::Acc { chr, e } => { - let cmd = match chr { - '\u{302}' => "hat", - '\u{303}' => "tilde", - '\u{307}' => "dot", - '\u{308}' => "ddot", - '\u{20DB}' => "dddot", - '\u{30C}' => "check", - '\u{306}' => "breve", - '\u{301}' => "acute", - '\u{300}' => "grave", - '\u{305}' | '\u{AF}' => "bar", - '\u{20D6}' => "overleftarrow", - '\u{20E1}' => "overleftrightarrow", - _ => "vec", - }; - s.push('\\'); - s.push_str(cmd); - braced(e, s, depth); + arg(name, output, depth); + braced(e, output, depth); } + MNode::Lim { upper, e, lim } => limit_node(*upper, e, lim, output, depth), + MNode::Acc { chr, e } => accent_node(*chr, e, output, depth), MNode::Bar { top, e } => { - s.push_str(if *top { "\\overline" } else { "\\underline" }); - braced(e, s, depth); + output.push_str(if *top { "\\overline" } else { "\\underline" }); + braced(e, output, depth); } MNode::BorderBox { e, .. } => { - s.push_str("\\boxed"); - braced(e, s, depth); + output.push_str("\\boxed"); + braced(e, output, depth); } - MNode::Boxed { e } => braced(e, s, depth), + MNode::Boxed { e } => braced(e, output, depth), MNode::GroupChr { chr, top, e } => { - s.push_str(group_chr_tex(*chr, *top)); - braced(e, s, depth); - } - MNode::EqArr { rows } => { - if rows.len() == 1 - && let Some(r) = rows.first() - && crate::math::has_number_mark(r) - { - arg(r, s, depth); - return; - } - s.push_str("\\begin{aligned}"); - for (i, r) in rows.iter().enumerate() { - if i > 0 { - s.push_str("\\\\"); - } - for n in r { - match n { - MNode::Run(run) if !run.lit && run.text.contains('&') => { - for (k, part) in run.text.split('&').enumerate() { - if k > 0 { - s.push('&'); - } - let mut p = run.clone(); - p.text = part.to_string(); - node(&MNode::Run(p), s, depth + 1); - } - } - other => node(other, s, depth + 1), - } - } - } - s.push_str("\\end{aligned}"); - } - MNode::Matrix { rows, .. } => { - s.push_str("\\begin{matrix}"); - for (i, r) in rows.iter().enumerate() { - if i > 0 { - s.push_str("\\\\"); - } - for (j, c) in r.iter().enumerate() { - if j > 0 { - s.push('&'); - } - arg(c, s, depth); - } - } - s.push_str("\\end{matrix}"); + output.push_str(group_chr_tex(*chr, *top)); + braced(e, output, depth); } + MNode::EqArr { rows } => equation_array_node(rows, output, depth), + MNode::Matrix { rows, .. } => matrix_node(rows, output, depth), MNode::Phant { e, .. } => { - s.push_str("\\phantom"); - braced(e, s, depth); + output.push_str("\\phantom"); + braced(e, output, depth); } } } +fn run_node(run: &MRun, output: &mut String) { + let (open, close) = if run.nor { + ("\\text{", "}") + } else { + match (run.sty, run.scr) { + (_, MScr::DoubleStruck) => ("\\mathbb{", "}"), + (_, MScr::Script) => ("\\mathcal{", "}"), + (_, MScr::Fraktur) => ("\\mathfrak{", "}"), + (_, MScr::SansSerif) => ("\\mathsf{", "}"), + (_, MScr::Monospace) => ("\\mathtt{", "}"), + (Some(MSty::Bold), _) => ("\\mathbf{", "}"), + (Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"), + (Some(MSty::Plain), _) if run.text.chars().any(char::is_alphabetic) => { + if FUNCTION_NAMES.contains(&run.text.as_str()) { + output.push('\\'); + output.push_str(&run.text); + output.push(' '); + return; + } + ("\\mathrm{", "}") + } + _ => ("", ""), + } + }; + output.push_str(open); + if run.nor { + text_chars(&run.text, output) + } else { + chars(&run.text, output) + } + output.push_str(close); +} + +fn fraction_node(kind: FracKind, numerator: &[MNode], denominator: &[MNode], output: &mut String, depth: usize) { + match kind { + FracKind::NoBar => { + output.push_str("\\genfrac{}{}{0pt}{}"); + braced(numerator, output, depth); + braced(denominator, output, depth); + } + FracKind::Linear | FracKind::Skewed => { + braced(numerator, output, depth); + output.push('/'); + braced(denominator, output, depth); + } + FracKind::Bar => { + output.push_str("\\frac"); + braced(numerator, output, depth); + braced(denominator, output, depth); + } + } +} + +fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], output: &mut String, depth: usize) { + if kind == ScriptKind::Pre { + output.push_str("{}_"); + braced(sub, output, depth); + output.push('^'); + braced(sup, output, depth); + braced(base, output, depth); + return; + } + braced(base, output, depth); + if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { + output.push('_'); + braced(sub, output, depth); + } + if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { + output.push('^'); + braced(sup, output, depth); + } +} + +fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) { + output.push_str("\\sqrt"); + if !degree_hidden && !degree.is_empty() { + output.push('['); + arg(degree, output, depth); + output.push(']'); + } + braced(expression, output, depth); +} + +fn nary_node(node: &MNode, output: &mut String, depth: usize) { + let MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } = node else { return }; + match name_of(*chr) { + Some(name) => { + output.push('\\'); + output.push_str(name); + } + None => output.push(*chr), + } + match lim_loc { + Some(crate::math::LimLoc::UndOvr) => output.push_str("\\limits"), + Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => output.push_str("\\nolimits"), + _ => {} + } + if !sub_hide && !sub.is_empty() { + output.push('_'); + braced(sub, output, depth); + } + if !sup_hide && !sup.is_empty() { + output.push('^'); + braced(sup, output, depth); + } + output.push(' '); + braced(e, output, depth); +} + +fn delimiter_node(begin: Option, end: Option, separator: Option, elements: &[Arg], output: &mut String, depth: usize) { + output.push_str("\\left"); + output.push_str(&delim_tex(begin)); + let middle = separator.map(|c| format!("\\middle{}", delim_tex(Some(c)))).unwrap_or_else(|| "\\middle|".to_string()); + for (index, element) in elements.iter().enumerate() { + if index > 0 { + output.push_str(&middle); + } + arg(element, output, depth); + } + output.push_str("\\right"); + output.push_str(&delim_tex(end)); +} + +fn limit_node(upper: bool, expression: &[MNode], limit: &[MNode], output: &mut String, depth: usize) { + let simple = matches!(expression, [MNode::Run(run)] if run.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&run.text.as_str())); + if simple && !upper { + arg(expression, output, depth); + output.push('_'); + braced(limit, output, depth); + } else if let [MNode::GroupChr { chr, top, e: inner }] = expression { + output.push_str(group_chr_tex(*chr, *top)); + braced(inner, output, depth); + output.push(if upper { '^' } else { '_' }); + braced(limit, output, depth); + } else { + output.push_str(if upper { "\\overset" } else { "\\underset" }); + braced(limit, output, depth); + braced(expression, output, depth); + } +} + +fn accent_node(character: char, expression: &[MNode], output: &mut String, depth: usize) { + let command = match character { + '\u{302}' => "hat", + '\u{303}' => "tilde", + '\u{307}' => "dot", + '\u{308}' => "ddot", + '\u{20DB}' => "dddot", + '\u{30C}' => "check", + '\u{306}' => "breve", + '\u{301}' => "acute", + '\u{300}' => "grave", + '\u{305}' | '\u{AF}' => "bar", + '\u{20D6}' => "overleftarrow", + '\u{20E1}' => "overleftrightarrow", + _ => "vec", + }; + output.push('\\'); + output.push_str(command); + braced(expression, output, depth); +} + +fn equation_array_node(rows: &[Arg], output: &mut String, depth: usize) { + if rows.len() == 1 + && let Some(row) = rows.first() + && crate::math::has_number_mark(row) + { + arg(row, output, depth); + return; + } + output.push_str("\\begin{aligned}"); + for (index, row) in rows.iter().enumerate() { + if index > 0 { + output.push_str("\\\\"); + } + for child_node in row { + match child_node { + MNode::Run(run) if !run.lit && run.text.contains('&') => { + for (index, part) in run.text.split('&').enumerate() { + if index > 0 { + output.push('&'); + } + let mut piece = run.clone(); + piece.text = part.to_string(); + run_node(&piece, output); + } + } + other => node(other, output, depth + 1), + } + } + } + output.push_str("\\end{aligned}"); +} + +fn matrix_node(rows: &[Vec], output: &mut String, depth: usize) { + output.push_str("\\begin{matrix}"); + for (row_index, row) in rows.iter().enumerate() { + if row_index > 0 { + output.push_str("\\\\"); + } + for (column, cell) in row.iter().enumerate() { + if column > 0 { + output.push('&'); + } + arg(cell, output, depth); + } + } + output.push_str("\\end{matrix}"); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/doc/src/para/float.rs b/crates/doc/src/para/float.rs index 120bb24cfc..86f81c94d1 100644 --- a/crates/doc/src/para/float.rs +++ b/crates/doc/src/para/float.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// How a floating object is placed. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] diff --git a/crates/doc/src/para/inline_kinds.rs b/crates/doc/src/para/inline_kinds.rs index 45c9412bc4..686089f7f7 100644 --- a/crates/doc/src/para/inline_kinds.rs +++ b/crates/doc/src/para/inline_kinds.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/para/inline_object.rs b/crates/doc/src/para/inline_object.rs index 2b65a2ec9b..d10bbb9385 100644 --- a/crates/doc/src/para/inline_object.rs +++ b/crates/doc/src/para/inline_object.rs @@ -1,6 +1,6 @@ use super::float::{Float, Wrap, cap_alt}; use super::inline_kinds::{NoteKind, PTabAlign, PTabBase, RubyAlign, ShapeKind}; -use super::*; +use super::{CharProps, Deserialize, Serialize}; /// An inline object anchored at a U+FFFC in the paragraph text. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/para/run.rs b/crates/doc/src/para/run.rs index 3c619c8751..10ff4f09e9 100644 --- a/crates/doc/src/para/run.rs +++ b/crates/doc/src/para/run.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AtomicU64, CharProps, Deserialize, Ordering, Serialize}; /// Object replacement character: marks an inline object in paragraph text. pub const OBJ: char = '\u{FFFC}'; diff --git a/crates/doc/src/parts.rs b/crates/doc/src/parts.rs index 535073bd9e..830aa04307 100644 --- a/crates/doc/src/parts.rs +++ b/crates/doc/src/parts.rs @@ -1,6 +1,6 @@ use super::block::Blocks; use super::position::Pos; -use super::*; +use super::{Deserialize, Serialize, props}; /// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the /// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id), diff --git a/crates/doc/src/position.rs b/crates/doc/src/position.rs index 479aab4ae7..800197720a 100644 --- a/crates/doc/src/position.rs +++ b/crates/doc/src/position.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// Which story a position is in. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] diff --git a/crates/doc/src/props/char_props.rs b/crates/doc/src/props/char_props.rs index 04133bad14..b8ef433c52 100644 --- a/crates/doc/src/props/char_props.rs +++ b/crates/doc/src/props/char_props.rs @@ -1,6 +1,6 @@ use super::color::{Rgb, TextColor}; use super::para_props::Border; -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -447,24 +447,50 @@ impl CharProps { /// The fields of `self` that differ from `base` (equal ones unset): what `self` adds when /// overlaid on `base`. pub fn minus(mut self, base: &CharProps) -> CharProps { - drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, - shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, - cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, ligatures); + self.minus_text_style(base); + self.minus_script_style(base); + self.minus_document_marks(base); self } + fn minus_text_style(&mut self, base: &CharProps) { + drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, + shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl); + } + + fn minus_script_style(&mut self, base: &CharProps) { + drop_same_fields!(self, base; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout); + } + + fn minus_document_marks(&mut self, base: &CharProps) { + drop_same_fields!(self, base; link, ins, del, ligatures); + } + /// Apply every `Some` field of `patch` on top of `self`. /// /// Plain and complex-script properties inherit independently. Commands that intend to /// format both scripts must set both values explicitly. pub fn overlay(&mut self, patch: &CharProps) { - overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, - shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, - cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, fmt_change, ligatures); + self.overlay_text_style(patch); + self.overlay_script_style(patch); + self.overlay_document_marks(patch); if let Some(p) = &patch.text_effects { self.text_effects.get_or_insert_with(Default::default).overlay(p); } } + + fn overlay_text_style(&mut self, patch: &CharProps) { + overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, + shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl); + } + + fn overlay_script_style(&mut self, patch: &CharProps) { + overlay_fields!(self, patch; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout); + } + + fn overlay_document_marks(&mut self, patch: &CharProps) { + overlay_fields!(self, patch; link, ins, del, fmt_change, ligatures); + } pub fn overlaid(mut self, patch: &CharProps) -> CharProps { self.overlay(patch); self diff --git a/crates/doc/src/props/color.rs b/crates/doc/src/props/color.rs index 41631279d3..baf2d948d4 100644 --- a/crates/doc/src/props/color.rs +++ b/crates/doc/src/props/color.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize}; /// An sRGB colour. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)] diff --git a/crates/doc/src/props/para_props.rs b/crates/doc/src/props/para_props.rs index cab54173ca..29a8b86850 100644 --- a/crates/doc/src/props/para_props.rs +++ b/crates/doc/src/props/para_props.rs @@ -1,6 +1,6 @@ use super::char_props::{NumChange, PropChange}; use super::color::Rgb; -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -367,10 +367,23 @@ pub struct ParaProps { impl ParaProps { pub fn overlay(&mut self, patch: &ParaProps) { - overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, space_before_auto, space_after_auto, line_spacing, - contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders, - suppress_hyphens, suppress_line_numbers, bidi, drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, - snap_to_grid); + self.overlay_layout(patch); + self.overlay_pagination(patch); + self.overlay_script_layout(patch); + } + + fn overlay_layout(&mut self, patch: &ParaProps) { + overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, + space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders); + } + + fn overlay_pagination(&mut self, patch: &ParaProps) { + overlay_fields!(self, patch; keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, suppress_hyphens, suppress_line_numbers, + drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, snap_to_grid); + } + + fn overlay_script_layout(&mut self, patch: &ParaProps) { + overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn); } pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps { self.overlay(patch); diff --git a/crates/doc/src/props/table_props.rs b/crates/doc/src/props/table_props.rs index 8ae58b531a..8461ffc029 100644 --- a/crates/doc/src/props/table_props.rs +++ b/crates/doc/src/props/table_props.rs @@ -1,7 +1,7 @@ use super::char_props::PropChange; use super::color::Rgb; use super::para_props::{Align, Borders}; -use super::*; +use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/review.rs b/crates/doc/src/review.rs index 47d9f72328..5a0b96820a 100644 --- a/crates/doc/src/review.rs +++ b/crates/doc/src/review.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Blocks, Deserialize, Document, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/doc/src/settings.rs b/crates/doc/src/settings.rs index d5a7390383..aec1bd70f8 100644 --- a/crates/doc/src/settings.rs +++ b/crates/doc/src/settings.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Rgb, Serialize, math, section, styles}; /// A bibliography source. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] diff --git a/crates/doc/src/text_walk.rs b/crates/doc/src/text_walk.rs index 799f79893d..d7a681a5f8 100644 --- a/crates/doc/src/text_walk.rs +++ b/crates/doc/src/text_walk.rs @@ -1,7 +1,7 @@ use super::block::{Block, Blocks}; use super::parts::PartKind; use super::position::Path; -use super::*; +use super::{InlineObject, Numbering, numbering, para, section, styles}; /// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char). pub fn count_words(s: &str) -> usize { From 75d30d69b4f10a351e0860f85829a9f4b5c94edf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 01:06:47 +0000 Subject: [PATCH 11/33] Refactor docx for strict Clippy checks --- crates/docx/src/read/chart/canvas.rs | 2 +- crates/docx/src/read/chart/model.rs | 2 +- crates/docx/src/read/chart/parse.rs | 2 +- crates/docx/src/read/chart/plot.rs | 2 +- crates/docx/src/read/chart/polar.rs | 2 +- crates/docx/src/read/meta.rs | 5 +- crates/docx/src/read/parts.rs | 5 +- crates/docx/src/read/story/blocks.rs | 6 +- crates/docx/src/read/story/drawing.rs | 6 +- crates/docx/src/read/story/fields.rs | 2 +- crates/docx/src/read/story/runs.rs | 15 +- crates/docx/src/read/story/vml.rs | 24 +- crates/docx/src/read/styles.rs | 5 +- crates/docx/src/read/toc.rs | 2 +- crates/docx/src/smart_art.rs | 100 +++-- crates/docx/src/write/bibliography.rs | 2 +- crates/docx/src/write/math.rs | 120 +++--- crates/docx/src/write/parts.rs | 330 ++++++++-------- crates/docx/src/write/props.rs | 72 ++-- crates/docx/src/write/settings.rs | 36 +- crates/docx/src/write/story/blocks.rs | 2 +- crates/docx/src/write/story/drawing.rs | 34 +- crates/docx/src/write/story/field.rs | 13 +- crates/docx/src/write/story/object.rs | 456 ++++++++++++----------- crates/docx/src/write/story/para.rs | 4 +- crates/docx/src/write/story/section.rs | 2 +- crates/docx/src/write/story/toc.rs | 2 +- crates/docx/src/write/styles.rs | 2 +- crates/docx/tests/it/content_controls.rs | 35 +- crates/docx/tests/it/fixtures.rs | 14 +- crates/docx/tests/it/roundtrip.rs | 114 ++++-- 31 files changed, 810 insertions(+), 608 deletions(-) diff --git a/crates/docx/src/read/chart/canvas.rs b/crates/docx/src/read/chart/canvas.rs index f83ab3db41..a8280e4ba7 100644 --- a/crates/docx/src/read/chart/canvas.rs +++ b/crates/docx/src/read/chart/canvas.rs @@ -1,5 +1,5 @@ use super::model::Pos; -use super::*; +use super::{CHAR_W, GAP, GraphicItem, LEGEND_SHARE, LINE, MAX_LEGEND_ROWS, PAD, PathSeg, Rgb, ShapeKind, TEXT, TextAlign}; #[derive(Clone, Copy, Debug, PartialEq)] pub(super) struct Rect { diff --git a/crates/docx/src/read/chart/model.rs b/crates/docx/src/read/chart/model.rs index 807e9e6eff..a3ee99637a 100644 --- a/crates/docx/src/read/chart/model.rs +++ b/crates/docx/src/read/chart/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Rgb; /// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in /// white on the fill) or just beside it (above, or right of a horizontal bar's end). diff --git a/crates/docx/src/read/chart/parse.rs b/crates/docx/src/read/chart/parse.rs index 5b6551268b..737effde32 100644 --- a/crates/docx/src/read/chart/parse.rs +++ b/crates/docx/src/read/chart/parse.rs @@ -1,7 +1,7 @@ use super::format::{date_text, excel_stamp, format_value, is_date_code}; use super::model::{Axis, Grouping, Kind, Pos, Series}; use super::plot::label; -use super::*; +use super::{Chart, El, FONT, LINE_W, MAX_CELLS, MAX_POINTS, MAX_RADAR_SERIES, MAX_SERIES, MAX_SPOKES, Rgb, fill_colour, int, measure, slot}; impl Chart { pub(super) fn parse(space: &El, theme: &[Rgb]) -> Option { diff --git a/crates/docx/src/read/chart/plot.rs b/crates/docx/src/read/chart/plot.rs index 5857f51e73..652ee79a8b 100644 --- a/crates/docx/src/read/chart/plot.rs +++ b/crates/docx/src/read/chart/plot.rs @@ -3,7 +3,7 @@ use super::format::format_value; use super::model::{DataLabel, Grouping, Kind, Series}; use super::parse::category_text; use super::polar::{draw_data_labels, label_of}; -use super::*; +use super::{AXIS, Chart, El, GAP, GRID, GraphicItem, LINE, MAX_LABEL, MAX_TICKS, PathSeg, Rgb, ShapeKind, TextAlign, fill_colour, measure}; pub(super) fn label(s: &str) -> String { s.chars().take(MAX_LABEL).collect() diff --git a/crates/docx/src/read/chart/polar.rs b/crates/docx/src/read/chart/polar.rs index f8f4f0b382..57ad10472b 100644 --- a/crates/docx/src/read/chart/polar.rs +++ b/crates/docx/src/read/chart/polar.rs @@ -2,7 +2,7 @@ use super::canvas::{Rect, clip_text, fill, stroke, text, text_w, widest}; use super::format::format_value; use super::model::{DataLabel, Kind, Series}; use super::plot::{Axes, has_values}; -use super::*; +use super::{Chart, FRAC_PI_2, GAP, GRID, GraphicItem, LINE, PathSeg, Rgb, ShapeKind, TAU, TEXT, TextAlign}; pub(super) fn pie(out: &mut Vec, c: &Chart, area: Rect) { let Some(s) = c.series.first() else { return }; diff --git a/crates/docx/src/read/meta.rs b/crates/docx/src/read/meta.rs index 5ec53de6f3..9739f66dce 100644 --- a/crates/docx/src/read/meta.rs +++ b/crates/docx/src/read/meta.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, BTreeMap, Blocks, Comment, DocxError, El, HashMap, HashSet, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph, + PartKind, Reader, Rels, Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of, +}; impl Reader<'_> { pub(super) fn read_settings(&mut self, path: &str) -> Result<(), DocxError> { diff --git a/crates/docx/src/read/parts.rs b/crates/docx/src/read/parts.rs index cc3bfbcd24..f5ce13834e 100644 --- a/crates/docx/src/read/parts.rs +++ b/crates/docx/src/read/parts.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels, + Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style, +}; impl Reader<'_> { /// Keep the parts the VBA project at `project` relates to, with their relationship types and diff --git a/crates/docx/src/read/story/blocks.rs b/crates/docx/src/read/story/blocks.rs index 3789690e5d..b1b26ca835 100644 --- a/crates/docx/src/read/story/blocks.rs +++ b/crates/docx/src/read/story/blocks.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Arc, Block, Blocks, Cell, CharProps, El, InlineObject, MAX_COLS, MAX_ROWS, MAX_TABLE_DEPTH, Node, NumChange, PB, Paragraph, Phase, PropChange, + Reader, Rels, RevisionKind, Row, Run, RunCtx, StoryCtx, Table, children_of_choice, clean_text, collect, last_para_mut, para_block, + placeholder_of, sectpr, tcpr, trpr, +}; impl Reader<'_> { /// Read a story's blocks from a container element (`w:body`, `w:hdr`, `w:tc`, `w:txbxContent`…). diff --git a/crates/docx/src/read/story/drawing.rs b/crates/docx/src/read/story/drawing.rs index ab8df77486..f8cd077307 100644 --- a/crates/docx/src/read/story/drawing.rs +++ b/crates/docx/src/read/story/drawing.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + AltText, Anchor, Arc, Blocks, CHARTEX_URI, ConnEnd, ContentTypes, El, Embedded, Float, FloatAlign, Glow, Graphic, GraphicItem, GraphicKind, + INK_REL, INK_URI, InlineObject, MAX_STORY_DEPTH, Node, PartKind, Reader, Rels, Rgb, Shadow, ShapeEffects, ShapeExtra, ShapeKind, StoryCtx, + TextWarp, Wrap, WrapText, cap_alt, children_of_choice, int, measure, on_off_attr, own_fill, preset_kind, rt, shape_paint, u32_of, +}; impl Reader<'_> { // ---- drawings ---- diff --git a/crates/docx/src/read/story/fields.rs b/crates/docx/src/read/story/fields.rs index f661300b67..1847d3e8ad 100644 --- a/crates/docx/src/read/story/fields.rs +++ b/crates/docx/src/read/story/fields.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CharProps, FieldState, InlineObject, Item, MAX_FIELD_DEPTH, PB, Phase, Reader, StoryCtx}; /// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word /// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text diff --git a/crates/docx/src/read/story/runs.rs b/crates/docx/src/read/story/runs.rs index 222a46f563..9dec4c5b48 100644 --- a/crates/docx/src/read/story/runs.rs +++ b/crates/docx/src/read/story/runs.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx, + TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink, +}; impl Reader<'_> { pub(super) fn read_inline_children(&mut self, sc: &mut StoryCtx, pb: &mut PB, e: &El, rels: &Rels, ctx: &RunCtx, depth: usize) { @@ -105,7 +108,7 @@ impl Reader<'_> { // Some generators write a break directly under the paragraph instead of inside a run. "w:br" | "w:cr" => { let props = self.run_props_none(ctx); - self.read_run_child(sc, pb, k, &props, rels, &mut None, depth + 1); + self.read_run_child(sc, pb, k, (&props, rels), &mut None, depth + 1); } _ => {} } @@ -131,21 +134,19 @@ impl Reader<'_> { }; let mut note: Option<(NoteKind, u32, Option)> = None; for k in r.els() { - self.read_run_child(sc, pb, k, &props, rels, &mut note, depth + 1); + self.read_run_child(sc, pb, k, (&props, rels), &mut note, depth + 1); } if let Some((kind, id, custom)) = note.take() { self.emit_obj(sc, pb, InlineObject::NoteRef { kind, id, custom: custom.unwrap_or_default() }, &props); } } - #[allow(clippy::too_many_arguments)] fn read_run_child( &mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, - props: &CharProps, - rels: &Rels, + (props, rels): (&CharProps, &Rels), note: &mut Option<(NoteKind, u32, Option)>, depth: usize, ) { @@ -301,7 +302,7 @@ impl Reader<'_> { self.emit_obj(sc, pb, o, props); } else if let Some(c) = children_of_choice(k) { for x in c.els() { - self.read_run_child(sc, pb, x, props, rels, note, depth + 1); + self.read_run_child(sc, pb, x, (props, rels), note, depth + 1); } } } diff --git a/crates/docx/src/read/story/vml.rs b/crates/docx/src/read/story/vml.rs index e3fd56b024..75faa2c91a 100644 --- a/crates/docx/src/read/story/vml.rs +++ b/crates/docx/src/read/story/vml.rs @@ -1,4 +1,14 @@ -use super::*; +use super::{ + Anchor, Arc, CharProps, El, Float, FloatAlign, InlineObject, MAX_STORY_DEPTH, MAX_VML_GROUP_DEPTH, Paragraph, PartKind, Reader, Rels, Rgb, + ShapeExtra, ShapeKind, StoryCtx, TextColor, TextWarp, Wrap, int, measure, para_block, vml_color, vml_fill, vml_fraction, vml_preset_kind, +}; + +#[derive(Clone, Copy)] +struct VmlGroupSpace { + origin: (f32, f32), + scale: (f32, f32), + depth: usize, +} impl Reader<'_> { /// Every legacy VML drawing in one `w:pict`, in document order: a `w:pict` may hold any @@ -43,7 +53,7 @@ impl Reader<'_> { let (ch_w, ch_h) = vml_coordpair(grp, "coordsize").filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((w.max(1.0), h.max(1.0))); let (cx, cy) = vml_coordpair(grp, "coordorigin").unwrap_or((0.0, 0.0)); let mut children = Vec::new(); - self.vml_group_members(sc, pict, grp, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children); + self.vml_group_members(sc, pict, grp, rels, VmlGroupSpace { origin: (-cx, -cy), scale: (1.0, 1.0), depth: 0 }, &mut children); if children.is_empty() { return None; } @@ -59,11 +69,11 @@ impl Reader<'_> { pict: &El, g: &El, rels: &Rels, - t: (f32, f32, f32, f32), - depth: usize, + space: VmlGroupSpace, out: &mut Vec, ) { - let (x0, y0, sx, sy) = t; + let (x0, y0) = space.origin; + let (sx, sy) = space.scale; let max = crate::units::MAX_LEN_PT; let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max); for e in g.els() { @@ -77,7 +87,7 @@ impl Reader<'_> { let num = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).map_or(0.0, wordcraft_geom::finite); let ((ox, oy), (ew, eh)) = ((num("left"), num("top")), (num("width"), num("height"))); if e.local() == "group" { - if depth >= MAX_VML_GROUP_DEPTH { + if space.depth >= MAX_VML_GROUP_DEPTH { continue; } // Its members' space maps onto its own box. @@ -85,7 +95,7 @@ impl Reader<'_> { let (cox, coy) = vml_coordpair(e, "coordorigin").unwrap_or((0.0, 0.0)); let (kx, ky) = (ew / cw, eh / ch); let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky); - self.vml_group_members(sc, pict, e, rels, nt, depth + 1, out); + self.vml_group_members(sc, pict, e, rels, VmlGroupSpace { origin: (nt.0, nt.1), scale: (nt.2, nt.3), depth: space.depth + 1 }, out); continue; } let Some(mut obj) = self.read_vml_shape(sc, pict, e, rels, Some((sx, sy))) else { continue }; diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index 7a97f02f9c..55d01e8a49 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts, + VAlign, flag, int, props, tw, u32_of, +}; /// Built-in style names Word stores in lower case, and how the UI shows them. pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[ diff --git a/crates/docx/src/read/toc.rs b/crates/docx/src/read/toc.rs index d7307b73c3..1c246732a2 100644 --- a/crates/docx/src/read/toc.rs +++ b/crates/docx/src/read/toc.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Block, Blocks, ControlEvent, ControlKind, Document, HashSet, InlineObject, OBJ, StyleKind, control_events, pair_events}; /// Word draws a table of contents' entry links without the Hyperlink character style (no blue, /// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run diff --git a/crates/docx/src/smart_art.rs b/crates/docx/src/smart_art.rs index 22eda0567c..9378192a87 100644 --- a/crates/docx/src/smart_art.rs +++ b/crates/docx/src/smart_art.rs @@ -805,16 +805,17 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String { /// A layout node: `alg` with its parameters, a shape (`None`: no geometry), what text it shows /// (`presOf` attributes), then `body`. -fn layout_node( - w: &mut W, - name: &str, - style: Option<&str>, - alg: &str, - params: &[(&str, &str)], - shape: Option<&str>, - pres_of: &[(&str, &str)], - body: impl FnOnce(&mut W), -) { +struct LayoutNode<'a> { + name: &'a str, + style: Option<&'a str>, + alg: &'a str, + params: &'a [(&'a str, &'a str)], + shape: Option<&'a str>, + pres_of: &'a [(&'a str, &'a str)], +} + +fn layout_node(w: &mut W, node: LayoutNode<'_>, body: impl FnOnce(&mut W)) { + let LayoutNode { name, style, alg, params, shape, pres_of } = node; let mut attrs = vec![("name", name)]; if let Some(s) = style { attrs.push(("styleLbl", s)); @@ -870,45 +871,74 @@ pub fn layout_xml(l: SmartArtLayout) -> String { SmartArtLayout::Cycle | SmartArtLayout::Radial | SmartArtLayout::Venn => "ellipse", SmartArtLayout::Pyramid => "trapezoid", }; - layout_node(&mut w, "diagram", None, alg, params, None, &[], |w| match l { + layout_node(&mut w, LayoutNode { name: "diagram", style: None, alg, params, shape: None, pres_of: &[] }, |w| match l { SmartArtLayout::Hierarchy => { w.open("dgm:forEach", &[("name", "branches"), ("axis", "ch"), ("ptType", "node")]); - layout_node(w, "branch", None, "hierRoot", &[], None, &[], |w| { - layout_node(w, "item", Some("node1"), "tx", &[], Some(shape), &[("axis", "self")], |_| {}); - layout_node(w, "below", None, "hierChild", &[("chAlign", "t")], None, &[], |w| { - w.open("dgm:forEach", &[("name", "joins"), ("axis", "ch"), ("ptType", "parTrans")]); - layout_node( - w, - "join", - Some("connector"), - "conn", - &[("dim", "1D"), ("connRout", "bend"), ("begPts", "bCtr"), ("endPts", "tCtr")], - Some("conn"), - &[("axis", "self")], - |_| {}, - ); - w.close("dgm:forEach"); - w.empty("dgm:forEach", &[("name", "deeper"), ("ref", "branches")]); - }); + layout_node(w, LayoutNode { name: "branch", style: None, alg: "hierRoot", params: &[], shape: None, pres_of: &[] }, |w| { + layout_node( + w, + LayoutNode { name: "item", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: &[("axis", "self")] }, + |_| {}, + ); + layout_node( + w, + LayoutNode { name: "below", style: None, alg: "hierChild", params: &[("chAlign", "t")], shape: None, pres_of: &[] }, + |w| { + w.open("dgm:forEach", &[("name", "joins"), ("axis", "ch"), ("ptType", "parTrans")]); + layout_node( + w, + LayoutNode { + name: "join", + style: Some("connector"), + alg: "conn", + params: &[("dim", "1D"), ("connRout", "bend"), ("begPts", "bCtr"), ("endPts", "tCtr")], + shape: Some("conn"), + pres_of: &[("axis", "self")], + }, + |_| {}, + ); + w.close("dgm:forEach"); + w.empty("dgm:forEach", &[("name", "deeper"), ("ref", "branches")]); + }, + ); }); w.close("dgm:forEach"); } SmartArtLayout::Radial => { w.open("dgm:forEach", &[("name", "middle"), ("axis", "ch"), ("ptType", "node"), ("cnt", "1")]); - layout_node(w, "centre", Some("node1"), "tx", &[], Some(shape), &[("axis", "self")], |w| { - w.open("dgm:forEach", &[("name", "around"), ("axis", "ch"), ("ptType", "node")]); - layout_node(w, "spoke", Some("node1"), "tx", &[], Some(shape), ITEM_TEXT, |_| {}); - w.close("dgm:forEach"); - }); + layout_node( + w, + LayoutNode { name: "centre", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: &[("axis", "self")] }, + |w| { + w.open("dgm:forEach", &[("name", "around"), ("axis", "ch"), ("ptType", "node")]); + layout_node( + w, + LayoutNode { name: "spoke", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: ITEM_TEXT }, + |_| {}, + ); + w.close("dgm:forEach"); + }, + ); w.close("dgm:forEach"); } _ => { w.open("dgm:forEach", &[("name", "items"), ("axis", "ch"), ("ptType", "node")]); let style = if l == SmartArtLayout::Venn { "venn" } else { "node1" }; - layout_node(w, "item", Some(style), "tx", &[], Some(shape), ITEM_TEXT, |_| {}); + layout_node(w, LayoutNode { name: "item", style: Some(style), alg: "tx", params: &[], shape: Some(shape), pres_of: ITEM_TEXT }, |_| {}); if matches!(l, SmartArtLayout::Process | SmartArtLayout::Cycle) { w.open("dgm:forEach", &[("name", "arrows"), ("axis", "followSib"), ("ptType", "sibTrans"), ("cnt", "1")]); - layout_node(w, "arrow", Some("connector"), "conn", &[("dim", "2D")], Some("rightArrow"), &[("axis", "self")], |_| {}); + layout_node( + w, + LayoutNode { + name: "arrow", + style: Some("connector"), + alg: "conn", + params: &[("dim", "2D")], + shape: Some("rightArrow"), + pres_of: &[("axis", "self")], + }, + |_| {}, + ); w.close("dgm:forEach"); } w.close("dgm:forEach"); diff --git a/crates/docx/src/write/bibliography.rs b/crates/docx/src/write/bibliography.rs index 2f0f8b9a36..ee343c8662 100644 --- a/crates/docx/src/write/bibliography.rs +++ b/crates/docx/src/write/bibliography.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, W}; fn to_source_type(kind: &str) -> &str { match kind.to_ascii_lowercase().as_str() { diff --git a/crates/docx/src/write/math.rs b/crates/docx/src/write/math.rs index a1ae2a8c5b..5cacc0b51f 100644 --- a/crates/docx/src/write/math.rs +++ b/crates/docx/src/write/math.rs @@ -179,6 +179,67 @@ fn char_prop(w: &mut W, name: &str, c: Option) { val(w, name, &s); } +fn write_matrix(w: &mut W, n: &MNode, depth: usize) { + let MNode::Matrix { rows, col_jc } = n else { return }; + let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1); + w.open("m:m", &[]); + w.open("m:mPr", &[]); + w.open("m:mcs", &[]); + for c in 0..cols { + let jc = match col_jc.get(c).copied().unwrap_or_default() { + ColJc::Center => "center", + ColJc::Left => "left", + ColJc::Right => "right", + }; + w.open("m:mc", &[]); + w.open("m:mcPr", &[]); + val(w, "m:count", "1"); + val(w, "m:mcJc", jc); + w.close("m:mcPr"); + w.close("m:mc"); + } + w.close("m:mcs"); + ctrl(w); + w.close("m:mPr"); + for row in rows { + w.open("m:mr", &[]); + for c in 0..cols { + arg(w, "m:e", row.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth); + } + w.close("m:mr"); + } + w.close("m:m"); +} + +fn write_nary(w: &mut W, n: &MNode, depth: usize) { + let MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } = n else { return }; + w.open("m:nary", &[]); + w.open("m:naryPr", &[]); + if *chr != '∫' { + char_prop(w, "m:chr", Some(*chr)); + } + match lim_loc { + Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"), + Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"), + None => {} + } + if *grow { + val(w, "m:grow", "1"); + } + if *sub_hide { + val(w, "m:subHide", "1"); + } + if *sup_hide { + val(w, "m:supHide", "1"); + } + ctrl(w); + w.close("m:naryPr"); + arg(w, "m:sub", sub, depth); + arg(w, "m:sup", sup, depth); + arg(w, "m:e", e, depth); + w.close("m:nary"); +} + fn node(w: &mut W, n: &MNode, depth: usize) { match n { MNode::Run(r) => run(w, r), @@ -235,33 +296,7 @@ fn node(w: &mut W, n: &MNode, depth: usize) { arg(w, "m:e", e, depth); w.close("m:rad"); } - MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => { - w.open("m:nary", &[]); - w.open("m:naryPr", &[]); - if *chr != '∫' { - char_prop(w, "m:chr", Some(*chr)); - } - match lim_loc { - Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"), - Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"), - None => {} - } - if *grow { - val(w, "m:grow", "1"); - } - if *sub_hide { - val(w, "m:subHide", "1"); - } - if *sup_hide { - val(w, "m:supHide", "1"); - } - ctrl(w); - w.close("m:naryPr"); - arg(w, "m:sub", sub, depth); - arg(w, "m:sup", sup, depth); - arg(w, "m:e", e, depth); - w.close("m:nary"); - } + MNode::Nary { .. } => write_nary(w, n, depth), MNode::Delim { beg, end, sep, grow, shp_match, elems } => { w.open("m:d", &[]); w.open("m:dPr", &[]); @@ -375,36 +410,7 @@ fn node(w: &mut W, n: &MNode, depth: usize) { } w.close("m:eqArr"); } - MNode::Matrix { rows, col_jc } => { - let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1); - w.open("m:m", &[]); - w.open("m:mPr", &[]); - w.open("m:mcs", &[]); - for c in 0..cols { - let jc = match col_jc.get(c).copied().unwrap_or_default() { - ColJc::Center => "center", - ColJc::Left => "left", - ColJc::Right => "right", - }; - w.open("m:mc", &[]); - w.open("m:mcPr", &[]); - val(w, "m:count", "1"); - val(w, "m:mcJc", jc); - w.close("m:mcPr"); - w.close("m:mc"); - } - w.close("m:mcs"); - ctrl(w); - w.close("m:mPr"); - for r in rows { - w.open("m:mr", &[]); - for c in 0..cols { - arg(w, "m:e", r.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth); - } - w.close("m:mr"); - } - w.close("m:m"); - } + MNode::Matrix { .. } => write_matrix(w, n, depth), MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => { w.open("m:phant", &[]); w.open("m:phantPr", &[]); diff --git a/crates/docx/src/write/parts.rs b/crates/docx/src/write/parts.rs index 05adf217cc..7cea957d46 100644 --- a/crates/docx/src/write/parts.rs +++ b/crates/docx/src/write/parts.rs @@ -3,7 +3,10 @@ use super::settings::{ ANONYMOUS_INITIALS, annotation_author, app_xml, core_xml, note_id_base, notes_part, settings_xml, theme_xml, write_comment_ids, }; use super::styles::{numbering_xml, styles_xml}; -use super::*; +use super::{ + BTreeMap, Blocks, CT_WML, Document, DocxError, Flavor, HashMap, MAX_VBA_RELATED, PartRels, VBA_PROJECT_PART, VBA_RELATED, W, Writer, embed, + glossary, rt, settings_merge, watermark, xml, +}; /// The parts of the package [`write_as`] zips, in order, `[Content_Types].xml` first: (part name /// without the leading `/`, bytes). @@ -60,6 +63,8 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result = Vec::new(); let mut rels = PartRels::default(); let mut root = PartRels::default(); + let ns = xml::body_ns(); + let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); // Comments get paragraph ids before any story is written. let mut comment_paras: Vec<(u32, String)> = Vec::new(); @@ -73,41 +78,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); - w.open("w:document", &ns_refs); - let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media)); - if pic.is_some() || doc.settings.page_color.is_some() { - let color = doc.settings.page_color.map(|c| c.hex()); - let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect(); - w.open("w:background", &attrs); - if let Some(p) = pic - && let Some(file) = wr.media_files.get(&p.media) - { - let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false); - wr.used_media.insert(p.media.clone()); - w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]); - let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)]; - if !p.fill_type.is_empty() { - fa.push(("type", &p.fill_type)); - } - if !p.title.is_empty() { - fa.push(("o:title", &p.title)); - } - w.empty("v:fill", &fa); - w.close("v:background"); - } - w.close("w:background"); - } - w.open("w:body", &[]); - wr.blocks(&mut w, &doc.body, &mut rels, true, 0); - let last = doc.last_section.clone(); - wr.sectpr(&mut w, &last, &mut rels); - w.close("w:body"); - w.close("w:document"); - let body = w.into_bytes(); + let body = write_document_body(doc, &mut wr, &mut rels, &ns_refs); // Headers and footers (discovered while writing sections). let mut done = 0; @@ -150,126 +121,9 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result { - w.open(tag, &[("w:type", kind), ("w:id", id)]); - if blocks.is_empty() { - w.empty("w:p", &[]); - } else { - wr.blocks(&mut w, blocks, &mut prels, false, 0); - } - w.close(tag); - } - None if kind != "continuationNotice" => { - w.open(tag, &[("w:type", kind), ("w:id", id)]); - w.open("w:p", &[]); - w.open("w:pPr", &[]); - w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]); - w.close("w:pPr"); - w.open("w:r", &[]); - w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]); - w.close("w:r"); - w.close("w:p"); - w.close(tag); - } - None => {} - } - } - let base = note_id_base(doc, foot); - let mut k = 0; - while let Some(pid) = (if foot { &wr.footnotes } else { &wr.endnotes }).get(k).copied() { - let nid = (k + base).to_string(); - k += 1; - w.open(tag, &[("w:id", &nid)]); - let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default(); - wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid); - w.close(tag); - } - w.close(root); - let kind = if foot { rt::FOOTNOTES } else { rt::ENDNOTES }; - rels.add(kind, file, false); - push_part( - &mut entries, - &mut overrides, - &format!("word/{file}"), - w.into_bytes(), - &format!("{CT_WML}{}+xml", if foot { "footnotes" } else { "endnotes" }), - prels, - ); - } + write_notes(doc, &ns_refs, &mut wr, &mut entries, &mut overrides, &mut rels); - // Comments. - if !doc.comments.is_empty() { - let mut prels = PartRels::default(); - let mut w = W::new(); - w.open("w:comments", &ns_refs); - for (cid, c) in &doc.comments { - let id = cid.to_string(); - let author = annotation_author(doc, &c.author); - let mut a = vec![("w:id", id.as_str()), ("w:author", author)]; - if !c.date.is_empty() && !doc.settings.remove_date_time { - a.push(("w:date", c.date.as_str())); - } - if doc.settings.remove_personal_info { - a.push(("w:initials", ANONYMOUS_INITIALS)); - } else if !c.initials.is_empty() { - a.push(("w:initials", c.initials.as_str())); - } - w.open("w:comment", &a); - let blocks: Blocks = doc.parts.get(&c.part).map(|p| p.blocks.clone()).unwrap_or_default(); - wr.blocks(&mut w, &blocks, &mut prels, false, 0); - w.close("w:comment"); - } - w.close("w:comments"); - rels.add(rt::COMMENTS, "comments.xml", false); - push_part(&mut entries, &mut overrides, "word/comments.xml", w.into_bytes(), &format!("{CT_WML}comments+xml"), prels); - - let mut w = W::new(); - w.open( - "w15:commentsEx", - &[ - ("xmlns:w15", "http://schemas.microsoft.com/office/word/2012/wordml"), - ("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"), - ], - ); - let pid_of = |cid: u32| comment_paras.iter().find(|(c, _)| *c == cid).map(|(_, p)| p.clone()); - for (cid, c) in &doc.comments { - let Some(pid) = pid_of(*cid) else { continue }; - let done = if c.resolved { "1" } else { "0" }; - match c.parent.and_then(pid_of) { - Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:paraIdParent", &parent), ("w15:done", done)]), - None => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:done", done)]), - } - } - w.close("w15:commentsEx"); - rels.add(rt::COMMENTS_EX, "commentsExtended.xml", false); - push_part( - &mut entries, - &mut overrides, - "word/commentsExtended.xml", - w.into_bytes(), - &format!("{CT_WML}commentsExtended+xml"), - PartRels::default(), - ); - write_comment_ids(doc, &comment_paras, &mut entries, &mut overrides, &mut rels); - } + write_comments(doc, &comment_paras, &ns_refs, &mut wr, &mut entries, &mut overrides, &mut rels); rels.add(rt::STYLES, "styles.xml", false); push_part(&mut entries, &mut overrides, "word/styles.xml", styles_xml(doc), &format!("{CT_WML}styles+xml"), PartRels::default()); @@ -425,6 +279,172 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result, rels: &mut PartRels, ns_refs: &[(&str, &str)]) -> Vec { + let mut w = W::new(); + w.open("w:document", ns_refs); + let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media)); + if pic.is_some() || doc.settings.page_color.is_some() { + let color = doc.settings.page_color.map(|c| c.hex()); + let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect(); + w.open("w:background", &attrs); + if let Some(p) = pic + && let Some(file) = wr.media_files.get(&p.media) + { + let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false); + wr.used_media.insert(p.media.clone()); + w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]); + let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)]; + if !p.fill_type.is_empty() { + fa.push(("type", &p.fill_type)); + } + if !p.title.is_empty() { + fa.push(("o:title", &p.title)); + } + w.empty("v:fill", &fa); + w.close("v:background"); + } + w.close("w:background"); + } + w.open("w:body", &[]); + wr.blocks(&mut w, &doc.body, rels, true, 0); + let last = doc.last_section.clone(); + wr.sectpr(&mut w, &last, rels); + w.close("w:body"); + w.close("w:document"); + w.into_bytes() +} + +fn write_notes( + doc: &Document, + ns_refs: &[(&str, &str)], + wr: &mut Writer<'_>, + entries: &mut Vec<(String, Vec)>, + overrides: &mut Vec<(String, String)>, + rels: &mut PartRels, +) { + for foot in [true, false] { + let seps = if foot { &doc.footnote_separators } else { &doc.endnote_separators }; + if !notes_part(doc, wr, foot) { + continue; + } + let (root, tag, file) = if foot { ("w:footnotes", "w:footnote", "footnotes.xml") } else { ("w:endnotes", "w:endnote", "endnotes.xml") }; + let mut prels = PartRels::default(); + let mut w = W::new(); + w.open(root, ns_refs); + // The separators and continuation notice: the document's own, else the defaults (no + // notice). Word numbers them -1, 0 and 1. + for (id, kind, own) in [ + ("-1", "separator", &seps.separator), + ("0", "continuationSeparator", &seps.continuation_separator), + ("1", "continuationNotice", &seps.continuation_notice), + ] { + match own { + Some(blocks) => { + w.open(tag, &[("w:type", kind), ("w:id", id)]); + if blocks.is_empty() { + w.empty("w:p", &[]); + } else { + wr.blocks(&mut w, blocks, &mut prels, false, 0); + } + w.close(tag); + } + None if kind != "continuationNotice" => { + w.open(tag, &[("w:type", kind), ("w:id", id)]); + w.open("w:p", &[]); + w.open("w:pPr", &[]); + w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]); + w.close("w:pPr"); + w.open("w:r", &[]); + w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]); + w.close("w:r"); + w.close("w:p"); + w.close(tag); + } + None => {} + } + } + let base = note_id_base(doc, foot); + let mut k = 0; + while let Some(pid) = (if foot { &wr.footnotes } else { &wr.endnotes }).get(k).copied() { + let nid = (k + base).to_string(); + k += 1; + w.open(tag, &[("w:id", &nid)]); + let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default(); + wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid); + w.close(tag); + } + w.close(root); + let kind = if foot { rt::FOOTNOTES } else { rt::ENDNOTES }; + rels.add(kind, file, false); + push_part( + entries, + overrides, + &format!("word/{file}"), + w.into_bytes(), + &format!("{CT_WML}{}+xml", if foot { "footnotes" } else { "endnotes" }), + prels, + ); + } +} + +fn write_comments( + doc: &Document, + comment_paras: &[(u32, String)], + ns_refs: &[(&str, &str)], + wr: &mut Writer<'_>, + entries: &mut Vec<(String, Vec)>, + overrides: &mut Vec<(String, String)>, + rels: &mut PartRels, +) { + if !doc.comments.is_empty() { + let mut prels = PartRels::default(); + let mut w = W::new(); + w.open("w:comments", ns_refs); + for (cid, c) in &doc.comments { + let id = cid.to_string(); + let author = annotation_author(doc, &c.author); + let mut a = vec![("w:id", id.as_str()), ("w:author", author)]; + if !c.date.is_empty() && !doc.settings.remove_date_time { + a.push(("w:date", c.date.as_str())); + } + if doc.settings.remove_personal_info { + a.push(("w:initials", ANONYMOUS_INITIALS)); + } else if !c.initials.is_empty() { + a.push(("w:initials", c.initials.as_str())); + } + w.open("w:comment", &a); + let blocks: Blocks = doc.parts.get(&c.part).map(|p| p.blocks.clone()).unwrap_or_default(); + wr.blocks(&mut w, &blocks, &mut prels, false, 0); + w.close("w:comment"); + } + w.close("w:comments"); + rels.add(rt::COMMENTS, "comments.xml", false); + push_part(entries, overrides, "word/comments.xml", w.into_bytes(), &format!("{CT_WML}comments+xml"), prels); + + let mut w = W::new(); + w.open( + "w15:commentsEx", + &[ + ("xmlns:w15", "http://schemas.microsoft.com/office/word/2012/wordml"), + ("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"), + ], + ); + let pid_of = |cid: u32| comment_paras.iter().find(|(c, _)| *c == cid).map(|(_, p)| p.clone()); + for (cid, c) in &doc.comments { + let Some(pid) = pid_of(*cid) else { continue }; + let done = if c.resolved { "1" } else { "0" }; + match c.parent.and_then(pid_of) { + Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:paraIdParent", &parent), ("w15:done", done)]), + None => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:done", done)]), + } + } + w.close("w15:commentsEx"); + rels.add(rt::COMMENTS_EX, "commentsExtended.xml", false); + push_part(entries, overrides, "word/commentsExtended.xml", w.into_bytes(), &format!("{CT_WML}commentsExtended+xml"), PartRels::default()); + write_comment_ids(doc, comment_paras, entries, overrides, rels); + } +} + pub(super) fn push_part( entries: &mut Vec<(String, Vec)>, overrides: &mut Vec<(String, String)>, diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index aba9fdf464..b67ee6b076 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -253,6 +253,40 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha toggle(w, "w:keepNext", p.keep_next); toggle(w, "w:keepLines", p.keep_lines); toggle(w, "w:pageBreakBefore", p.page_break_before); + write_frame(w, p, framed); + toggle(w, "w:widowControl", p.widow_control); + if let Some(nr) = p.numbering { + w.open("w:numPr", &[]); + w.val("w:ilvl", &n(nr.level.min(8) as i64)); + w.val("w:numId", &n(nr.num as i64)); + if let Some(a) = num_change { + let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect(); + w.empty("w:numberingChange", &refs); + } + w.close("w:numPr"); + } + toggle(w, "w:suppressLineNumbers", p.suppress_line_numbers); + if let Some(b) = &p.borders { + borders(w, "w:pBdr", b, Some("w:between"), &[]); + } + if let Some(s) = p.shading { + w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]); + } + write_tabs(w, p); + write_text_flow(w, p); + write_spacing(w, p); + write_indentation(w, p); + toggle(w, "w:contextualSpacing", p.contextual_spacing); + toggle(w, "w:mirrorIndents", p.mirror_indents); + if let Some(a) = p.align { + w.val("w:jc", align_val(a)); + } + if let Some(l) = p.outline_level { + w.val("w:outlineLvl", &n(l.min(9) as i64)); + } +} + +fn write_frame(w: &mut W, p: &ParaProps, framed: bool) { if framed && let Some(lines) = p.drop_cap.filter(|l| *l > 0) { // ECMA-376 §17.3.1.11: the drop cap's frame — dropped or in the margin, the lines it // spans and its distance from the text (`w:hSpace`, twips). @@ -295,24 +329,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.empty("w:framePr", &a); } - toggle(w, "w:widowControl", p.widow_control); - if let Some(nr) = p.numbering { - w.open("w:numPr", &[]); - w.val("w:ilvl", &n(nr.level.min(8) as i64)); - w.val("w:numId", &n(nr.num as i64)); - if let Some(a) = num_change { - let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect(); - w.empty("w:numberingChange", &refs); - } - w.close("w:numPr"); - } - toggle(w, "w:suppressLineNumbers", p.suppress_line_numbers); - if let Some(b) = &p.borders { - borders(w, "w:pBdr", b, Some("w:between"), &[]); - } - if let Some(s) = p.shading { - w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]); - } +} + +fn write_tabs(w: &mut W, p: &ParaProps) { if let Some(tabs) = &p.tabs && !tabs.is_empty() { @@ -334,6 +353,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.close("w:tabs"); } +} + +fn write_text_flow(w: &mut W, p: &ParaProps) { toggle(w, "w:suppressAutoHyphens", p.suppress_hyphens); toggle(w, "w:kinsoku", p.kinsoku); toggle(w, "w:wordWrap", p.word_wrap); @@ -343,6 +365,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha toggle(w, "w:autoSpaceDN", p.auto_space_dn); toggle(w, "w:bidi", p.bidi); toggle(w, "w:snapToGrid", p.snap_to_grid); +} + +fn write_spacing(w: &mut W, p: &ParaProps) { if p.space_before.is_some() || p.space_after.is_some() || p.line_spacing.is_some() @@ -375,6 +400,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.empty("w:spacing", &a); } +} + +fn write_indentation(w: &mut W, p: &ParaProps) { let chars = [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars]; if p.indent_left.is_some() || p.indent_right.is_some() || p.indent_first.is_some() || chars.iter().any(Option::is_some) { let l = p.indent_left.map(twips); @@ -404,14 +432,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.empty("w:ind", &a); } - toggle(w, "w:contextualSpacing", p.contextual_spacing); - toggle(w, "w:mirrorIndents", p.mirror_indents); - if let Some(a) = p.align { - w.val("w:jc", align_val(a)); - } - if let Some(l) = p.outline_level { - w.val("w:outlineLvl", &n(l.min(9) as i64)); - } } pub fn margins(w: &mut W, tag: &str, m: &[f32; 4]) { diff --git a/crates/docx/src/write/settings.rs b/crates/docx/src/write/settings.rs index 4359055ba5..df4e77fa64 100644 --- a/crates/docx/src/write/settings.rs +++ b/crates/docx/src/write/settings.rs @@ -1,5 +1,5 @@ use super::parts::push_part; -use super::*; +use super::{CT_WML, Document, PartRels, W, Writer, math, rt, story, xml}; /// The file id of the first footnote or endnote: after the separators (-1, 0) and the /// continuation notice (1), when the document has one. @@ -102,22 +102,41 @@ pub(super) fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, temp } } w.val("w:characterSpacingControl", "doNotCompress"); + write_note_settings(&mut w, s, s_notes, footnotes, endnotes); + write_compatibility_settings(&mut w, doc); + if let Some(m) = &s.math { + math::math_pr(&mut w, m); + } + w.close("w:settings"); + w.into_bytes() +} + +fn write_note_settings( + w: &mut W, + settings: &wordcraft_doc::Settings, + separators: (&wordcraft_doc::NoteSeparators, &wordcraft_doc::NoteSeparators), + footnotes: bool, + endnotes: bool, +) { for (tag, fmt, pr, used, el) in [ - ("w:footnotePr", s.footnote_format, &s.footnote_pr, footnotes, "w:footnote"), - ("w:endnotePr", s.endnote_format, &s.endnote_pr, endnotes, "w:endnote"), + ("w:footnotePr", settings.footnote_format, &settings.footnote_pr, footnotes, "w:footnote"), + ("w:endnotePr", settings.endnote_format, &settings.endnote_pr, endnotes, "w:endnote"), ] { w.open(tag, &[]); - story::note_pr_inner(&mut w, &wordcraft_doc::section::NoteProps { num_fmt: Some(fmt), ..*pr }); + story::note_pr_inner(w, &wordcraft_doc::section::NoteProps { num_fmt: Some(fmt), ..*pr }); if used { w.empty(el, &[("w:id", "-1")]); w.empty(el, &[("w:id", "0")]); - let seps = if el == "w:footnote" { &s_notes.0 } else { &s_notes.1 }; + let seps = if el == "w:footnote" { separators.0 } else { separators.1 }; if seps.continuation_notice.is_some() { w.empty(el, &[("w:id", "1")]); } } w.close(tag); } +} + +fn write_compatibility_settings(w: &mut W, doc: &Document) { w.open("w:compat", &[]); // CT_Compat: the older switches come before the `w:compatSetting`s. if doc.settings.do_not_expand_shift_return { @@ -126,17 +145,12 @@ pub(super) fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, temp if doc.settings.sum_paragraph_spacing { w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]); } - if s.allow_space_of_same_style_in_table { + if doc.settings.allow_space_of_same_style_in_table { w.empty("w:allowSpaceOfSameStyleInTable", &[]); } let mode = doc.settings.compat_mode.clamp(11, 15).to_string(); w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", &mode)]); w.close("w:compat"); - if let Some(m) = &s.math { - math::math_pr(&mut w, m); - } - w.close("w:settings"); - w.into_bytes() } /// Is `d` a usable `w16cid:durableId`: 1–8 hex digits, below 0x7FFFFFFF, not 0? diff --git a/crates/docx/src/write/story/blocks.rs b/crates/docx/src/write/story/blocks.rs index f85995a831..bdb2fb5427 100644 --- a/crates/docx/src/write/story/blocks.rs +++ b/crates/docx/src/write/story/blocks.rs @@ -1,5 +1,5 @@ use super::toc::written_toc_span; -use super::*; +use super::{Block, Blocks, CharProps, MAX_DEPTH, PartRels, Table, W, Writer, tblpr, tcpr, trpr, twips}; impl Writer<'_> { fn write_trailing_para(&mut self, w: &mut W, depth: usize) { diff --git a/crates/docx/src/write/story/drawing.rs b/crates/docx/src/write/story/drawing.rs index 0fa653bb05..1c819fa66a 100644 --- a/crates/docx/src/write/story/drawing.rs +++ b/crates/docx/src/write/story/drawing.rs @@ -1,5 +1,16 @@ use super::object::ink_name; -use super::*; +use super::{ + AltText, Anchor, Fill, Float, GradientFlip, GradientKind, InlineObject, PartRels, PathShape, Rgb, ShapeEffects, ShapeKind, W, Wrap, Writer, emu, + n, rt, +}; + +pub(super) struct DrawingInfo<'a> { + pub float: &'a Float, + pub size: (f32, f32), + pub docpr: &'a str, + pub name: &'a str, + pub alt: &'a str, +} impl Writer<'_> { /// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0). @@ -198,28 +209,19 @@ impl Writer<'_> { w.close("wps:wsp"); } - #[allow(clippy::too_many_arguments)] /// A `w:drawing` holding `graphic` (an `a:graphic`) in a frame of the object's size and place. - #[allow(clippy::too_many_arguments)] - pub(super) fn graphic_frame( - &mut self, - w: &mut W, - float: &Float, - (gw, gh): (f32, f32), - docpr: &str, - name: &str, - alt: &str, - graphic: &crate::xml::El, - ) { + pub(super) fn graphic_frame(&mut self, w: &mut W, info: DrawingInfo<'_>, graphic: &crate::xml::El) { + let inline = info.float.wrap == Wrap::Inline; w.open("w:drawing", &[]); - self.drawing_open(w, float, gw, gh, docpr, name, alt); + self.drawing_open(w, info); w.empty("wp:cNvGraphicFramePr", &[]); w.raw(&graphic.to_xml()); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close(if inline { "wp:inline" } else { "wp:anchor" }); w.close("w:drawing"); } - pub(super) fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) { + pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) { + let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info; let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0))); if float.wrap == Wrap::Inline { w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]); diff --git a/crates/docx/src/write/story/field.rs b/crates/docx/src/write/story/field.rs index 7aecffc74e..b1eec8116e 100644 --- a/crates/docx/src/write/story/field.rs +++ b/crates/docx/src/write/story/field.rs @@ -1,8 +1,17 @@ use super::toc::is_toc; -use super::*; +use super::{CharProps, W, Writer, rpr}; + +pub(super) struct FieldData<'a> { + pub instr: &'a str, + pub result: &'a str, + pub locked: bool, + pub dirty: bool, + pub form: &'a str, +} impl Writer<'_> { - pub(super) fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, dirty: bool, form: &str, props: &CharProps) { + pub(super) fn field(&mut self, w: &mut W, field: FieldData<'_>, props: &CharProps) { + let FieldData { instr, result, locked, dirty, form } = field; if self.toc_hold_end && is_toc(instr) { // A TOC heading's field opens in the first entry and closes after the last (see `toc_span`). self.toc_hold_end = false; diff --git a/crates/docx/src/write/story/object.rs b/crates/docx/src/write/story/object.rs index 0fc089f07d..340ca8fce3 100644 --- a/crates/docx/src/write/story/object.rs +++ b/crates/docx/src/write/story/object.rs @@ -1,7 +1,7 @@ -use super::drawing::{shape_name, spin_attrs, write_fill, xfrm_spin_only}; -use super::field::field_run; +use super::drawing::{DrawingInfo, shape_name, spin_attrs, write_fill, xfrm_spin_only}; +use super::field::{FieldData, field_run}; use super::section::comment_reference_style; -use super::*; +use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr}; impl Writer<'_> { pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) { @@ -56,7 +56,9 @@ impl Writer<'_> { None => crate::write::math::write_equation(w, linear, *display, math, color), } } - InlineObject::Field { instr, result, locked, dirty, form, .. } => self.field(w, instr, result, *locked, *dirty, form, props), + InlineObject::Field { instr, result, locked, dirty, form, .. } => { + self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props) + } InlineObject::FieldStart { instr, locked, dirty } => { self.rev_open(w, props); self.field_start(w, instr, *locked, *dirty, "", props); @@ -111,223 +113,10 @@ impl Writer<'_> { } // Editable charts and SmartArt keep main's model writer; opaque graphics and native // ink keep their original source and related parts. - InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => { - let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink; - let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) { - (Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(), - (None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(), - // Ink in a group member's form (no drawing of its own) can't stand alone: left - // out before its parts are queued, so no orphan part or relationship is written. - (None, None, Some(src)) if ink && !standalone_ink(src) => { - log::warn!("docx: group-member ink outside its group can't be written; it is left out"); - None - } - (None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))), - (None, None, None) => None, - }; - let Some(mut root) = root else { return }; - let direct_ink = ink && root.name == "w:contentPart"; - if ink && !direct_ink { - Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0); - } - // Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture. - let alternate = (root.name == "mc:AlternateContent").then_some(&root); - let fallback = alternate.and_then(|e| e.child("mc:Fallback")); - let choice = alternate.and_then(|e| e.child("mc:Choice")); - let native = choice.and_then(|e| e.find("a:graphic")); - if alternate.is_some() && native.is_none() { - return; - } - // A chart's picture gets the chart's current frame too; ink's VML preview is resized in place. - let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) }; - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - if direct_ink { - w.raw(&root.to_xml()); - } else { - if fallback.is_some() { - let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() }; - w.open("mc:AlternateContent", &[]); - w.open("mc:Choice", &[("Requires", requires)]); - } - let docpr = self.next_docpr(); - let name = match graphic.kind { - wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"), - wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"), - wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"), - }; - let z = self.z; - self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, native.unwrap_or(&root)); - if let Some(fallback) = fallback { - w.close("mc:Choice"); - match picture { - Some(picture) => { - // Both branches are one object: same id and stacking order. - self.z = z; - w.open("mc:Fallback", &[]); - self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, picture); - w.close("mc:Fallback"); - } - None => w.raw(&fallback.to_xml()), - } - w.close("mc:AlternateContent"); - } - } - w.close("w:r"); - self.rev_close(w, props); - } - InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } => { - // An OLE object read from a file: the object itself, at its current size. - if let Some(src) = ole.as_deref() - && let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str()))) - { - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - w.raw(&obj); - w.close("w:r"); - self.rev_close(w, props); - return; - } - let Some(file) = self.media_files.get(media).cloned() else { return }; - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - w.open("w:drawing", &[]); - let docpr = self.next_docpr(); - let name = format!("Picture {docpr}"); - self.drawing_open(w, float, *iw, *ih, &docpr, &name, alt); - w.empty("wp:cNvGraphicFramePr", &[]); - w.open("a:graphic", &[]); - w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]); - self.pic(w, o, &file, (0.0, 0.0), rels); - w.close("a:graphicData"); - w.close("a:graphic"); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); - w.close("w:drawing"); - w.close("w:r"); - self.rev_close(w, props); - } - InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } => { - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - w.open("w:drawing", &[]); - let docpr = self.shape_docpr(extra.id); - // Ink is told apart by its name (and its pen), which reading looks for. - let name = shape_name(*kind, freeform.as_deref(), &docpr); - // The effect extent leaves room for the shadow and glow. - let mut float = float.clone(); - for (e, fx) in float.effect.iter_mut().zip(effects.extent()) { - *e = wordcraft_geom::finite(*e).max(fx); - } - // Tight and Through wrapping need a polygon: the shape's outline unless it has one. - if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() { - float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner) - .and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts)) - .map(std::sync::Arc::new); - } - let float = &float; - self.drawing_open(w, float, *sw, *sh, &docpr, &name, o.alt_text()); - w.empty("wp:cNvGraphicFramePr", &[]); - w.open("a:graphic", &[]); - w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]); - self.wsp(w, o, None, (0.0, 0.0), rels, depth); - w.close("a:graphicData"); - w.close("a:graphic"); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); - w.close("w:drawing"); - w.close("w:r"); - self.rev_close(w, props); - } - InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children, canvas } => { - self.rev_open(w, props); - w.open("w:r", &[]); - self.rpr(w, props); - w.open("w:drawing", &[]); - let docpr = self.next_docpr(); - let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" }); - self.drawing_open(w, float, *gw, *gh, &docpr, &name, o.alt_text()); - w.empty("wp:cNvGraphicFramePr", &[]); - w.open("a:graphic", &[]); - let outer = match canvas { - Some(c) => { - // A Drawing Canvas: its background (`wpc:bg`) and outline (`wpc:whole`), then - // its members, at offsets in the canvas's own (unscaled) space. - w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]); - w.open("wpc:wpc", &[]); - w.open("wpc:bg", &[]); - write_fill(w, c.fill.as_ref()); - w.close("wpc:bg"); - w.open("wpc:whole", &[]); - match c.stroke { - Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]), - None => w.open("a:ln", &[]), - } - write_fill(w, c.stroke.map(Fill::solid).as_ref()); - w.close("a:ln"); - w.close("wpc:whole"); - "wpc:wpc" - } - None => { - w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]); - // ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt` - // maps onto the group's `a:off`/`a:ext`. - w.open("wpg:wgp", &[]); - w.empty("wpg:cNvGrpSpPr", &[]); - w.open("wpg:grpSpPr", &[]); - w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::>()); - w.empty("a:off", &[("x", "0"), ("y", "0")]); - w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]); - w.empty("a:chOff", &[("x", "0"), ("y", "0")]); - w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]); - w.close("a:xfrm"); - w.close("wpg:grpSpPr"); - "wpg:wgp" - } - }; - for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) { - let at = (c.x, c.y); - match &c.obj { - InlineObject::Image { media, .. } => { - if let Some(file) = self.media_files.get(media).cloned() { - self.pic(w, &c.obj, &file, at, rels); - } - } - InlineObject::Shape { extra, .. } => { - let id = self.shape_docpr(extra.id); - self.wsp(w, &c.obj, Some(&id), at, rels, depth); - } - // Native ink kept from the file, with its fallback, at the member's place. - InlineObject::Graphic { w: iw, h: ih, float, graphic, .. } if graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink => { - if let Some(src) = graphic.source.as_deref() - && let Some(mut root) = self.embedded_tree(src, rels, None) - { - Self::place_member(&mut root, [c.x, c.y, *iw, *ih], float, 0); - w.raw(&root.to_xml()); - } - } - _ => {} - } - } - if canvas.is_some() && float.spin() != wordcraft_geom::Spin::default() { - // The canvas schema has no transform of its own: keep its turn in our - // extension (Word draws it unturned, as it can't turn a canvas). - w.open("wpc:extLst", &[]); - w.open("a:ext", &[("uri", crate::CANVAS_SPIN_EXT)]); - xfrm_spin_only(w, float.spin()); - w.close("a:ext"); - w.close("wpc:extLst"); - } - w.close(outer); - w.close("a:graphicData"); - w.close("a:graphic"); - w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); - w.close("w:drawing"); - w.close("w:r"); - self.rev_close(w, props); - } + InlineObject::Graphic { .. } => self.write_graphic(w, o, props, rels, depth), + InlineObject::Image { .. } => self.write_image(w, o, props, rels, depth), + InlineObject::Shape { .. } => self.write_shape(w, o, props, rels, depth), + InlineObject::Group { .. } => self.write_group(w, o, props, rels, depth), InlineObject::Ruby { base, ruby, align, size, raise, base_size, lang, props: ruby_props } => { // ECMA-376 §17.3.3.25: a run holding w:ruby (properties, ruby text, base text). let t = if self.is_del(props) { "w:delText" } else { "w:t" }; @@ -385,6 +174,231 @@ impl Writer<'_> { } } } + + fn write_graphic(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) { + let InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } = o else { return }; + let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink; + let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) { + (Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(), + (None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(), + // Ink in a group member's form (no drawing of its own) can't stand alone: left + // out before its parts are queued, so no orphan part or relationship is written. + (None, None, Some(src)) if ink && !standalone_ink(src) => { + log::warn!("docx: group-member ink outside its group can't be written; it is left out"); + None + } + (None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))), + (None, None, None) => None, + }; + let Some(mut root) = root else { return }; + let direct_ink = ink && root.name == "w:contentPart"; + if ink && !direct_ink { + Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0); + } + // Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture. + let alternate = (root.name == "mc:AlternateContent").then_some(&root); + let fallback = alternate.and_then(|e| e.child("mc:Fallback")); + let choice = alternate.and_then(|e| e.child("mc:Choice")); + let native = choice.and_then(|e| e.find("a:graphic")); + if alternate.is_some() && native.is_none() { + return; + } + // A chart's picture gets the chart's current frame too; ink's VML preview is resized in place. + let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) }; + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + if direct_ink { + w.raw(&root.to_xml()); + } else { + if fallback.is_some() { + let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() }; + w.open("mc:AlternateContent", &[]); + w.open("mc:Choice", &[("Requires", requires)]); + } + let docpr = self.next_docpr(); + let name = match graphic.kind { + wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"), + wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"), + wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"), + }; + let z = self.z; + self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root)); + if let Some(fallback) = fallback { + w.close("mc:Choice"); + match picture { + Some(picture) => { + // Both branches are one object: same id and stacking order. + self.z = z; + w.open("mc:Fallback", &[]); + self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture); + w.close("mc:Fallback"); + } + None => w.raw(&fallback.to_xml()), + } + w.close("mc:AlternateContent"); + } + } + w.close("w:r"); + self.rev_close(w, props); + } + + fn write_image(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) { + let InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } = o else { return }; + // An OLE object read from a file: the object itself, at its current size. + if let Some(src) = ole.as_deref() + && let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str()))) + { + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.raw(&obj); + w.close("w:r"); + self.rev_close(w, props); + return; + } + let Some(file) = self.media_files.get(media).cloned() else { return }; + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.open("w:drawing", &[]); + let docpr = self.next_docpr(); + let name = format!("Picture {docpr}"); + self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt }); + w.empty("wp:cNvGraphicFramePr", &[]); + w.open("a:graphic", &[]); + w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]); + self.pic(w, o, &file, (0.0, 0.0), rels); + w.close("a:graphicData"); + w.close("a:graphic"); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + w.close("w:r"); + self.rev_close(w, props); + } + + fn write_shape(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) { + let InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } = o else { return }; + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.open("w:drawing", &[]); + let docpr = self.shape_docpr(extra.id); + // Ink is told apart by its name (and its pen), which reading looks for. + let name = shape_name(*kind, freeform.as_deref(), &docpr); + // The effect extent leaves room for the shadow and glow. + let mut float = float.clone(); + for (e, fx) in float.effect.iter_mut().zip(effects.extent()) { + *e = wordcraft_geom::finite(*e).max(fx); + } + // Tight and Through wrapping need a polygon: the shape's outline unless it has one. + if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() { + float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner) + .and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts)) + .map(std::sync::Arc::new); + } + let float = &float; + self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() }); + w.empty("wp:cNvGraphicFramePr", &[]); + w.open("a:graphic", &[]); + w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]); + self.wsp(w, o, None, (0.0, 0.0), rels, depth); + w.close("a:graphicData"); + w.close("a:graphic"); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + w.close("w:r"); + self.rev_close(w, props); + } + + fn write_group(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) { + let InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children, canvas } = o else { return }; + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.open("w:drawing", &[]); + let docpr = self.next_docpr(); + let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" }); + self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() }); + w.empty("wp:cNvGraphicFramePr", &[]); + w.open("a:graphic", &[]); + let outer = match canvas { + Some(c) => { + // A Drawing Canvas: its background (`wpc:bg`) and outline (`wpc:whole`), then + // its members, at offsets in the canvas's own (unscaled) space. + w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]); + w.open("wpc:wpc", &[]); + w.open("wpc:bg", &[]); + write_fill(w, c.fill.as_ref()); + w.close("wpc:bg"); + w.open("wpc:whole", &[]); + match c.stroke { + Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]), + None => w.open("a:ln", &[]), + } + write_fill(w, c.stroke.map(Fill::solid).as_ref()); + w.close("a:ln"); + w.close("wpc:whole"); + "wpc:wpc" + } + None => { + w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]); + // ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt` + // maps onto the group's `a:off`/`a:ext`. + w.open("wpg:wgp", &[]); + w.empty("wpg:cNvGrpSpPr", &[]); + w.open("wpg:grpSpPr", &[]); + w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::>()); + w.empty("a:off", &[("x", "0"), ("y", "0")]); + w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]); + w.empty("a:chOff", &[("x", "0"), ("y", "0")]); + w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]); + w.close("a:xfrm"); + w.close("wpg:grpSpPr"); + "wpg:wgp" + } + }; + for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) { + let at = (c.x, c.y); + match &c.obj { + InlineObject::Image { media, .. } => { + if let Some(file) = self.media_files.get(media).cloned() { + self.pic(w, &c.obj, &file, at, rels); + } + } + InlineObject::Shape { extra, .. } => { + let id = self.shape_docpr(extra.id); + self.wsp(w, &c.obj, Some(&id), at, rels, depth); + } + // Native ink kept from the file, with its fallback, at the member's place. + InlineObject::Graphic { w: iw, h: ih, float, graphic, .. } if graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink => { + if let Some(src) = graphic.source.as_deref() + && let Some(mut root) = self.embedded_tree(src, rels, None) + { + Self::place_member(&mut root, [c.x, c.y, *iw, *ih], float, 0); + w.raw(&root.to_xml()); + } + } + _ => {} + } + } + if canvas.is_some() && float.spin() != wordcraft_geom::Spin::default() { + // The canvas schema has no transform of its own: keep its turn in our + // extension (Word draws it unturned, as it can't turn a canvas). + w.open("wpc:extLst", &[]); + w.open("a:ext", &[("uri", crate::CANVAS_SPIN_EXT)]); + xfrm_spin_only(w, float.spin()); + w.close("a:ext"); + w.close("wpc:extLst"); + } + w.close(outer); + w.close("a:graphicData"); + w.close("a:graphic"); + w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); + w.close("w:drawing"); + w.close("w:r"); + self.rev_close(w, props); + } } pub(super) fn ink_name(tool: wordcraft_doc::freeform::InkTool) -> &'static str { diff --git a/crates/docx/src/write/story/para.rs b/crates/docx/src/write/story/para.rs index f8c999df70..712a031993 100644 --- a/crates/docx/src/write/story/para.rs +++ b/crates/docx/src/write/story/para.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + ChangeAttrs, CharProps, InlineObject, Paragraph, PartRels, Piece, PropChange, RevisionKind, W, Writer, open_change, ppr_inner, rpr, rpr_inner, rt, +}; impl Writer<'_> { pub(super) fn para(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize) { diff --git a/crates/docx/src/write/story/section.rs b/crates/docx/src/write/story/section.rs index fdf91ccf6e..e9712ddcc5 100644 --- a/crates/docx/src/write/story/section.rs +++ b/crates/docx/src/write/story/section.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LineNumberRestart, NoteProps, PartRels, SectionProps, SectionStart, W, Writer, borders, n, open_change, twips}; impl Writer<'_> { /// The section's headers, plus the header holding only the watermark in each slot the diff --git a/crates/docx/src/write/story/toc.rs b/crates/docx/src/write/story/toc.rs index 7ecd76b430..47c1041b75 100644 --- a/crates/docx/src/write/story/toc.rs +++ b/crates/docx/src/write/story/toc.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Blocks, InlineObject, W, Writer}; impl Writer<'_> { pub(super) fn update_toc_before_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: bool, w: &mut W) { diff --git a/crates/docx/src/write/styles.rs b/crates/docx/src/write/styles.rs index 31a3246061..e750ba32e0 100644 --- a/crates/docx/src/write/styles.rs +++ b/crates/docx/src/write/styles.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, Level, LevelSuffix, ParaProps, Style, StyleKind, TablePart, VAlign, W, props, xml}; /// Built-in names Word keeps in lower case in styles.xml. fn file_style_name(name: &str) -> String { diff --git a/crates/docx/tests/it/content_controls.rs b/crates/docx/tests/it/content_controls.rs index 7aab1415ed..63b79c4c4f 100644 --- a/crates/docx/tests/it/content_controls.rs +++ b/crates/docx/tests/it/content_controls.rs @@ -109,7 +109,13 @@ fn by_id(d: &Document, id: i64) -> ContentControl { #[test] fn every_kind_reads_with_its_properties() { let d = read(&form()).unwrap(); - let intro = controls(&d).into_iter().find(|c| c.title == "Intro").unwrap(); + assert_intro_control(&d); + assert_control_kinds(&d); + assert_table_controls(&d); +} + +fn assert_intro_control(d: &Document) { + let intro = controls(d).into_iter().find(|c| c.title == "Intro").unwrap(); assert!(intro.block && intro.showing_placeholder); assert_eq!((intro.tag.as_str(), intro.id, intro.lock), ("intro", Some(-1201), ControlLock::SdtLocked)); assert_eq!(intro.placeholder, "DefaultPlaceholder_1"); @@ -119,13 +125,15 @@ fn every_kind_reads_with_its_properties() { let ranges = d.control_ranges(StoryRef::Body); let r = ranges.iter().find(|r| r.control.title == "Intro").unwrap(); assert_eq!(d.control_text(r), "Write the introduction.\nSecond paragraph"); +} - assert_eq!(by_id(&d, 7).kind, ControlKind::Text { multi_line: true }); - assert!(!by_id(&d, 7).block); +fn assert_control_kinds(d: &Document) { + assert_eq!(by_id(d, 7).kind, ControlKind::Text { multi_line: true }); + assert!(!by_id(d, 7).block); assert!( - matches!(by_id(&d, 8).kind, ControlKind::CheckBox { checked: true, checked_char: '\u{2612}', ref checked_font, .. } if checked_font == "Symbola") + matches!(by_id(d, 8).kind, ControlKind::CheckBox { checked: true, checked_char: '\u{2612}', ref checked_font, .. } if checked_font == "Symbola") ); - let dd = by_id(&d, 9); + let dd = by_id(d, 9); assert_eq!(dd.lock, ControlLock::ContentLocked); assert_eq!( dd.kind, @@ -135,14 +143,17 @@ fn every_kind_reads_with_its_properties() { } ); assert!( - matches!(by_id(&d, 10).kind, ControlKind::Date { ref full_date, ref format, .. } if full_date == "2026-10-11T00:00:00Z" && format == "d.M.yyyy") + matches!(by_id(d, 10).kind, ControlKind::Date { ref full_date, ref format, .. } if full_date == "2026-10-11T00:00:00Z" && format == "d.M.yyyy") ); - assert_eq!(by_id(&d, 10).extra.len(), 1, "data binding kept"); - assert_eq!(by_id(&d, 12).kind, ControlKind::RichText); - assert!(matches!(by_id(&d, 13).kind, ControlKind::Gallery { list: false, ref gallery, unique: true, .. } if gallery == "Cover Pages")); - assert!(matches!(by_id(&d, 20).kind, ControlKind::RepeatingSection { ref title, .. } if title == "Items")); - assert_eq!(by_id(&d, 21).kind, ControlKind::RepeatingSectionItem); - assert_eq!(by_id(&d, 23).kind, ControlKind::Picture); + assert_eq!(by_id(d, 10).extra.len(), 1, "data binding kept"); + assert_eq!(by_id(d, 12).kind, ControlKind::RichText); + assert!(matches!(by_id(d, 13).kind, ControlKind::Gallery { list: false, ref gallery, unique: true, .. } if gallery == "Cover Pages")); + assert!(matches!(by_id(d, 20).kind, ControlKind::RepeatingSection { ref title, .. } if title == "Items")); + assert_eq!(by_id(d, 21).kind, ControlKind::RepeatingSectionItem); + assert_eq!(by_id(d, 23).kind, ControlKind::Picture); +} + +fn assert_table_controls(d: &Document) { // Row-level controls sit on the rows, the cell-level one on its cell, the table's on the table. let tables: Vec<_> = d.body.iter().filter_map(|b| b.as_table()).collect(); assert_eq!(tables.len(), 2); diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index a7c382660f..5d9e128fba 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -495,6 +495,12 @@ fn styles_numbering_settings_notes_comments_from_parts() { ("docProps/core.xml", core.as_bytes()), ]); let d = wordcraft_docx::read(&bytes).unwrap(); + assert_imported_styles(&d); + assert_imported_numbering_and_settings(&d); + assert_imported_body_and_metadata(&d); +} + +fn assert_imported_styles(d: &Document) { // Styles: localized default id renamed to Normal; display names mapped. let h = d.styles.get("1").unwrap(); assert_eq!(h.name, "Heading 1"); @@ -511,6 +517,9 @@ fn styles_numbering_settings_notes_comments_from_parts() { assert_eq!(g.header_chr.bold, Some(true)); assert_eq!(d.settings.theme_colors[4], wordcraft_doc::Rgb(0x44, 0x72, 0xC4)); assert_eq!(d.settings.major_font, "Major Face"); +} + +fn assert_imported_numbering_and_settings(d: &Document) { // Numbering. let lv = d.numbering.level(2, 0).unwrap(); assert_eq!((lv.start, lv.text.as_str(), lv.indent, lv.hanging, lv.align), (3, "%1)", 36.0, 18.0, Align::Right)); @@ -525,8 +534,11 @@ fn styles_numbering_settings_notes_comments_from_parts() { // A hostile start is clamped, an unknown position left out. let fp = d.settings.footnote_pr; assert_eq!((fp.pos, fp.num_start, fp.num_restart), (None, Some(1), Some(wordcraft_doc::section::NoteRestart::EachPage))); +} + +fn assert_imported_body_and_metadata(d: &Document) { // Body. - let p = paras(&d); + let p = paras(d); assert_eq!(p[0].props.style.as_deref(), Some("1")); assert_eq!(p[0].props.numbering, Some(wordcraft_doc::props::NumRef { num: 2, level: 1 })); assert_eq!(p[0].props_of_char(4).font.as_deref(), Some("Major Face")); diff --git a/crates/docx/tests/it/roundtrip.rs b/crates/docx/tests/it/roundtrip.rs index 8ba6cc0d61..55a532834d 100644 --- a/crates/docx/tests/it/roundtrip.rs +++ b/crates/docx/tests/it/roundtrip.rs @@ -2262,9 +2262,13 @@ fn bibliography_sources_round_trip() { }); let bytes = wordcraft_docx::write(&d).expect("write"); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); - // Verify package-root customXml parts exist + assert_bibliography_parts(&bytes); + assert_bibliography_sources_round_trip(&bytes); +} + +fn assert_bibliography_parts(bytes: &[u8]) { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); assert!(zip.by_name("customXml/item1.xml").is_ok()); assert!(zip.by_name("customXml/itemProps1.xml").is_ok()); assert!(zip.by_name("customXml/_rels/item1.xml.rels").is_ok()); @@ -2281,6 +2285,12 @@ fn bibliography_sources_round_trip() { let mut item_xml = String::new(); std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap(); + assert_bibliography_types(&item_xml); + assert_bibliography_people(&item_xml); + assert_bibliography_details(&item_xml); +} + +fn assert_bibliography_types(item_xml: &str) { assert!(item_xml.contains("SelectedStyle=\"\\APA.XSL\""), "{item_xml}"); assert!(item_xml.contains("StyleName=\"APA\""), "{item_xml}"); assert!(item_xml.contains("Book"), "{item_xml}"); @@ -2288,12 +2298,18 @@ fn bibliography_sources_round_trip() { assert!(item_xml.contains("InternetSite"), "{item_xml}"); assert!(item_xml.contains("BookSection"), "{item_xml}"); assert!(item_xml.contains("Misc"), "{item_xml}"); +} + +fn assert_bibliography_people(item_xml: &str) { assert!(item_xml.contains(""), "{item_xml}"); assert!(item_xml.contains("RiveraAlex"), "{item_xml}"); assert!(item_xml.contains("SmithJohnDavid"), "{item_xml}"); assert!(item_xml.contains("Solo"), "{item_xml}"); assert!(item_xml.contains(""), "{item_xml}"); assert!(item_xml.contains("JohnsonRobert"), "{item_xml}"); +} + +fn assert_bibliography_details(item_xml: &str) { assert!(item_xml.contains("2nd"), "{item_xml}"); assert!(item_xml.contains("10"), "{item_xml}"); assert!(item_xml.contains("15"), "{item_xml}"); @@ -2303,50 +2319,68 @@ fn bibliography_sources_round_trip() { assert!(item_xml.contains("05"), "{item_xml}"); assert!(item_xml.contains("15"), "{item_xml}"); assert!(item_xml.contains("2024-05-15"), "{item_xml}"); +} - let back = wordcraft_docx::read(&bytes).expect("read"); +fn assert_bibliography_sources_round_trip(bytes: &[u8]) { + let back = wordcraft_docx::read(bytes).expect("read"); assert_eq!(back.sources.len(), 5); - assert_eq!(back.sources[0].tag, "Rivera2021"); - assert_eq!(back.sources[0].kind, "book"); - assert_eq!(back.sources[0].author, "Rivera, Alex; Chen, Mei"); - assert_eq!(back.sources[0].title, "Shared Spaces"); - assert_eq!(back.sources[0].year, "2021"); - assert_eq!(back.sources[0].publisher, "Harbor Books"); - assert_eq!(back.sources[0].city, "Portland"); - assert_eq!(back.sources[0].editor, "Johnson, Robert"); - assert_eq!(back.sources[0].edition, "2nd"); - assert_eq!(back.sources[0].month, "10"); - assert_eq!(back.sources[0].day, "15"); + assert_bibliography_source_0(&back.sources[0]); + assert_bibliography_source_1(&back.sources[1]); + assert_bibliography_source_2(&back.sources[2]); + assert_bibliography_source_3(&back.sources[3]); + assert_bibliography_source_4(&back.sources[4]); +} - assert_eq!(back.sources[1].tag, "Smith2023"); - assert_eq!(back.sources[1].kind, "article"); - assert_eq!(back.sources[1].author, "Smith, John David"); - assert_eq!(back.sources[1].title, "Modern Typography"); - assert_eq!(back.sources[1].journal, "Design Quarterly"); - assert_eq!(back.sources[1].volume, "12"); - assert_eq!(back.sources[1].pages, "45-60"); - assert_eq!(back.sources[1].url, "https://example.com/typo"); - assert_eq!(back.sources[1].issue, "4"); - assert_eq!(back.sources[1].doi, "10.1000/182"); +fn assert_bibliography_source_0(source: &Source) { + assert_eq!(source.tag, "Rivera2021"); + assert_eq!(source.kind, "book"); + assert_eq!(source.author, "Rivera, Alex; Chen, Mei"); + assert_eq!(source.title, "Shared Spaces"); + assert_eq!(source.year, "2021"); + assert_eq!(source.publisher, "Harbor Books"); + assert_eq!(source.city, "Portland"); + assert_eq!(source.editor, "Johnson, Robert"); + assert_eq!(source.edition, "2nd"); + assert_eq!(source.month, "10"); + assert_eq!(source.day, "15"); +} - assert_eq!(back.sources[2].tag, "WebSite2024"); - assert_eq!(back.sources[2].kind, "website"); - assert_eq!(back.sources[2].author, "Solo"); - assert_eq!(back.sources[2].title, "Open Fonts"); - assert_eq!(back.sources[2].url, "https://example.org"); - assert_eq!(back.sources[2].accessed, "2024-05-15"); +fn assert_bibliography_source_1(source: &Source) { + assert_eq!(source.tag, "Smith2023"); + assert_eq!(source.kind, "article"); + assert_eq!(source.author, "Smith, John David"); + assert_eq!(source.title, "Modern Typography"); + assert_eq!(source.journal, "Design Quarterly"); + assert_eq!(source.volume, "12"); + assert_eq!(source.pages, "45-60"); + assert_eq!(source.url, "https://example.com/typo"); + assert_eq!(source.issue, "4"); + assert_eq!(source.doi, "10.1000/182"); +} - assert_eq!(back.sources[3].tag, "Chapter2025"); - assert_eq!(back.sources[3].kind, "bookSection"); - assert_eq!(back.sources[3].author, "Lee, Harper"); - assert_eq!(back.sources[3].title, "Chapter One"); - assert_eq!(back.sources[3].journal, "Anthology of Prose"); - assert_eq!(back.sources[3].pages, "1-20"); +fn assert_bibliography_source_2(source: &Source) { + assert_eq!(source.tag, "WebSite2024"); + assert_eq!(source.kind, "website"); + assert_eq!(source.author, "Solo"); + assert_eq!(source.title, "Open Fonts"); + assert_eq!(source.url, "https://example.org"); + assert_eq!(source.accessed, "2024-05-15"); +} - assert_eq!(back.sources[4].tag, "Misc2026"); - assert_eq!(back.sources[4].kind, "other"); - assert_eq!(back.sources[4].title, "Archive Manuscript"); - assert_eq!(back.sources[4].city, "Oxford"); +fn assert_bibliography_source_3(source: &Source) { + assert_eq!(source.tag, "Chapter2025"); + assert_eq!(source.kind, "bookSection"); + assert_eq!(source.author, "Lee, Harper"); + assert_eq!(source.title, "Chapter One"); + assert_eq!(source.journal, "Anthology of Prose"); + assert_eq!(source.pages, "1-20"); +} + +fn assert_bibliography_source_4(source: &Source) { + assert_eq!(source.tag, "Misc2026"); + assert_eq!(source.kind, "other"); + assert_eq!(source.title, "Archive Manuscript"); + assert_eq!(source.city, "Oxford"); } #[test] From a2435388e542cb0a450e493a33b3e70168533574 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 01:06:48 +0000 Subject: [PATCH 12/33] Refactor engine for strict Clippy checks --- crates/engine/src/cmd/citations/cross_refs.rs | 2 +- crates/engine/src/cmd/citations/generated.rs | 5 +- crates/engine/src/cmd/citations/render.rs | 2 +- crates/engine/src/cmd/citations/sources.rs | 2 +- crates/engine/src/cmd/column.rs | 3 +- crates/engine/src/cmd/fmt_revisions.rs | 31 +- crates/engine/src/cmd/insert/header_footer.rs | 5 +- crates/engine/src/cmd/insert/items.rs | 5 +- crates/engine/src/cmd/insert/links.rs | 2 +- crates/engine/src/cmd/insert/objects.rs | 5 +- crates/engine/src/cmd/mailings/filter.rs | 2 +- crates/engine/src/cmd/mailings/labels.rs | 5 +- crates/engine/src/cmd/mailings/merge.rs | 5 +- crates/engine/src/cmd/mailings/recipients.rs | 2 +- crates/engine/src/cmd/objects/arrange.rs | 2 +- crates/engine/src/cmd/objects/group.rs | 2 +- crates/engine/src/cmd/objects/picture.rs | 2 +- crates/engine/src/cmd/objects/select.rs | 2 +- crates/engine/src/cmd/objects/shape.rs | 2 +- crates/engine/src/cmd/objects/specs.rs | 2 +- crates/engine/src/cmd/para/format.rs | 2 +- crates/engine/src/cmd/para/lists.rs | 2 +- crates/engine/src/cmd/para/outline.rs | 2 +- crates/engine/src/cmd/para/styles.rs | 2 +- crates/engine/src/cmd/para/tabs_borders.rs | 2 +- crates/engine/src/cmd/references/fields.rs | 5 +- crates/engine/src/cmd/references/notes.rs | 5 +- .../engine/src/cmd/references/properties.rs | 2 +- crates/engine/src/cmd/references/toc.rs | 5 +- crates/engine/src/cmd/review/changes.rs | 13 +- crates/engine/src/cmd/review/comments.rs | 5 +- crates/engine/src/cmd/review/proofing.rs | 2 +- crates/engine/src/cmd/table/cells.rs | 2 +- crates/engine/src/cmd/table/clear.rs | 2 +- crates/engine/src/cmd/table/convert.rs | 5 +- crates/engine/src/cmd/table/layout.rs | 5 +- crates/engine/src/cmd/table/select.rs | 2 +- crates/engine/src/cmd/table/structure.rs | 2 +- crates/engine/src/cmd/text/autoformat.rs | 5 +- crates/engine/src/cmd/text/delete.rs | 2 +- crates/engine/src/cmd/text/enter_tab.rs | 2 +- crates/engine/src/cmd/text/unicode.rs | 2 +- crates/engine/src/cmd/tools/actions.rs | 2 +- crates/engine/src/cmd/tools/autocorrect.rs | 2 +- crates/engine/src/cmd/tools/compare.rs | 4 +- crates/engine/src/cmd/tools/inspect.rs | 2 +- crates/engine/src/cmd/tools/specs.rs | 5 +- crates/engine/src/cmd/tools/typing.rs | 2 +- crates/engine/src/session/history.rs | 2 +- crates/engine/src/session/ime.rs | 2 +- crates/engine/src/session/layout.rs | 2 +- crates/engine/src/session/lifecycle.rs | 2 +- crates/engine/src/session/query.rs | 2 +- crates/engine/src/session/run.rs | 302 +++++++++++------- crates/engine/src/session/snapshot.rs | 2 +- crates/engine/src/tests.rs | 15 +- .../engine/tests/it/three_column_headers.rs | 120 ++++--- 57 files changed, 378 insertions(+), 252 deletions(-) diff --git a/crates/engine/src/cmd/citations/cross_refs.rs b/crates/engine/src/cmd/citations/cross_refs.rs index f465045779..64967f0bd7 100644 --- a/crates/engine/src/cmd/citations/cross_refs.rs +++ b/crates/engine/src/cmd/citations/cross_refs.rs @@ -1,5 +1,5 @@ use super::generated::page_of; -use super::*; +use super::{CharProps, CmdError, CmdResult, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p}; pub(super) fn cross_ref(s: &mut Session, v: &Value) -> CmdResult { let to = p::str(v, "to").unwrap_or("heading"); diff --git a/crates/engine/src/cmd/citations/generated.rs b/crates/engine/src/cmd/citations/generated.rs index 2f9e580d62..f0946e10b2 100644 --- a/crates/engine/src/cmd/citations/generated.rs +++ b/crates/engine/src/cmd/citations/generated.rs @@ -2,7 +2,10 @@ use super::cross_refs::caption_label; use super::render::entry; use super::sources::bib_order; use super::styles::style; -use super::*; +use super::{ + Block, CharProps, CmdError, CmdResult, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader, TabStop, + Value, delete_selection, heading_letter, p, sel_result, sort_key, +}; /// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "